// @ts-check "use strict"; const vscode = require("vscode"); // The .wrn compiler, bundled to CJS by `bun run build:compiler`. Loaded // defensively so the rest of the extension (highlighting, snippets, completion) // still works even if the bundle is missing. let compiler = null; try { compiler = require("./compiler.cjs"); } catch (err) { console.warn("[wrnexus] compiler bundle not found; diagnostics disabled.", err && err.message); } /** Top-level block keywords offered at file scope. */ const BLOCK_KEYWORDS = [ ["page", "page ${1:Name} {\n\t$0\n}", "A route page"], ["component", "component ${1:Name} {\n\t$0\n}", "A reusable, prop-driven component"], ["layout", 'layout = "${1:public}"', "Select a layout for this page"], ["state", "state ${1:count} = ${2:0}", "Reactive state seeded into the scope"], ["props", 'props {\n\t${1:label} = ${2:"Label"}\n}', "Component props with typed defaults"], ["view", "view {\n\t$0\n}", "Server-rendered HTML"], ["seo", 'seo {\n\ttitle = "$1"\n\tdescription = "$2"\n}', "Per-page SEO metadata"], ["api", "api ${1|GET,POST,PUT,PATCH,DELETE|} ${2:/path} {\n\t$0\n}", "Colocated API route"], ["ssr", "ssr {\n\tapi ${1:load} GET ${2:/api/data} {\n\t\t$0\n\t}\n}", "Server-side data block"], [ "client", "client {\n\tapi ${1:load} GET ${2:/api/data} {\n\t\t$0\n\t}\n}", "Client-side data block", ], [ "realtime", "realtime ${1:chat} {\n\ton ${2:message}(${3:data}) {\n\t\t$0\n\t}\n}", "Realtime channel", ], ["functions", "functions {\n\t$0\n}", "Shared helper functions"], ["style", "style {\n\t$0\n}", "Scoped CSS"], ]; /** `data-*` attributes understood by the reactive runtime. */ const DATA_ATTRS = [ ["data-component", 'data-component="$1"', "Mount a component by name"], ["data-for", 'data-for="${1:item} in ${2:items}"', "Repeat this element per item"], ["data-show", 'data-show="${1:condition}"', "Toggle visibility reactively"], ["data-text", 'data-text="${1:expr}"', "Bind text content to an expression"], ["data-scope", 'data-scope="${1:key}: ${2:value}"', "Declare a local reactive scope"], ["data-slot", 'data-slot="${1:name}"', "Fill a named on a component"], ]; /** Client event bindings. */ const EVENTS = [ "click", "input", "change", "submit", "keydown", "keyup", "focus", "blur", "mouseenter", "mouseleave", ]; /** * @param {vscode.ExtensionContext} context */ function activate(context) { const diagnostics = vscode.languages.createDiagnosticCollection("wrn"); context.subscriptions.push(diagnostics); const timers = new Map(); const runDiagnostics = (doc) => { if (doc.languageId !== "wrn") return; if (!vscode.workspace.getConfiguration("wrnexus").get("diagnostics.enable", true)) { diagnostics.delete(doc.uri); return; } if (!compiler || typeof compiler.compileWireFile !== "function") return; const text = doc.getText(); /** @type {vscode.Diagnostic[]} */ const found = []; try { compiler.compileWireFile(text); } catch (err) { found.push(toDiagnostic(doc, err)); } diagnostics.set(doc.uri, found); }; const schedule = (doc) => { const key = doc.uri.toString(); clearTimeout(timers.get(key)); timers.set( key, setTimeout(() => { timers.delete(key); runDiagnostics(doc); }, 250), ); }; // Lint on open, edit and save; clear on close. vscode.workspace.textDocuments.forEach(runDiagnostics); context.subscriptions.push( vscode.workspace.onDidOpenTextDocument(runDiagnostics), vscode.workspace.onDidChangeTextDocument((e) => schedule(e.document)), vscode.workspace.onDidSaveTextDocument(runDiagnostics), vscode.workspace.onDidCloseTextDocument((doc) => diagnostics.delete(doc.uri)), ); // Completions. context.subscriptions.push( vscode.languages.registerCompletionItemProvider( "wrn", { provideCompletionItems: provideCompletions }, "-", "@", "{", ":", ), ); } /** * Map a thrown ParseError to a VS Code diagnostic. The compiler encodes the * failure position as `... at offset ` in the message; we resolve it to a * range. Errors without an offset (e.g. "Unexpected end of input") anchor to the * end of the document. * @param {vscode.TextDocument} doc * @param {unknown} err * @returns {vscode.Diagnostic} */ function toDiagnostic(doc, err) { const message = err && err.message ? String(err.message) : "Failed to parse .wrn file"; const match = /offset\s+(\d+)/.exec(message); let range; if (match) { const offset = Number(match[1]); const start = doc.positionAt(offset); const wordRange = doc.getWordRangeAtPosition(start); range = wordRange || new vscode.Range(start, doc.positionAt(offset + 1)); } else { const last = doc.lineAt(Math.max(0, doc.lineCount - 1)); range = new vscode.Range(last.range.start, last.range.end); } const diag = new vscode.Diagnostic(range, message, vscode.DiagnosticSeverity.Error); diag.source = "wrn"; return diag; } /** * @param {vscode.TextDocument} document * @param {vscode.Position} position * @returns {vscode.CompletionItem[]} */ function provideCompletions(document, position) { const line = document.lineAt(position).text; const before = line.slice(0, position.character); const full = document.getText(); const upto = document.offsetAt(position); const inView = isInsideBlock(full, upto, "view"); // `{t:` — offer nothing structured, just let the user type the key. // `@` inside a view — event bindings. if (inView && /@[A-Za-z-]*$/.test(before)) { return EVENTS.map((ev) => { const item = new vscode.CompletionItem(ev, vscode.CompletionItemKind.Event); item.insertText = new vscode.SnippetString(`${ev}="$0"`); item.detail = "wrn event binding"; // Replace the `@`-less part already typed. return item; }); } // `data-` inside a view — reactive attributes. if (inView && /(^|\s)data-[A-Za-z-]*$/.test(before)) { return DATA_ATTRS.map(([label, snippet, doc]) => { const item = new vscode.CompletionItem(label, vscode.CompletionItemKind.Property); item.insertText = new vscode.SnippetString(String(snippet)); item.documentation = new vscode.MarkdownString(String(doc)); item.detail = "wrn runtime attribute"; const dashIdx = before.lastIndexOf("data-"); item.range = new vscode.Range(position.line, dashIdx, position.line, position.character); return item; }); } // Top-level block keywords, when not inside a view/style/functions body. if (!inView && !isInsideBlock(full, upto, "style") && !isInsideBlock(full, upto, "functions")) { return BLOCK_KEYWORDS.map(([label, snippet, doc]) => { const item = new vscode.CompletionItem(String(label), vscode.CompletionItemKind.Keyword); item.insertText = new vscode.SnippetString(String(snippet)); item.documentation = new vscode.MarkdownString(String(doc)); item.detail = "wrn block"; return item; }); } return []; } /** * Very small heuristic: is `offset` inside a ` { ... }` block? Finds the * nearest preceding `name {` opener and checks braces don't balance before the * cursor. Good enough to gate view/style/functions-aware completions. * @param {string} text * @param {number} offset * @param {string} name * @returns {boolean} */ function isInsideBlock(text, offset, name) { const re = new RegExp("\\b" + name + "\\b\\s*(?:[^\\n{]*)\\{", "g"); let opener = -1; let m; while ((m = re.exec(text)) !== null) { const bracePos = m.index + m[0].length - 1; if (bracePos >= offset) break; opener = bracePos; } if (opener === -1) return false; // Count braces between the opener and the cursor; still open => inside. let depth = 0; for (let i = opener; i < offset && i < text.length; i++) { const c = text[i]; if (c === "{") depth++; else if (c === "}") depth--; } return depth > 0; } function deactivate() {} module.exports = { activate, deactivate };