236 lines
7.9 KiB
JavaScript
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 };
|