Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 61 additions & 6 deletions packages/schema/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"linkDirectory": true
},
"engines": {
"vscode": "^1.63.0"
"vscode": "^1.99.3"
},
"categories": [
"Programming Languages"
Expand Down Expand Up @@ -64,13 +64,68 @@
"type": "boolean",
"default": true,
"description": "Use Prisma style indentation."
},
"zenstack.searchForExtensions": {
"type": "boolean",
"default": true,
"description": "Search for Mermaid extensions when viewing Mermaid source."
}
}
}
},
"menus": {
"editor/title": [
{
"command": "zenstack.preview-zmodel",
"when": "editorLangId == zmodel",
"group": "navigation"
}
],
"commandPalette": [
{
"command": "zenstack.preview-zmodel",
"when": "editorLangId == zmodel"
},
{
"command": "zenstack.clear-documentation-cache"
},
{
"command": "zenstack.logout"
}
]
},
"commands": [
{
"command": "zenstack.preview-zmodel",
"title": "ZenStack: Preview ZModel Documentation",
"icon": "$(preview)"
},
{
"command": "zenstack.clear-documentation-cache",
"title": "ZenStack: Clear Documentation Cache",
"icon": "$(trash)"
},
{
"command": "zenstack.logout",
"title": "ZenStack: Logout",
"icon": "$(log-out)"
}
],
"keybindings": [
{
"command": "zenstack.preview-zmodel",
"key": "ctrl+shift+v",
"mac": "cmd+shift+v",
"when": "editorLangId == zmodel"
}
]
},
"activationEvents": [],
"capabilities": {
"untrustedWorkspaces": {
"supported": true
},
"virtualWorkspaces": true
},
"activationEvents": [
"onLanguage:zmodel"
],
"bin": {
"zenstack": "bin/cli"
},
Expand Down Expand Up @@ -127,7 +182,7 @@
"@types/strip-color": "^0.1.0",
"@types/tmp": "^0.2.3",
"@types/uuid": "^8.3.4",
"@types/vscode": "^1.56.0",
"@types/vscode": "^1.99.3",
"@vscode/vsce": "^3.5.0",
"@zenstackhq/runtime": "workspace:*",
"dotenv": "^16.0.3",
Expand Down
152 changes: 152 additions & 0 deletions packages/schema/src/documentation-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import * as vscode from 'vscode';
import { createHash } from 'crypto';

// Cache entry interface
interface CacheEntry {
data: string;
timestamp: number;
extensionVersion: string;
}

/**
* DocumentationCache class handles persistent caching of ZModel documentation
* using VS Code's globalState for cross-session persistence
*/
export class DocumentationCache implements vscode.Disposable {
private static readonly CACHE_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days cache duration
private static readonly CACHE_PREFIX = 'doc-cache.';

private extensionContext: vscode.ExtensionContext;
private extensionVersion: string;

constructor(context: vscode.ExtensionContext) {
this.extensionContext = context;
this.extensionVersion = context.extension.packageJSON.version as string;
// clear expired cache entries on initialization
this.clearExpiredCache();
}

/**
* Dispose of the cache resources (implements vscode.Disposable)
*/
dispose(): void {}

/**
* Get the cache prefix used for keys
*/
getCachePrefix(): string {
return DocumentationCache.CACHE_PREFIX;
}

/**
* Enable cache synchronization across machines via VS Code Settings Sync
*/
private enableCacheSync(): void {
const cacheKeys = this.extensionContext.globalState
.keys()
.filter((key) => key.startsWith(DocumentationCache.CACHE_PREFIX));
if (cacheKeys.length > 0) {
this.extensionContext.globalState.setKeysForSync(cacheKeys);
}
}

/**
* Generate a cache key from request body with normalized content
*/
private generateCacheKey(models: string[]): string {
// Remove ALL whitespace characters from each model string for cache key generation
// This ensures identical content with different formatting uses the same cache
const normalizedModels = models.map((model) => model.replace(/\s/g, '')).sort();
const hash = createHash('sha512')
.update(JSON.stringify({ models: normalizedModels }))
.digest('hex');
return `${DocumentationCache.CACHE_PREFIX}${hash}`;
}

/**
* Check if cache entry is still valid (not expired)
*/
private isCacheValid(entry: CacheEntry): boolean {
return Date.now() - entry.timestamp < DocumentationCache.CACHE_DURATION_MS;
}

/**
* Get cached response if available and valid
*/
async getCachedResponse(models: string[]): Promise<string | null> {
const cacheKey = this.generateCacheKey(models);
const entry = this.extensionContext.globalState.get<CacheEntry>(cacheKey);

if (entry && this.isCacheValid(entry)) {
console.log('Using cached documentation response from persistent storage');
return entry.data;
}

// Clean up expired entry if it exists
if (entry) {
await this.extensionContext.globalState.update(cacheKey, undefined);
}

return null;
}

/**
* Cache a response for future use
*/
async setCachedResponse(models: string[], data: string): Promise<void> {
const cacheKey = this.generateCacheKey(models);
const cacheEntry: CacheEntry = {
data,
timestamp: Date.now(),
extensionVersion: this.extensionVersion,
};

await this.extensionContext.globalState.update(cacheKey, cacheEntry);

// Update sync keys to include new cache entry
this.enableCacheSync();
}

/**
* Clear expired cache entries from persistent storage
*/
async clearExpiredCache(): Promise<void> {
const now = Date.now();
let clearedCount = 0;
const allKeys = this.extensionContext.globalState.keys();

for (const key of allKeys) {
if (key.startsWith(DocumentationCache.CACHE_PREFIX)) {
const entry = this.extensionContext.globalState.get<CacheEntry>(key);
if (
entry?.extensionVersion !== this.extensionVersion ||
now - entry.timestamp >= DocumentationCache.CACHE_DURATION_MS
) {
await this.extensionContext.globalState.update(key, undefined);
clearedCount++;
}
}
}

if (clearedCount > 0) {
console.log(`Cleared ${clearedCount} expired cache entries from persistent storage`);
}
}

/**
* Clear all cache entries from persistent storage
*/
async clearAllCache(): Promise<void> {
const allKeys = this.extensionContext.globalState.keys();
let clearedCount = 0;

for (const key of allKeys) {
if (key.startsWith(DocumentationCache.CACHE_PREFIX)) {
await this.extensionContext.globalState.update(key, undefined);
clearedCount++;
}
}

console.log(`Cleared all cache entries from persistent storage (${clearedCount} items)`);
}
}
40 changes: 40 additions & 0 deletions packages/schema/src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,52 @@
import * as vscode from 'vscode';
import * as path from 'path';

