Files
WRNexusJS/editors/vscode/src/extension.js
T
2026-07-12 15:55:18 +05:30

236 lines
7.9 KiB
JavaScript

// @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 <slot> 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 <N>` 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 `<name> { ... }` 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 };