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
4 changes: 2 additions & 2 deletions .github/RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ Use this template when preparing a new release.
## Creating the Release

```bash
git tag v0.14.0
git push origin v0.14.0
git tag v0.15.0
git push origin v0.15.0
```

The [release workflow](../workflows/release.yml) will automatically:
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.15.0]

### Added

- Worker thread for ANTLR parsing — heavy lexer/parser/visitor work now runs off the extension host thread, eliminating ~4s UI freezes on large files
- `parseAsync()` method on `SysMLParser` with automatic fallback to synchronous parsing if the worker is unavailable
- 300ms debounce on document change events to avoid parse-per-keystroke

### Fixed

- Cytoscape SVG plugin registered twice — removed duplicate `cytoscape.use()` calls; UMD bundles already self-register on script load
- Removed unsupported `<meta http-equiv>` cache tags from visualization webview HTML (stripped by VS Code sandbox)
- Release workflow now installs Java and downloads the ANTLR jar so `vscode:prepublish` succeeds in CI

## [0.14.0]

### Added
Expand Down
9 changes: 5 additions & 4 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@
// Open the parser gate so language providers wait for us
parser.beginParseGate();

// Micro-delay (≈0 ms) so that if the user is rapidly clicking we only
// keep the very last request, but the notification appears on the next
// event-loop turn — far faster than the old 50 ms setTimeout.
// Debounce (300 ms) — wait for the user to pause typing before
// kicking off the expensive ANTLR parse. This prevents parse-per-
// keystroke and keeps the extension host responsive.
parseDebounceTimer = setTimeout(async () => {
if (cancelSource.token.isCancellationRequested || document.isClosed) {
parser.endParseGate();
Expand Down Expand Up @@ -91,7 +91,7 @@
}
cancelSource.dispose();
}
}, 0);
}, 300);
}

export function activate(context: vscode.ExtensionContext) {
Expand Down Expand Up @@ -310,7 +310,7 @@
combinedContent += Buffer.from(content).toString('utf8');
combinedContent += '\n\n';
} catch (error) {
console.warn(`Failed to read SysML file ${fileUri.fsPath}:`, error);

Check warning on line 313 in src/extension.ts

View workflow job for this annotation

GitHub Actions / test (20.x)

Unexpected console statement

Check warning on line 313 in src/extension.ts

View workflow job for this annotation

GitHub Actions / test (18.x)

Unexpected console statement
}
}

Expand Down Expand Up @@ -366,7 +366,7 @@

} catch (error) {
vscode.window.showErrorMessage(`Failed to visualize SysML: ${error}`);
console.error('Error in sysml.visualizeFolder:', error);

Check warning on line 369 in src/extension.ts

View workflow job for this annotation

GitHub Actions / test (20.x)

Unexpected console statement

Check warning on line 369 in src/extension.ts

View workflow job for this annotation

GitHub Actions / test (18.x)

Unexpected console statement
}
})
);
Expand Down Expand Up @@ -703,6 +703,7 @@
outputChannel?.appendLine('SysML v2.0 extension is now deactivated');

// Clean up resources
parser?.dispose();
if (VisualizationPanel.currentPanel) {
VisualizationPanel.currentPanel.dispose();
}
Expand Down
93 changes: 93 additions & 0 deletions src/parser/parserWorker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Worker thread for ANTLR SysML parsing.
*
* Runs the heavyweight ANTLR lexer/parser/visitor off the extension host
* thread to prevent ~4 s UI blocks on large files.
*
* IMPORTANT: This file uses only `require()` calls — no `import` statements —
* so that the vscode module mock is installed *before* any dependent module
* (antlrSysMLParser) is loaded. TypeScript hoists `import` to the top of the
* compiled output, which would cause `require('vscode')` to run before the
* mock is ready.
*/

/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */

// ── Step 1: Intercept `require('vscode')` ────────────────────────────
// Must happen before ANY module that `import * as vscode from 'vscode'`.
const nodeModule = require('module');
const nodePath = require('path');

const _origResolve = (nodeModule as any)._resolveFilename;
(nodeModule as any)._resolveFilename = function (request: string, ...args: unknown[]) {
if (request === 'vscode') {
return nodePath.join(__dirname, 'vscodeMock.js');
}
return _origResolve.call(this, request, ...args);
};

// ── Step 2: Load the ANTLR parser (now uses vscodeMock for 'vscode') ─
const { parentPort } = require('worker_threads');
const { ANTLRSysMLParser } = require('./antlrSysMLParser');