import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind } from 'vscode-languageclient/node';
import { AUTH_PROVIDER_ID, ZenStackAuthenticationProvider } from './zenstack-auth-provider';
import { DocumentationCache } from './documentation-cache';
import { ZModelPreview } from './zmodel-preview';
import { ReleaseNotesManager } from './release-notes-manager';

// Global variables
let client: LanguageClient;

// Utility to require authentication when needed
export async function requireAuth(): Promise<vscode.AuthenticationSession | undefined> {
let session: vscode.AuthenticationSession | undefined;

session = await vscode.authentication.getSession(AUTH_PROVIDER_ID, [], { createIfNone: false });

if (!session) {
const signIn = 'Sign in';
const selection = await vscode.window.showWarningMessage('Please sign in to use this feature', signIn);
if (selection === signIn) {
try {
session = await vscode.authentication.getSession(AUTH_PROVIDER_ID, [], { createIfNone: true });
if (session) {
vscode.window.showInformationMessage('ZenStack sign-in successful!');
}
} catch (e: unknown) {
vscode.window.showErrorMessage(
'ZenStack sign-in failed: ' + (e instanceof Error ? e.message : String(e))
);
}
}
}
return session;
}

// This function is called when the extension is activated.
export function activate(context: vscode.ExtensionContext): void {
// Initialize and register the ZenStack authentication provider
context.subscriptions.push(new ZenStackAuthenticationProvider(context));

// Start language client
client = startLanguageClient(context);

const documentationCache = new DocumentationCache(context);
context.subscriptions.push(documentationCache);
context.subscriptions.push(new ZModelPreview(context, client, documentationCache));
context.subscriptions.push(new ReleaseNotesManager(context));
}

// This function is called when the extension is deactivated.
Expand Down
48 changes: 48 additions & 0 deletions packages/schema/src/language-server/main.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,61 @@
import { startLanguageServer } from 'langium';
import { NodeFileSystem } from 'langium/node';
import { createConnection, ProposedFeatures } from 'vscode-languageserver/node';
import { URI } from 'vscode-uri';
import { createZModelServices } from './zmodel-module';
import { eagerLoadAllImports } from '../cli/cli-util';

// Create a connection to the client
const connection = createConnection(ProposedFeatures.all);

// Inject the shared services and language-specific services
const { shared } = createZModelServices({ connection, ...NodeFileSystem });

// Add custom LSP request handlers
connection.onRequest('zenstack/getAllImportedZModelURIs', async (params: { textDocument: { uri: string } }) => {
try {
const uri = URI.parse(params.textDocument.uri);
const document = shared.workspace.LangiumDocuments.getOrCreateDocument(uri);

// Ensure the document is parsed and built
if (!document.parseResult) {
await shared.workspace.DocumentBuilder.build([document]);
}

// #region merge imported documents
const langiumDocuments = shared.workspace.LangiumDocuments;

// load all imports
const importedURIs = eagerLoadAllImports(document, langiumDocuments);

const importedDocuments = importedURIs.map((uri) => langiumDocuments.getOrCreateDocument(uri));

// build the document together with standard library, plugin modules, and imported documents
await shared.workspace.DocumentBuilder.build([document, ...importedDocuments], {
validationChecks: 'all',
});

const hasSyntaxErrors = [uri, ...importedURIs].some((uri) => {
const doc = langiumDocuments.getOrCreateDocument(uri);
return (
doc.parseResult.lexerErrors.length > 0 ||
doc.parseResult.parserErrors.length > 0 ||
doc.diagnostics?.some((e) => e.severity === 1)
);
});

return {
hasSyntaxErrors,
importedURIs,
};
} catch (error) {
console.error('Error getting imported ZModel file:', error);
return {
hasSyntaxErrors: true,
importedURIs: [],
};
}
});

// Start the language server with the shared services
startLanguageServer(shared);
Loading
Loading