607 lines
15 KiB
JavaScript
607 lines
15 KiB
JavaScript
// @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<string, Set<number>>}
|
|
*/
|
|
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(`(?<![A-Za-z0-9_$])${escapeRegExp(name)}(?![A-Za-z0-9_$])`, "g");
|
|
|
|
let match;
|
|
|
|
while ((match = pattern.exec(text)) !== null) {
|
|
const offset = match.index;
|
|
|
|
if (isInsideComment(offset, commentRanges)) {
|
|
continue;
|
|
}
|
|
|
|
const position = document.positionAt(offset);
|
|
let modifiers = [];
|
|
|
|
if (declarationOffsets.has(offset)) {
|
|
modifiers = ["declaration"];
|
|
} else if (isStateModification(text, offset, name.length)) {
|
|
modifiers = ["modification"];
|
|
}
|
|
|
|
builder.push(position.line, position.character, name.length, "variable", modifiers);
|
|
}
|
|
}
|
|
|
|
return builder.build();
|
|
},
|
|
};
|
|
|
|
module.exports = {
|
|
activate,
|
|
deactivate,
|
|
registerCompilerDiagnostics,
|
|
registerFormatter,
|
|
registerSemanticTokens,
|
|
recoverWrnLanguage,
|
|
toCompilerDiagnostic,
|
|
};
|