// ── Minimal TextDocument mock ─────────────────────────────────────────
// ANTLRSysMLParser.parseDocument() reads:
// • getText() – full text
// • getText(range) – text within a Range
// • lineCount – number of lines
// • uri / fileName – for error reporting

interface RangeLike {
start: { line: number; character: number };
end: { line: number; character: number };
}

function createMockDocument(text: string, uri: string) {
const lines = text.split('\n');
return {
getText(range?: RangeLike): string {
if (!range) { return text; }
const sl = range.start.line;
const sc = range.start.character;
const el = range.end.line;
const ec = range.end.character;
if (sl === el) {
return (lines[sl] || '').substring(sc, ec);
}
const parts: string[] = [];
parts.push((lines[sl] || '').substring(sc));
for (let i = sl + 1; i < el; i++) {
parts.push(lines[i] || '');
}
parts.push((lines[el] || '').substring(0, ec));
return parts.join('\n');
},
get lineCount() { return lines.length; },
get uri() { return { toString: () => uri, fsPath: uri }; },
get languageId() { return 'sysml'; },
get fileName() { return uri; },
get isClosed() { return false; },
};
}

// ── Worker message handling ───────────────────────────────────────────
// One parser instance lives for the worker's lifetime so the ANTLR DFA
// prediction cache is reused across parse requests.
const antlrParser = new ANTLRSysMLParser();

parentPort?.on('message', (msg: any) => {
if (msg.type === 'parse') {
try {
const doc = createMockDocument(msg.text, msg.uri);
const elements = antlrParser.parseDocument(doc, msg.includeErrors ?? false);
const relationships = antlrParser.getRelationships();
parentPort!.postMessage({ id: msg.id, type: 'result', elements, relationships });
} catch (err: unknown) {
parentPort!.postMessage({
id: msg.id,
type: 'error',
error: err instanceof Error ? err.message : String(err),
});
}
}
});
130 changes: 130 additions & 0 deletions src/parser/parserWorkerHost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* Manages a Worker thread for ANTLR SysML parsing.
*
* Provides an async parse API that runs the heavyweight ANTLR
* lexer / parser / visitor off the extension host thread, preventing
* the ~4 s UI block that occurs on large files.
*/

import * as path from 'path';
import * as vscode from 'vscode';
import { Worker } from 'worker_threads';
import type { Relationship, SysMLElement } from './sysmlParser';

interface PendingRequest {
resolve: (result: ParseWorkerResult) => void;
reject: (error: Error) => void;
}

export interface ParseWorkerResult {
elements: SysMLElement[];
relationships: Relationship[];
}

export class ParserWorkerHost {
private worker: Worker | null = null;
private nextId = 0;
private pending = new Map<number, PendingRequest>();

// ── Worker lifecycle ──────────────────────────────────────────────

private ensureWorker(): Worker {
if (this.worker) { return this.worker; }

const workerPath = path.join(__dirname, 'parserWorker.js');
this.worker = new Worker(workerPath);

this.worker.on('message', (msg: any) => {
const req = this.pending.get(msg.id);
if (!req) { return; } // stale / cancelled
this.pending.delete(msg.id);

if (msg.type === 'error') {
req.reject(new Error(msg.error));
} else {
const elements = this.reconstructRanges(msg.elements);
req.resolve({ elements, relationships: msg.relationships });
}
});

this.worker.on('error', (err) => {
for (const req of this.pending.values()) { req.reject(err); }
this.pending.clear();
this.worker = null; // respawn on next request
});

this.worker.on('exit', (code) => {
if (code !== 0) {
const err = new Error(`Parser worker exited with code ${code}`);
for (const req of this.pending.values()) { req.reject(err); }
this.pending.clear();
}
this.worker = null;
});

return this.worker;
}

// ── Public API ────────────────────────────────────────────────────

/**
* Parse a SysML document in a Worker thread.
* Returns the parsed elements and relationships with proper
* `vscode.Range` objects reconstructed from the worker's output.
*/
async parseDocument(
text: string,
uri: string,
includeErrors: boolean
): Promise<ParseWorkerResult> {
const worker = this.ensureWorker();
const id = ++this.nextId;

return new Promise<ParseWorkerResult>((resolve, reject) => {
this.pending.set(id, { resolve, reject });
worker.postMessage({ type: 'parse', id, text, uri, includeErrors });
});
}

/**
* Cancel all in-flight parse requests (e.g. when the user switches files).
*/
cancelAll(): void {
for (const req of this.pending.values()) {
req.reject(new Error('Parse cancelled'));
}
this.pending.clear();
}

/**
* Terminate the worker thread and release resources.
*/
dispose(): void {
this.cancelAll();
if (this.worker) {
this.worker.terminate();
this.worker = null;
}
}

// ── Deserialization ───────────────────────────────────────────────

/**
* Recursively converts plain `{start, end}` range objects that came
* through `structuredClone` into proper `vscode.Range` instances.
*/
private reconstructRanges(elements: any[]): SysMLElement[] {
for (const el of elements) {
if (el.range?.start != null && el.range?.end != null) {
el.range = new vscode.Range(
el.range.start.line, el.range.start.character,
el.range.end.line, el.range.end.character
);
}
if (el.children?.length) {
this.reconstructRanges(el.children);
}
}
return elements;
}
}
77 changes: 75 additions & 2 deletions src/parser/sysmlParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as vscode from 'vscode';
import { LibraryService } from '../library/service';
import { EnrichedElement, ResolutionResult, SemanticResolver } from '../resolver';
import type { ANTLRSysMLParser } from './antlrSysMLParser';
import { ParserWorkerHost } from './parserWorkerHost';

