// @ts-check "use strict"; const vscode = require("vscode"); const path = require("node:path"); const { LanguageClient, TransportKind } = require("vscode-languageclient/node"); const { formatWrn } = require("./formatter"); const { registerCompletionProvider } = require("./completion"); const { registerDefinitionProvider } = require("./definition"); const { registerComponentIntelligence } = require("./component-intelligence"); const { registerDiagnostics } = require("./diagnostics"); const { registerV060LanguageFeatures } = require("./v060-language"); /** * The WRN compiler is bundled to CommonJS using: * * bun run build:compiler * * Loading is optional so highlighting, formatting, snippets, * autocomplete and navigation continue working when the compiler * bundle is temporarily unavailable. */ let compiler = null; try { compiler = require("./compiler.cjs"); } catch (error) { console.warn( "[wrnexus] compiler bundle not found; compiler diagnostics disabled.", error instanceof Error ? error.message : String(error), ); } const WRN_LANGUAGE_ID = "wrn"; const COMPILER_DIAGNOSTIC_COLLECTION = "wrnexus-compiler"; /** * Register the WRN document formatter. * * @param {vscode.ExtensionContext} context */ function registerFormatter(context) { const selector = { language: WRN_LANGUAGE_ID, }; const provider = vscode.languages.registerDocumentFormattingEditProvider(selector, { provideDocumentFormattingEdits(document, options, token) { if (token.isCancellationRequested) { return []; } const configuration = vscode.workspace.getConfiguration("wrnexus", document.uri); const enabled = configuration.get("format.enable", true); if (!enabled) { return []; } const source = document.getText(); if (!source.trim()) { return []; } const printWidth = configuration.get("formatting.printWidth", 100); const multilineAttributes = configuration.get("formatting.multilineAttributes", true); try { const formatted = formatWrn(source, { tabSize: options.tabSize || 4, insertSpaces: options.insertSpaces !== false, printWidth, multilineAttributes, }); if (typeof formatted !== "string" || formatted === source) { return []; } const fullRange = new vscode.Range( document.positionAt(0), document.positionAt(source.length), ); return [vscode.TextEdit.replace(fullRange, formatted)]; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("[wrnexus] formatting failed:", message); vscode.window.showErrorMessage(`WRNexus formatting failed: ${message}`); return []; } }, }); context.subscriptions.push(provider); } /** * Convert a compiler ParseError into a VS Code diagnostic. * * Compiler errors normally contain: * * at offset 123 * * When no offset exists, the diagnostic is attached to the last * line of the document. * * @param {vscode.TextDocument} document * @param {unknown} error * @returns {vscode.Diagnostic} */ function toCompilerDiagnostic(document, error) { const message = error && typeof error === "object" && "message" in error ? String(error.message) : "Failed to compile .wrn file."; const offsetMatch = /offset\s+(\d+)/i.exec(message); let range; if (offsetMatch) { const requestedOffset = Number(offsetMatch[1]); const safeOffset = Math.max(0, Math.min(requestedOffset, document.getText().length)); const start = document.positionAt(safeOffset); const wordRange = document.getWordRangeAtPosition(start); range = wordRange || new vscode.Range( start, document.positionAt(Math.min(safeOffset + 1, document.getText().length)), ); } else { const lastLine = document.lineAt(Math.max(0, document.lineCount - 1)); range = lastLine.range; } const diagnostic = new vscode.Diagnostic(range, message, vscode.DiagnosticSeverity.Error); diagnostic.source = "WRNexus Compiler"; diagnostic.code = "wrn-compiler-error"; return diagnostic; } /** * Register diagnostics produced by the actual WRN compiler. * * The lightweight diagnostics in diagnostics.js provide immediate * editor feedback. Compiler diagnostics verify that the document * can also be parsed and generated by the real framework compiler. * * @param {vscode.ExtensionContext} context */ function registerCompilerDiagnostics(context) { const collection = vscode.languages.createDiagnosticCollection(COMPILER_DIAGNOSTIC_COLLECTION); const timers = new Map(); /** * @param {vscode.TextDocument} document */ const run = (document) => { if (document.languageId !== WRN_LANGUAGE_ID) { return; } const configuration = vscode.workspace.getConfiguration("wrnexus", document.uri); const enabled = configuration.get("diagnostics.enable", true); if (!enabled) { collection.delete(document.uri); return; } if (!compiler || typeof compiler.compileWireFile !== "function") { collection.delete(document.uri); return; } const source = document.getText(); if (!source.trim()) { collection.delete(document.uri); return; } try { compiler.compileWireFile(source); collection.set(document.uri, []); } catch (error) { collection.set(document.uri, [toCompilerDiagnostic(document, error)]); } }; /** * @param {vscode.TextDocument} document */ const schedule = (document) => { if (document.languageId !== WRN_LANGUAGE_ID) { return; } const key = document.uri.toString(); const existing = timers.get(key); if (existing) { clearTimeout(existing); } const timer = setTimeout(() => { timers.delete(key); run(document); }, 250); timers.set(key, timer); }; for (const document of vscode.workspace.textDocuments) { run(document); } context.subscriptions.push( collection, vscode.workspace.onDidOpenTextDocument(run), vscode.workspace.onDidChangeTextDocument((event) => { schedule(event.document); }), vscode.workspace.onDidSaveTextDocument(run), vscode.workspace.onDidCloseTextDocument((document) => { const key = document.uri.toString(); const timer = timers.get(key); if (timer) { clearTimeout(timer); timers.delete(key); } collection.delete(document.uri); }), vscode.workspace.onDidChangeConfiguration((event) => { if (!event.affectsConfiguration("wrnexus.diagnostics.enable")) { return; } for (const document of vscode.workspace.textDocuments) { run(document); } }), { dispose() { for (const timer of timers.values()) { clearTimeout(timer); } timers.clear(); }, }, ); } /** * Register semantic highlighting for WRN state declarations and references. * * @param {vscode.ExtensionContext} context */ function registerSemanticTokens(context) { context.subscriptions.push( vscode.languages.registerDocumentSemanticTokensProvider( { language: WRN_LANGUAGE_ID, }, wrnSemanticTokensProvider, semanticTokenLegend, ), ); } /** * Recover files affected by older workspace settings that associated `*.wrn` * with the obsolete `wire` language id. Normal language contribution matching * happens before activation; this fallback is intentionally limited to Plain * Text and the legacy id so explicit third-party associations are respected. * * @param {vscode.TextDocument} document */ async function recoverWrnLanguage(document) { if (!document.fileName.toLowerCase().endsWith(".wrn")) return; if (!["plaintext", "wire"].includes(document.languageId)) return; try { await vscode.languages.setTextDocumentLanguage(document, WRN_LANGUAGE_ID); } catch (error) { console.warn( "[wrnexus] unable to recover .wrn language association:", error instanceof Error ? error.message : String(error), ); } } /** * Activate the WRNexus VS Code extension. * * @param {vscode.ExtensionContext} context */ async function activate(context) { for (const document of vscode.workspace.textDocuments) { void recoverWrnLanguage(document); } context.subscriptions.push( vscode.workspace.onDidOpenTextDocument((document) => { void recoverWrnLanguage(document); }), ); const useLanguageServer = vscode.workspace .getConfiguration("wrnexus") .get("languageServer.enable", true); let languageServerStarted = false; if (useLanguageServer) { const module = path.join(context.extensionPath, "src", "language-server.cjs"); const serverOptions = { run: { module, transport: TransportKind.stdio }, debug: { module, transport: TransportKind.stdio, options: { execArgv: ["--nolazy"] } }, }; const client = new LanguageClient( "wrnexusLanguageServer", "WRNexus Language Server", serverOptions, { documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }], }, ); try { await client.start(); languageServerStarted = true; context.subscriptions.push({ dispose: () => void client.stop() }); } catch (error) { console.warn( "[wrnexus] language server failed to start; using built-in providers.", error instanceof Error ? error.message : String(error), ); } } if (!languageServerStarted) { registerDiagnostics(context); registerCompilerDiagnostics(context); registerCompletionProvider(context); registerDefinitionProvider(context); registerFormatter(context); } // Package/local component metadata is not yet part of the LSP workspace // catalog, so keep prop/event completion, hover, and validation available in // both LSP and built-in-provider modes. registerComponentIntelligence(context); registerSemanticTokens(context); registerV060LanguageFeatures(context); } function deactivate() {} const semanticTokenLegend = new vscode.SemanticTokensLegend( ["variable"], ["declaration", "modification"], ); /** * @param {string} value * @returns {string} */ function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** * @param {string} text * @returns {Map>} */ function collectStateVariables(text) { const states = new Map(); const statePattern = /\bstate\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?==|;|\r?$)/gm; let match; while ((match = statePattern.exec(text)) !== null) { const name = match[1]; if (!name) { continue; } const offset = match.index + match[0].lastIndexOf(name); const declarations = states.get(name) || new Set(); declarations.add(offset); states.set(name, declarations); } return states; } /** * Comments are excluded, but quoted attribute expressions are deliberately * included because WRN state references commonly appear in values such as * class:flex="centered" and @click="enabled = !enabled". * * @param {string} text * @returns {Array<{start: number, end: number}>} */ function collectCommentRanges(text) { const ranges = []; let index = 0; let quote = null; let escaped = false; while (index < text.length) { const current = text[index]; const next = text[index + 1]; if (quote) { if (escaped) { escaped = false; index++; continue; } if (current === "\\") { escaped = true; index++; continue; } if (current === quote) { quote = null; } index++; continue; } if (current === '"' || current === "'" || current === "`") { quote = current; index++; continue; } if (current === "/" && next === "/") { const start = index; index += 2; while (index < text.length && text[index] !== "\n") { index++; } ranges.push({ start, end: index }); continue; } if (current === "/" && next === "*") { const start = index; index += 2; while (index < text.length && !(text[index] === "*" && text[index + 1] === "/")) { index++; } index = Math.min(text.length, index + 2); ranges.push({ start, end: index }); continue; } index++; } return ranges; } /** * @param {number} offset * @param {Array<{start: number, end: number}>} ranges * @returns {boolean} */ function isInsideComment(offset, ranges) { let low = 0; let high = ranges.length - 1; while (low <= high) { const middle = Math.floor((low + high) / 2); const range = ranges[middle]; if (!range) { return false; } if (offset < range.start) { high = middle - 1; } else if (offset >= range.end) { low = middle + 1; } else { return true; } } return false; } /** * @param {string} text * @param {number} offset * @param {number} length * @returns {boolean} */ function isStateModification(text, offset, length) { const after = text.slice(offset + length).match(/^\s*(=|\+=|-=|\*=|\/=|%=|\+\+|--)/); if (after) { return true; } const before = text.slice(Math.max(0, offset - 8), offset).match(/(\+\+|--)\s*$/); return Boolean(before); } const wrnSemanticTokensProvider = { /** * @param {vscode.TextDocument} document * @param {vscode.CancellationToken} token */ provideDocumentSemanticTokens(document, token) { const builder = new vscode.SemanticTokensBuilder(semanticTokenLegend); const text = document.getText(); const states = collectStateVariables(text); const commentRanges = collectCommentRanges(text); for (const [name, declarationOffsets] of states) { if (token.isCancellationRequested) { return builder.build(); } const pattern = new RegExp(`(?