// Debug flag - set to true for verbose logging
const DEBUG_ACTIVITY_PARSING = false;
Expand Down Expand Up @@ -315,6 +316,7 @@ export class SysMLParser {
private relationships: Relationship[] = [];
private antlrParser: ANTLRSysMLParser | false | null = null; // Lazy loaded to avoid circular imports
private semanticResolver: SemanticResolver | null = null; // Semantic resolver for type checking
private workerHost: ParserWorkerHost | null = null; // Worker thread for async parsing

// Parse cache to avoid reparsing unchanged documents
private parseCache: Map<string, { hash: number; elements: SysMLElement[]; relationships: Relationship[] }> = new Map();
Expand Down Expand Up @@ -595,8 +597,8 @@ export class SysMLParser {
return cached.result;
}

// First parse with ANTLR (this also uses caching)
const elements = this.parse(document);
// First parse with ANTLR via Worker thread (this also uses caching)
const elements = await this.parseAsync(document);

// Then resolve types and validate against library
const resolver = this.getSemanticResolver();
Expand Down Expand Up @@ -645,6 +647,77 @@ export class SysMLParser {
}
}

/**
* Parse document asynchronously using a Worker thread.
* Falls back to synchronous parsing if the worker is unavailable.
*/
async parseAsync(document: vscode.TextDocument): Promise<SysMLElement[]> {
const uri = document.uri.toString();
const content = document.getText();
const contentHash = this.hashContent(content);

// Check cache first — avoid expensive ANTLR parsing if content unchanged
const cached = this.parseCache.get(uri);
if (cached && cached.hash === contentHash) {
this.elements.clear();
this.updateElementCache(cached.elements);
this.relationships = cached.relationships;
return cached.elements;
}

try {
if (!this.workerHost) {
this.workerHost = new ParserWorkerHost();
}

const result = await this.workerHost.parseDocument(content, uri, false);

// Update internal state
this.elements.clear();
this.updateElementCache(result.elements);
this.relationships = result.relationships;

// Cache the result
this.parseCache.set(uri, {
hash: contentHash,
elements: result.elements,
relationships: [...this.relationships]
});

// Log parse errors for diagnostics if any
const errorElements = result.elements.filter(el => el.type === 'error');
if (errorElements.length > 0) {
const message = `ANTLR parsing (worker) produced ${errorElements.length} error elements`;
try {
const { getOutputChannel } = require('../extension');
getOutputChannel()?.appendLine(message);
} catch {
// Silently fail
}
}

return result.elements;
} catch (error) {
// Fall back to synchronous parsing on the main thread
const message = `Worker parse failed, falling back to sync: ${error instanceof Error ? error.message : 'Unknown error'}`;
try {
const { getOutputChannel } = require('../extension');
getOutputChannel()?.appendLine(message);
} catch {
// Silently fail
}
return this.parse(document);
}
}

/**
* Terminate the parser Worker thread and release resources.
*/
dispose(): void {
this.workerHost?.dispose();
this.workerHost = null;
}

/**
* Convert enriched elements back to SysML elements for compatibility
* This bridges Phase 3 resolver output to existing visualization consumers
Expand Down
Loading
Loading