release: WRNexusJS 0.2.32

This commit is contained in:
2026-07-14 22:39:50 +05:30
parent 8b4b7ca5ac
commit 959ed24009
66 changed files with 3135 additions and 648 deletions
+91 -6
View File
@@ -289,6 +289,8 @@ function parse(source) {
const functions = [];
const dataApis = [];
const modeFunctions = [];
const lifecycle = {};
const watches = [];
const apis = [];
const realtimes = [];
while (lx.peek().type !== "rbrace") {
@@ -413,6 +415,40 @@ function parse(source) {
styles.push(lx.readBalancedBraces());
break;
}
case "lifecycle": {
lx.next();
expect("lbrace");
while (lx.peek().type !== "rbrace") {
const hook = lx.peek();
if (hook.type === "eof") {
throw new ParseError("Unexpected end of input inside lifecycle block");
}
if (hook.type !== "ident") {
throw new ParseError(`Expected a lifecycle hook at offset ${hook.pos}`);
}
if (hook.value !== "mount" && hook.value !== "update" && hook.value !== "unmount") {
throw new ParseError(`Unknown lifecycle hook '${hook.value}' at offset ${hook.pos}`);
}
const hookName = hook.value;
lx.next();
if (lifecycle[hookName] !== undefined) {
throw new ParseError(`Duplicate lifecycle hook '${hookName}' at offset ${hook.pos}`);
}
lifecycle[hookName] = lx.readBalancedBraces();
}
expect("rbrace");
break;
}
case "watch": {
lx.next();
const stateName = expect("ident").value;
const body = lx.readBalancedBraces();
watches.push({
state: stateName,
body
});
break;
}
case "functions": {
lx.next();
functions.push(lx.readBalancedBraces());
@@ -423,6 +459,12 @@ function parse(source) {
}
}
expect("rbrace");
const declaredStates = new Set(states.map((state) => state.name));
for (const watcher of watches) {
if (!declaredStates.has(watcher.state)) {
throw new ParseError(`Cannot watch undeclared state '${watcher.state}'`);
}
}
return {
type: "page",
kind,
@@ -436,6 +478,8 @@ function parse(source) {
functions,
dataApis,
modeFunctions,
lifecycle,
watches,
apis,
realtimes
};
@@ -716,10 +760,18 @@ function renderAttr(attr) {
}
}
function eventAttribute(name) {
if (name.startsWith("browser-"))
if (name.startsWith("window:")) {
return `data-on-window-${name.slice("window:".length)}`;
}
if (name.startsWith("document:")) {
return `data-on-document-${name.slice("document:".length)}`;
}
if (name.startsWith("browser-")) {
return `data-on-wrnexus-browser-${name.slice(8)}`;
if (name.startsWith("mobile-"))
}
if (name.startsWith("mobile-")) {
return `data-on-wrnexus-mobile-${name.slice(7)}`;
}
return `data-on-${name}`;
}
function reactiveAttrValue(raw, reactive) {
@@ -924,7 +976,7 @@ function renderNestedComponentInvocation(node, ctx) {
let bindIndex = 0;
const attrs = node.attrs.filter((attr) => attr.name !== "data-component").map((attr) => {
if (attr.event) {
return ` ${eventAttribute(attr.name)}="${compileAttrValue(attr.value, ctx)}"`;
return escLit(` ${eventAttribute(attr.name)}="`) + escLit(attrEscape(attr.value)) + escLit(`"`);
}
if (attr.boolean) {
return ` ${attr.name}`;
@@ -1262,6 +1314,34 @@ function safeRef(name) {
function escLit(s) {
return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
}
function componentBehavior(ast) {
const functions = ast.functions.map((body) => body.trim()).filter(Boolean).join(`
`);
const lifecycle = {
...ast.lifecycle.mount?.trim() ? { mount: ast.lifecycle.mount.trim() } : {},
...ast.lifecycle.update?.trim() ? { update: ast.lifecycle.update.trim() } : {},
...ast.lifecycle.unmount?.trim() ? { unmount: ast.lifecycle.unmount.trim() } : {}
};
const watches = ast.watches.map((watch) => ({
state: watch.state,
body: watch.body.trim()
}));
if (!functions && Object.keys(lifecycle).length === 0 && watches.length === 0) {
return null;
}
return {
functions,
lifecycle,
watches
};
}
function behaviorAttribute(behavior) {
if (!behavior) {
return "";
}
return ` data-wrn-behavior="${attrEscape(JSON.stringify(behavior))}"`;
}
var INTERP_RE = /\{([^{}]+)\}/g;
function exprRefsState(expr, stateNames) {
for (const name of stateNames) {
@@ -1340,7 +1420,7 @@ function renderComponentNode(node, ctx) {
}
const attrs = node.attrs.filter((a) => a.name !== "class" && !a.name.startsWith("class:")).map((a) => {
if (a.event) {
return ` ${eventAttribute(a.name)}="${compileAttrValue(a.value, ctx)}"`;
return ` ${eventAttribute(a.name)}="${escLit(attrEscape(a.value))}"`;
}
if (a.boolean) {
return ` ${a.name}`;
@@ -1400,11 +1480,13 @@ function generateComponent(ast) {
${styles.map(styleEscape).join(`
`)}
</style>`) : "";
const needsScope = ast.states.length > 0 || viewHasEvents(ast.view);
const behavior = componentBehavior(ast);
const needsScope = ast.states.length > 0 || viewHasEvents(ast.view) || behavior !== null;
const scopeKeys = [
...effectiveProps.map((prop) => prop.name),
...ast.states.map((state) => state.name)
];
const behaviorAttr = behaviorAttribute(behavior);
const decls = [];
for (const prop of effectiveProps) {
decls.push(` const ${nameRefs.get(prop.name)} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}));`);
@@ -1412,7 +1494,7 @@ ${styles.map(styleEscape).join(`
for (const state of ast.states) {
decls.push(` const ${nameRefs.get(state.name)} = (${resolveExpr(state.expr)});`);
}
const returnExpr = needsScope ? "`" + styleTag + '<div data-scope="${__scope}">' + viewCode + "</div>`" : "`" + styleTag + viewCode + "`";
const returnExpr = needsScope ? "`" + styleTag + `<div data-scope="\${__scope}"${behaviorAttr}>` + viewCode + "</div>`" : "`" + styleTag + viewCode + "`";
const scopeLine = needsScope && scopeKeys.length > 0 ? ` const __scope = __wrnexusScopeDecl({ ${scopeKeys.map((k) => `${JSON.stringify(k)}: ${nameRefs.get(k)}`).join(", ")} });
` : needsScope ? ` const __scope = "";
` : "";
@@ -1421,6 +1503,9 @@ ${styles.map(styleEscape).join(`
} else {
out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`);
}
if (behavior) {
out.push(`export const __wrnexusBehavior = ${JSON.stringify(behavior, null, 2)};`);
}
out.push(`function __coerce(v: any, def: any): any {
if (v === undefined || v === null) return def;
if (typeof def === "number") return Number(v);
+356 -30
View File
@@ -20,15 +20,26 @@ const BLOCK_COMPLETIONS = [
{
label: "component",
detail: "WRN component",
documentation: "Create a reusable WRN component.",
documentation:
"Create a reusable WRN component with state, functions, lifecycle hooks, watchers, and a view.",
snippet: [
"component ${1:ComponentName} {",
" props {",
' ${2:title} = "${3:Title}"',
" state ${2:ready} = false",
"",
" functions {",
" function ${3:initialize}() {",
" $0",
" }",
" }",
"",
" lifecycle {",
" mount {",
" ${3:initialize}()",
" }",
" }",
"",
" view {",
" $0",
" <div></div>",
" }",
"}",
].join("\n"),
@@ -47,9 +58,16 @@ const BLOCK_COMPLETIONS = [
"}",
].join("\n"),
},
{
label: "props",
detail: "Component or layout props block",
documentation: "Declare values accepted by a component or layout.",
snippet: ["props {", ' ${1:title} = "${2:Title}"', "}"].join("\n"),
},
{
label: "seo",
detail: "SEO metadata block",
documentation: "Declare title, description, and other page metadata.",
snippet: [
"seo {",
' title = "${1:Page title}"',
@@ -60,13 +78,84 @@ const BLOCK_COMPLETIONS = [
{
label: "view",
detail: "WRN view block",
documentation: "Declare the HTML view rendered by the page, component, or layout.",
snippet: ["view {", " $0", "}"].join("\n"),
},
{
label: "state",
detail: "Reactive state declaration",
documentation: "Declare reactive state owned by the current component, layout, or page.",
snippet: 'state ${1:name} = ${2:"value"}',
},
{
label: "functions",
detail: "Browser component functions block",
documentation:
"Declare functions that can be called from events, lifecycle hooks, and watchers.",
snippet: ["functions {", " function ${1:handler}(${2}) {", " $0", " }", "}"].join(
"\n",
),
},
{
label: "function",
detail: "WRN component function",
documentation: "Declare a browser-side function inside a functions block.",
snippet: ["function ${1:name}(${2}) {", " $0", "}"].join("\n"),
},
{
label: "lifecycle",
detail: "Component lifecycle block",
documentation: "Declare mount, update, and unmount hooks for a component or layout.",
snippet: [
"lifecycle {",
" mount {",
" $1",
" }",
"",
" update {",
" $2",
" }",
"",
" unmount {",
" $0",
" }",
"}",
].join("\n"),
},
{
label: "mount",
detail: "Lifecycle mount hook",
documentation: "Runs once after the component is connected and hydrated.",
snippet: ["mount {", " $0", "}"].join("\n"),
},
{
label: "update",
detail: "Lifecycle update hook",
documentation: "Runs once after a batch of reactive state changes.",
snippet: ["update {", " $0", "}"].join("\n"),
},
{
label: "unmount",
detail: "Lifecycle unmount hook",
documentation:
"Runs before the component is removed. Use it to remove global listeners and release resources.",
snippet: ["unmount {", " $0", "}"].join("\n"),
},
{
label: "watch",
detail: "Reactive state watcher",
documentation:
"Run code when one declared state value changes. The watcher receives `value` and `previous`.",
snippet: ["watch ${1:stateName} {", " console.log(value, previous)", " $0", "}"].join(
"\n",
),
},
{
label: "style",
detail: "Scoped style block",
documentation: "Declare styles for the current WRN declaration.",
snippet: ["style {", " $0", "}"].join("\n"),
},
];
const ATTRIBUTE_COMPLETIONS = [
@@ -76,6 +165,19 @@ const ATTRIBUTE_COMPLETIONS = [
["@submit", "Submit event handler", '@submit="${1:handler()}"'],
["@focus", "Focus event handler", '@focus="${1:handler()}"'],
["@blur", "Blur event handler", '@blur="${1:handler()}"'],
["@keydown", "Keyboard key-down event handler", '@keydown="${1:handler()}"'],
["@keyup", "Keyboard key-up event handler", '@keyup="${1:handler()}"'],
["@mouseenter", "Pointer enter event handler", '@mouseenter="${1:handler()}"'],
["@mouseleave", "Pointer leave event handler", '@mouseleave="${1:handler()}"'],
["@window:scroll", "Window scroll event handler", '@window:scroll="${1:handler()}"'],
["@window:resize", "Window resize event handler", '@window:resize="${1:handler()}"'],
["@window:keydown", "Window key-down event handler", '@window:keydown="${1:handler()}"'],
["@document:click", "Document click event handler", '@document:click="${1:handler()}"'],
[
"@document:visibilitychange",
"Document visibility-change event handler",
'@document:visibilitychange="${1:handler()}"',
],
["data-show", "Conditional visibility", 'data-show="${1:condition}"'],
["data-for", "Reactive loop", 'data-for="${1:item} in ${2:items}"'],
["class:", "Conditional CSS class", 'class:${1:border-indigo-500}="${2:condition}"'],
@@ -90,6 +192,11 @@ const CONTEXT_COMPLETIONS = [
["ctx.locals", "Request-local data"],
];
const WATCH_VALUE_COMPLETIONS = [
["value", "Current watcher state value"],
["previous", "Previous watcher state value"],
];
function completionKindFor(label) {
if (label.startsWith("@")) {
return vscode.CompletionItemKind.Event;
@@ -99,15 +206,47 @@ function completionKindFor(label) {
return vscode.CompletionItemKind.Property;
}
if (label === "function" || label === "functions") {
return vscode.CompletionItemKind.Function;
}
return vscode.CompletionItemKind.Keyword;
}
function createCompletion(label, detail, snippet) {
function createCompletion(label, detail, snippet, documentation) {
const item = new vscode.CompletionItem(label, completionKindFor(label));
item.detail = detail;
item.insertText = new vscode.SnippetString(snippet || label);
item.documentation = new vscode.MarkdownString(detail);
item.documentation = new vscode.MarkdownString(documentation || detail);
return item;
}
function createVariableCompletion(label, detail, insertion = label) {
const item = new vscode.CompletionItem(label, vscode.CompletionItemKind.Variable);
item.detail = detail;
item.insertText = insertion;
return item;
}
function createFunctionCompletion(name, parameters = []) {
const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Function);
item.detail = `WRN component function${
parameters.length > 0 ? ` (${parameters.join(", ")})` : ""
}`;
item.documentation = new vscode.MarkdownString(
`Call the browser function \`${name}\` declared in the current \`functions { ... }\` block.`,
);
const placeholders = parameters.map((parameter, index) => `\${${index + 1}:${parameter}}`);
item.insertText = new vscode.SnippetString(`${name}(${placeholders.join(", ")})`);
return item;
}
@@ -118,6 +257,7 @@ function getCurrentOpeningTag(document, position) {
);
const lastOpen = textBeforeCursor.lastIndexOf("<");
const lastClose = textBeforeCursor.lastIndexOf(">");
if (lastOpen > lastClose) {
@@ -129,6 +269,7 @@ function getCurrentOpeningTag(document, position) {
function extractRouteParams(document) {
const fileName = document.fileName.replace(/\\/g, "/");
const matches = [...fileName.matchAll(/\[([A-Za-z_$][\w$]*)\]/g)];
return matches.map((match) => match[1]);
@@ -136,13 +277,195 @@ function extractRouteParams(document) {
function extractStates(document) {
const source = document.getText();
const matches = [...source.matchAll(/^\s*state\s+([A-Za-z_$][\w$]*)\s*=/gm)];
return matches.map((match) => match[1]);
return [...new Set(matches.map((match) => match[1]))];
}
function extractProps(document) {
const source = document.getText();
const propsBlockPattern = /\bprops\s*\{([\s\S]*?)\}/g;
const props = new Set();
let blockMatch;
while ((blockMatch = propsBlockPattern.exec(source)) !== null) {
const body = blockMatch[1];
for (const match of body.matchAll(/^\s*([A-Za-z_$][\w$]*)\s*=/gm)) {
props.add(match[1]);
}
}
return [...props];
}
function extractFunctions(document) {
const source = document.getText();
const functions = [];
const seen = new Set();
const pattern = /(?:^|\s)(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(([^)]*)\)/g;
let match;
while ((match = pattern.exec(source)) !== null) {
const name = match[1];
if (seen.has(name)) {
continue;
}
seen.add(name);
const parameters = match[2]
.split(",")
.map((parameter) => parameter.trim())
.filter(Boolean)
.map((parameter) => parameter.replace(/=.*$/, "").trim());
functions.push({
name,
parameters,
});
}
return functions;
}
function sourceBeforePosition(document, position) {
return document.getText(new vscode.Range(new vscode.Position(0, 0), position));
}
function isInsideNamedBlock(document, position, blockName) {
const source = sourceBeforePosition(document, position);
const tokenPattern = new RegExp(`\\b${blockName}\\s*\\{|\\{|\\}`, "g");
const stack = [];
let match;
while ((match = tokenPattern.exec(source)) !== null) {
const token = match[0];
if (token.startsWith(blockName)) {
stack.push(blockName);
continue;
}
if (token === "{") {
stack.push(null);
continue;
}
if (token === "}") {
stack.pop();
}
}
return stack.includes(blockName);
}
function isInsideLifecycle(document, position) {
return isInsideNamedBlock(document, position, "lifecycle");
}
function isInsideFunctions(document, position) {
return isInsideNamedBlock(document, position, "functions");
}
function isInsideWatch(document, position) {
const source = sourceBeforePosition(document, position);
const watchMatches = [...source.matchAll(/\bwatch\s+[A-Za-z_$][\w$]*\s*\{/g)];
if (watchMatches.length === 0) {
return false;
}
const latest = watchMatches[watchMatches.length - 1];
const tail = source.slice(latest.index);
let depth = 0;
for (const character of tail) {
if (character === "{") {
depth += 1;
} else if (character === "}") {
depth -= 1;
}
}
return depth > 0;
}
function isAfterWatchKeyword(document, position) {
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
return /^\s*watch\s+[A-Za-z0-9_$]*$/.test(linePrefix);
}
function addBlockCompletions(items, document, position) {
const insideLifecycle = isInsideLifecycle(document, position);
const insideFunctions = isInsideFunctions(document, position);
for (const completion of BLOCK_COMPLETIONS) {
if (insideLifecycle && !["mount", "update", "unmount"].includes(completion.label)) {
continue;
}
if (insideFunctions && completion.label !== "function") {
continue;
}
if (!insideLifecycle && ["mount", "update", "unmount"].includes(completion.label)) {
continue;
}
if (!insideFunctions && completion.label === "function") {
continue;
}
items.push(
createCompletion(
completion.label,
completion.detail,
completion.snippet,
completion.documentation,
),
);
}
}
function addStateCompletions(items, document, position) {
const states = extractStates(document);
const afterWatch = isAfterWatchKeyword(document, position);
for (const state of states) {
const item = createVariableCompletion(
state,
afterWatch ? "Declared WRN state available for watching" : "WRN reactive state",
);
if (afterWatch) {
item.sortText = `0-${state}`;
}
items.push(item);
}
}
function addFunctionCompletions(items, document) {
for (const fn of extractFunctions(document)) {
items.push(createFunctionCompletion(fn.name, fn.parameters));
}
}
function provideCompletionItems(document, position) {
const items = [];
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
const openingTag = getCurrentOpeningTag(document, position);
@@ -151,10 +474,10 @@ function provideCompletionItems(document, position) {
for (const [label, detail, snippet] of ATTRIBUTE_COMPLETIONS) {
items.push(createCompletion(label, detail, snippet));
}
} else if (isAfterWatchKeyword(document, position)) {
addStateCompletions(items, document, position);
} else {
for (const completion of BLOCK_COMPLETIONS) {
items.push(createCompletion(completion.label, completion.detail, completion.snippet));
}
addBlockCompletions(items, document, position);
}
if (linePrefix.includes("ctx.") || linePrefix.includes("ctx.params.")) {
@@ -164,31 +487,27 @@ function provideCompletionItems(document, position) {
}
for (const param of extractRouteParams(document)) {
const item = new vscode.CompletionItem(param, vscode.CompletionItemKind.Variable);
items.push(createVariableCompletion(param, `Route parameter from [${param}].wrn`));
item.detail = `Route parameter from [${param}].wrn`;
item.insertText = param;
items.push(item);
const fullItem = new vscode.CompletionItem(
`ctx.params.${param}`,
vscode.CompletionItemKind.Variable,
items.push(
createVariableCompletion(`ctx.params.${param}`, `Route parameter from [${param}].wrn`),
);
fullItem.detail = `Route parameter from [${param}].wrn`;
fullItem.insertText = `ctx.params.${param}`;
items.push(fullItem);
}
for (const state of extractStates(document)) {
const item = new vscode.CompletionItem(state, vscode.CompletionItemKind.Variable);
if (!isAfterWatchKeyword(document, position)) {
addStateCompletions(items, document, position);
}
item.detail = "WRN state";
item.insertText = state;
for (const prop of extractProps(document)) {
items.push(createVariableCompletion(prop, "WRN component or layout prop"));
}
items.push(item);
addFunctionCompletions(items, document);
if (isInsideWatch(document, position)) {
for (const [label, detail] of WATCH_VALUE_COMPLETIONS) {
items.push(createVariableCompletion(label, detail));
}
}
return items;
@@ -196,7 +515,10 @@ function provideCompletionItems(document, position) {
function registerCompletionProvider(context) {
const provider = vscode.languages.registerCompletionItemProvider(
{ language: "wrn", scheme: "file" },
{
language: "wrn",
scheme: "file",
},
{
provideCompletionItems,
},
@@ -205,13 +527,17 @@ function registerCompletionProvider(context) {
".",
"<",
" ",
"(",
);
context.subscriptions.push(provider);
}
module.exports = {
extractFunctions,
extractProps,
extractRouteParams,
extractStates,
provideCompletionItems,
registerCompletionProvider,
};
+490 -22
View File
@@ -9,6 +9,8 @@ const TOP_LEVEL_PATTERN = /^\s*(page|component|layout)\s+([A-Za-z_$][\w$]*)\s*\{
const VALID_TOP_LEVEL_KINDS = new Set(["page", "component", "layout"]);
const VALID_LIFECYCLE_HOOKS = new Set(["mount", "update", "unmount"]);
const VALID_MEMBERS = {
page: new Set([
"layout",
@@ -23,9 +25,9 @@ const VALID_MEMBERS = {
"realtime",
]),
component: new Set(["props", "state", "view", "style", "functions"]),
component: new Set(["props", "state", "view", "style", "functions", "lifecycle", "watch"]),
layout: new Set(["props", "state", "view", "style", "functions"]),
layout: new Set(["props", "state", "view", "style", "functions", "lifecycle", "watch"]),
};
function createDiagnostic(
@@ -59,7 +61,6 @@ function lineDiagnostic(
code,
) {
const line = document.lineAt(lineNumber);
const diagnostic = new vscode.Diagnostic(line.range, message, severity);
diagnostic.source = "WRNexus";
@@ -316,6 +317,15 @@ function validateHtmlTags(document, source) {
function getRootBodyRange(source, rootMatch) {
const openingBrace = rootMatch.index + rootMatch[0].lastIndexOf("{");
const closingBrace = findMatchingBrace(source, openingBrace);
return {
start: openingBrace + 1,
end: closingBrace === -1 ? source.length : closingBrace,
};
}
function findMatchingBrace(source, openingBrace) {
let depth = 0;
let quote = null;
let escaped = false;
@@ -346,6 +356,17 @@ function getRootBodyRange(source, rootMatch) {
continue;
}
if (source.startsWith("<!--", index)) {
const commentEnd = source.indexOf("-->", index + 4);
if (commentEnd === -1) {
return -1;
}
index = commentEnd + 2;
continue;
}
if (character === "{") {
depth += 1;
continue;
@@ -355,22 +376,44 @@ function getRootBodyRange(source, rootMatch) {
depth -= 1;
if (depth === 0) {
return {
start: openingBrace + 1,
end: index,
};
return index;
}
}
}
return -1;
}
function skipWhitespace(source, index, end) {
while (index < end && /\s/.test(source[index])) {
index += 1;
}
return index;
}
function readIdentifier(source, index, end) {
if (index >= end || !/[A-Za-z_$]/.test(source[index])) {
return null;
}
const start = index;
index += 1;
while (index < end && /[A-Za-z0-9_$-]/.test(source[index])) {
index += 1;
}
return {
start: openingBrace + 1,
end: source.length,
name: source.slice(start, index),
start,
end: index,
};
}
function findRootMembers(source, bodyStart, bodyEnd) {
const members = [];
let index = bodyStart;
let depth = 0;
let quote = null;
@@ -414,7 +457,9 @@ function findRootMembers(source, bodyStart, bodyEnd) {
if (source.startsWith("<!--", index)) {
const end = source.indexOf("-->", index + 4);
index = end === -1 ? bodyEnd : end + 3;
continue;
}
@@ -430,25 +475,35 @@ function findRootMembers(source, bodyStart, bodyEnd) {
continue;
}
if (depth === 0 && /[A-Za-z_]/.test(character)) {
const start = index;
index += 1;
if (depth === 0 && /[A-Za-z_$]/.test(character)) {
const identifier = readIdentifier(source, index, bodyEnd);
while (index < bodyEnd && /[A-Za-z0-9_-]/.test(source[index])) {
if (!identifier) {
index += 1;
continue;
}
const name = source.slice(start, index);
index = identifier.end;
members.push({
name,
start,
end: index,
name: identifier.name,
start: identifier.start,
end: identifier.end,
});
// Single-line declarations must skip the rest of the line.
if (name === "state" || name === "layout") {
if (identifier.name === "state" || identifier.name === "layout") {
skipLine();
continue;
}
if (identifier.name === "watch") {
index = skipWhitespace(source, index, bodyEnd);
const watchedState = readIdentifier(source, index, bodyEnd);
if (watchedState) {
index = watchedState.end;
}
}
continue;
@@ -460,6 +515,234 @@ function findRootMembers(source, bodyStart, bodyEnd) {
return members;
}
function findNamedBlocks(source, bodyStart, bodyEnd, blockName) {
const blocks = [];
let index = bodyStart;
while (index < bodyEnd) {
index = skipWhitespace(source, index, bodyEnd);
const identifier = readIdentifier(source, index, bodyEnd);
if (!identifier) {
index += 1;
continue;
}
index = identifier.end;
if (identifier.name !== blockName) {
const possibleBrace = skipWhitespace(source, index, bodyEnd);
if (source[possibleBrace] === "{") {
const end = findMatchingBrace(source, possibleBrace);
index = end === -1 ? bodyEnd : end + 1;
}
continue;
}
const openingBrace = skipWhitespace(source, index, bodyEnd);
if (source[openingBrace] !== "{") {
blocks.push({
name: blockName,
nameStart: identifier.start,
nameEnd: identifier.end,
openingBrace: -1,
closingBrace: -1,
});
continue;
}
const closingBrace = findMatchingBrace(source, openingBrace);
blocks.push({
name: blockName,
nameStart: identifier.start,
nameEnd: identifier.end,
openingBrace,
closingBrace,
});
index = closingBrace === -1 ? bodyEnd : closingBrace + 1;
}
return blocks;
}
function findStateDeclarations(source, bodyStart, bodyEnd) {
const states = new Map();
let index = bodyStart;
let depth = 0;
let quote = null;
let escaped = false;
while (index < bodyEnd) {
const character = source[index];
if (quote !== null) {
if (escaped) {
escaped = false;
} else if (character === "\\") {
escaped = true;
} else if (character === quote) {
quote = null;
}
index += 1;
continue;
}
if (character === '"' || character === "'") {
quote = character;
index += 1;
continue;
}
if (source.startsWith("<!--", index)) {
const end = source.indexOf("-->", index + 4);
index = end === -1 ? bodyEnd : end + 3;
continue;
}
if (character === "{") {
depth += 1;
index += 1;
continue;
}
if (character === "}") {
depth = Math.max(0, depth - 1);
index += 1;
continue;
}
if (
depth === 0 &&
source.startsWith("state", index) &&
!/[A-Za-z0-9_$]/.test(source[index - 1] || "") &&
!/[A-Za-z0-9_$]/.test(source[index + 5] || "")
) {
let cursor = skipWhitespace(source, index + 5, bodyEnd);
const state = readIdentifier(source, cursor, bodyEnd);
if (state) {
states.set(state.name, state);
index = state.end;
continue;
}
}
index += 1;
}
return states;
}
function findWatchDeclarations(source, bodyStart, bodyEnd) {
const watches = [];
let index = bodyStart;
let depth = 0;
let quote = null;
let escaped = false;
while (index < bodyEnd) {
const character = source[index];
if (quote !== null) {
if (escaped) {
escaped = false;
} else if (character === "\\") {
escaped = true;
} else if (character === quote) {
quote = null;
}
index += 1;
continue;
}
if (character === '"' || character === "'") {
quote = character;
index += 1;
continue;
}
if (source.startsWith("<!--", index)) {
const end = source.indexOf("-->", index + 4);
index = end === -1 ? bodyEnd : end + 3;
continue;
}
if (character === "{") {
depth += 1;
index += 1;
continue;
}
if (character === "}") {
depth = Math.max(0, depth - 1);
index += 1;
continue;
}
if (
depth === 0 &&
source.startsWith("watch", index) &&
!/[A-Za-z0-9_$]/.test(source[index - 1] || "") &&
!/[A-Za-z0-9_$]/.test(source[index + 5] || "")
) {
const watchStart = index;
let cursor = skipWhitespace(source, index + 5, bodyEnd);
const watchedState = readIdentifier(source, cursor, bodyEnd);
if (!watchedState) {
watches.push({
watchStart,
watchEnd: index + 5,
state: null,
openingBrace: -1,
closingBrace: -1,
});
index += 5;
continue;
}
cursor = skipWhitespace(source, watchedState.end, bodyEnd);
const openingBrace = source[cursor] === "{" ? cursor : -1;
const closingBrace = openingBrace === -1 ? -1 : findMatchingBrace(source, openingBrace);
watches.push({
watchStart,
watchEnd: index + 5,
state: watchedState,
openingBrace,
closingBrace,
});
index = closingBrace === -1 ? watchedState.end : closingBrace + 1;
continue;
}
index += 1;
}
return watches;
}
function validateRootMembers(document, source, rootKind, rootMatch) {
const diagnostics = [];
const allowed = VALID_MEMBERS[rootKind];
@@ -469,6 +752,7 @@ function validateRootMembers(document, source, rootKind, rootMatch) {
}
const bodyRange = getRootBodyRange(source, rootMatch);
const members = findRootMembers(source, bodyRange.start, bodyRange.end);
for (const member of members) {
@@ -491,8 +775,182 @@ function validateRootMembers(document, source, rootKind, rootMatch) {
return diagnostics;
}
function validateLifecycleBlocks(document, source, rootKind, rootMatch) {
if (rootKind !== "component" && rootKind !== "layout") {
return [];
}
const diagnostics = [];
const bodyRange = getRootBodyRange(source, rootMatch);
const blocks = findNamedBlocks(source, bodyRange.start, bodyRange.end, "lifecycle");
if (blocks.length > 1) {
for (const block of blocks.slice(1)) {
diagnostics.push(
createDiagnostic(
document,
block.nameStart,
block.nameEnd,
"Only one `lifecycle { ... }` block is allowed.",
vscode.DiagnosticSeverity.Error,
"wrn-duplicate-lifecycle",
),
);
}
}
for (const block of blocks) {
if (block.openingBrace === -1) {
diagnostics.push(
createDiagnostic(
document,
block.nameStart,
block.nameEnd,
"`lifecycle` must be followed by a block.",
vscode.DiagnosticSeverity.Error,
"wrn-invalid-lifecycle",
),
);
continue;
}
const lifecycleEnd = block.closingBrace === -1 ? bodyRange.end : block.closingBrace;
let index = block.openingBrace + 1;
const seenHooks = new Set();
while (index < lifecycleEnd) {
index = skipWhitespace(source, index, lifecycleEnd);
if (index >= lifecycleEnd) {
break;
}
const hook = readIdentifier(source, index, lifecycleEnd);
if (!hook) {
index += 1;
continue;
}
index = hook.end;
if (!VALID_LIFECYCLE_HOOKS.has(hook.name)) {
diagnostics.push(
createDiagnostic(
document,
hook.start,
hook.end,
`Unknown lifecycle hook \`${hook.name}\`. Use \`mount\`, \`update\`, or \`unmount\`.`,
vscode.DiagnosticSeverity.Error,
"wrn-invalid-lifecycle-hook",
),
);
} else if (seenHooks.has(hook.name)) {
diagnostics.push(
createDiagnostic(
document,
hook.start,
hook.end,
`Duplicate lifecycle hook \`${hook.name}\`.`,
vscode.DiagnosticSeverity.Error,
"wrn-duplicate-lifecycle-hook",
),
);
} else {
seenHooks.add(hook.name);
}
const openingBrace = skipWhitespace(source, index, lifecycleEnd);
if (source[openingBrace] !== "{") {
diagnostics.push(
createDiagnostic(
document,
hook.start,
hook.end,
`Lifecycle hook \`${hook.name}\` must be followed by a block.`,
vscode.DiagnosticSeverity.Error,
"wrn-invalid-lifecycle-hook",
),
);
index = hook.end;
continue;
}
const closingBrace = findMatchingBrace(source, openingBrace);
index = closingBrace === -1 ? lifecycleEnd : closingBrace + 1;
}
}
return diagnostics;
}
function validateWatchBlocks(document, source, rootKind, rootMatch) {
if (rootKind !== "component" && rootKind !== "layout") {
return [];
}
const diagnostics = [];
const bodyRange = getRootBodyRange(source, rootMatch);
const states = findStateDeclarations(source, bodyRange.start, bodyRange.end);
const watches = findWatchDeclarations(source, bodyRange.start, bodyRange.end);
for (const watch of watches) {
if (!watch.state) {
diagnostics.push(
createDiagnostic(
document,
watch.watchStart,
watch.watchEnd,
"`watch` must be followed by a declared state name.",
vscode.DiagnosticSeverity.Error,
"wrn-invalid-watch",
),
);
continue;
}
if (!states.has(watch.state.name)) {
diagnostics.push(
createDiagnostic(
document,
watch.state.start,
watch.state.end,
`Cannot watch undeclared state \`${watch.state.name}\`.`,
vscode.DiagnosticSeverity.Error,
"wrn-unknown-watch-state",
),
);
}
if (watch.openingBrace === -1) {
diagnostics.push(
createDiagnostic(
document,
watch.state.start,
watch.state.end,
`Watcher for \`${watch.state.name}\` must be followed by a block.`,
vscode.DiagnosticSeverity.Error,
"wrn-invalid-watch",
),
);
}
}
return diagnostics;
}
function validateRequiredView(document, source, rootKind, rootMatch) {
const bodyRange = getRootBodyRange(source, rootMatch);
const body = source.slice(bodyRange.start, bodyRange.end);
if (/\bview\s*\{/.test(body)) {
@@ -550,6 +1008,7 @@ function validateDocument(document) {
if (declaration.diagnostic) {
diagnostics.push(declaration.diagnostic);
diagnostics.push(...validateBalancedCharacters(document, source));
return diagnostics;
@@ -576,6 +1035,12 @@ function validateDocument(document) {
diagnostics.push(...validateRootMembers(document, source, declaration.kind, declaration.match));
diagnostics.push(
...validateLifecycleBlocks(document, source, declaration.kind, declaration.match),
);
diagnostics.push(...validateWatchBlocks(document, source, declaration.kind, declaration.match));
diagnostics.push(...validateRequiredView(document, source, declaration.kind, declaration.match));
diagnostics.push(...validateLayoutUsage(document, source, declaration.kind));
@@ -593,19 +1058,20 @@ function registerDiagnostics(context) {
return;
}
const previousTimer = timers.get(document.uri.toString());
const key = document.uri.toString();
const previousTimer = timers.get(key);
if (previousTimer) {
clearTimeout(previousTimer);
}
const timer = setTimeout(() => {
timers.delete(document.uri.toString());
timers.delete(key);
collection.set(document.uri, validateDocument(document));
}, 150);
timers.set(document.uri.toString(), timer);
timers.set(key, timer);
};
for (const document of vscode.workspace.textDocuments) {
@@ -652,5 +1118,7 @@ module.exports = {
validateBalancedCharacters,
validateDocument,
validateHtmlTags,
validateLifecycleBlocks,
validateRootMembers,
validateWatchBlocks,
};
+243 -217
View File
@@ -2,257 +2,283 @@
"use strict";
const vscode = require("vscode");
const { formatWrn } = require("./formatter");
const { registerCompletionProvider } = require("./completion");
const { registerDefinitionProvider } = require("./definition");
const { registerDiagnostics } = require("./diagnostics");
// 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.
/**
* 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 (err) {
console.warn("[wrnexus] compiler bundle not found; diagnostics disabled.", err && err.message);
} catch (error) {
console.warn(
"[wrnexus] compiler bundle not found; compiler diagnostics disabled.",
error instanceof Error ? error.message : String(error),
);
}
/** 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",
];
const WRN_LANGUAGE_ID = "wrn";
const COMPILER_DIAGNOSTIC_COLLECTION = "wrnexus-compiler";
/**
* Register the WRN document formatter.
*
* @param {vscode.ExtensionContext} context
*/
function activate(context) {
registerDiagnostics(context);
registerCompletionProvider(context);
registerDefinitionProvider(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.registerDocumentFormattingEditProvider("wrn", {
function registerFormatter(context) {
const provider = vscode.languages.registerDocumentFormattingEditProvider(
{
language: WRN_LANGUAGE_ID,
scheme: "file",
},
{
provideDocumentFormattingEdits(document, options) {
const configuration = vscode.workspace.getConfiguration("wrnexus", document.uri);
const enabled = configuration.get("format.enable", true);
if (!enabled) {
return [];
}
const source = document.getText();
const printWidth = configuration.get("formatting.printWidth", 100);
const formatted = formatWrn(source, {
tabSize: options.tabSize,
insertSpaces: options.insertSpaces,
printWidth: 100,
printWidth,
});
if (formatted === source) {
return [];
}
const range = new vscode.Range(document.positionAt(0), document.positionAt(source.length));
return [vscode.TextEdit.replace(range, formatted)];
},
},
);
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.languages.registerCompletionItemProvider(
"wrn",
{ provideCompletionItems: provideCompletions },
"-",
"@",
"{",
":",
),
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();
},
},
);
}
/**
* 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}
* Activate the WRNexus VS Code extension.
*
* @param {vscode.ExtensionContext} context
*/
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;
}
function activate(context) {
registerDiagnostics(context);
registerCompilerDiagnostics(context);
/**
* @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;
registerCompletionProvider(context);
registerDefinitionProvider(context);
registerFormatter(context);
}
function deactivate() {}
module.exports = { activate, deactivate };
module.exports = {
activate,
deactivate,
registerCompilerDiagnostics,
registerFormatter,
toCompilerDiagnostic,
};
+163 -45
View File
@@ -19,24 +19,35 @@ const VOID_ELEMENTS = new Set([
function findOpeningTagEnd(value) {
let quote = null;
let escaped = false;
for (let index = 0; index < value.length; index += 1) {
const char = value[index];
const character = value[index];
if (quote !== null) {
if (char === quote && value[index - 1] !== "\\") {
if (escaped) {
escaped = false;
continue;
}
if (character === "\\") {
escaped = true;
continue;
}
if (character === quote) {
quote = null;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
if (character === '"' || character === "'") {
quote = character;
continue;
}
if (char === ">") {
if (character === ">") {
return index;
}
}
@@ -46,7 +57,8 @@ function findOpeningTagEnd(value) {
function parseAttributes(value) {
const attributes = [];
const pattern = /[^\s"'=<>`]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?/g;
const pattern = /[^\s"'=<>`]+(?:\s*=\s*(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s"'=<>`]+))?/g;
let match;
@@ -65,17 +77,21 @@ function parseOpeningTag(value) {
}
const openingPart = value.slice(0, endIndex + 1);
const remainder = value.slice(endIndex + 1).trim();
const match = /^<([A-Za-z][\w:-]*)([\s\S]*?)(\/?)>$/.exec(openingPart);
const match = /^<([A-Za-z][\w$:.-]*)([\s\S]*?)(\/?)>$/.exec(openingPart);
if (!match) {
return null;
}
const tagName = match[1];
const attributes = parseAttributes(match[2].trim());
const selfClosing = match[3] === "/";
const inlineClosing = remainder === `</${tagName}>`;
return {
@@ -98,7 +114,9 @@ function formatOpeningTag(value, unit, depth, printWidth = 100) {
}
const baseIndent = unit.repeat(depth);
const attributeIndent = unit.repeat(depth + 1);
const normalizedSingleLine = value.replace(/\s+/g, " ").trim();
const shouldBreak =
@@ -159,42 +177,144 @@ function isMultilineOpeningTagStart(value) {
}
function isClosingTag(value) {
return /^<\/[A-Za-z][\w:-]*\s*>/.test(value);
return /^<\/[A-Za-z][\w$:.-]*\s*>/.test(value);
}
function isInlineElement(value) {
return /^<([A-Za-z][\w:-]*)\b[^>]*>[\s\S]*<\/\1\s*>$/.test(value);
return /^<([A-Za-z][\w$:.-]*)\b[^>]*>[\s\S]*<\/\1\s*>$/.test(value);
}
function isWrnBlockClosing(value) {
return value === "}" || value.startsWith("} ");
}
function countLeadingClosingBraces(value) {
let index = 0;
let count = 0;
function isWrnBlockOpening(value) {
if (!value.endsWith("{")) {
return false;
while (index < value.length) {
while (index < value.length && /\s/.test(value[index])) {
index += 1;
}
if (value[index] !== "}") {
break;
}
count += 1;
index += 1;
}
return !value.startsWith("{");
return count;
}
/**
* Count braces outside strings and HTML comments.
*
* This supports WRN blocks, function bodies, lifecycle hooks,
* watcher bodies and multiline JavaScript object literals.
*/
function countStructuralBraces(value) {
let openings = 0;
let closings = 0;
let quote = null;
let escaped = false;
let htmlComment = false;
for (let index = 0; index < value.length; index += 1) {
if (!quote && !htmlComment && value.startsWith("<!--", index)) {
htmlComment = true;
index += 3;
continue;
}
if (htmlComment && value.startsWith("-->", index)) {
htmlComment = false;
index += 2;
continue;
}
if (htmlComment) {
continue;
}
const character = value[index];
if (quote !== null) {
if (escaped) {
escaped = false;
continue;
}
if (character === "\\") {
escaped = true;
continue;
}
if (character === quote) {
quote = null;
}
continue;
}
if (character === '"' || character === "'" || character === "`") {
quote = character;
continue;
}
if (character === "{") {
openings += 1;
} else if (character === "}") {
closings += 1;
}
}
return {
openings,
closings,
};
}
function collectOpeningTag(inputLines, startIndex) {
const collected = [inputLines[startIndex].trim()];
let index = startIndex;
while (index + 1 < inputLines.length) {
const joined = collected.join(" ");
if (findOpeningTagEnd(joined) !== -1) {
break;
}
index += 1;
collected.push(inputLines[index].trim());
}
return {
value: collected.join(" "),
endIndex: index,
};
}
function formatWrn(source, options = {}) {
const unit = options.insertSpaces === false ? "\t" : " ".repeat(options.tabSize || 4);
const printWidth = options.printWidth || 100;
const inputLines = source.replace(/\r\n/g, "\n").split("\n");
const output = [];
let wrnDepth = 0;
let codeDepth = 0;
let htmlDepth = 0;
let index = 0;
let previousWasBlank = false;
while (index < inputLines.length) {
const originalLine = inputLines[index];
const trimmed = originalLine.trim();
if (trimmed === "") {
let value = originalLine.trim();
if (value === "") {
if (!previousWasBlank && output.length > 0) {
output.push("");
}
@@ -206,43 +326,31 @@ function formatWrn(source, options = {}) {
previousWasBlank = false;
let value = trimmed;
if (isMultilineOpeningTagStart(value)) {
const collected = [value];
let cursor = index + 1;
const collected = collectOpeningTag(inputLines, index);
while (cursor < inputLines.length) {
const nextPart = inputLines[cursor].trim();
collected.push(nextPart);
const joined = collected.join(" ");
if (findOpeningTagEnd(joined) !== -1) {
break;
}
cursor += 1;
}
value = collected.join(" ");
index = cursor;
value = collected.value;
index = collected.endIndex;
}
if (isWrnBlockClosing(value)) {
wrnDepth = Math.max(0, wrnDepth - 1);
}
const leadingClosingBraces = countLeadingClosingBraces(value);
const lineCodeDepth = Math.max(0, codeDepth - leadingClosingBraces);
let lineHtmlDepth = htmlDepth;
if (isClosingTag(value)) {
htmlDepth = Math.max(0, htmlDepth - 1);
lineHtmlDepth = Math.max(0, htmlDepth - 1);
}
const depth = wrnDepth + htmlDepth;
const depth = lineCodeDepth + lineHtmlDepth;
if (
value.startsWith("<") &&
!value.startsWith("</") &&
!value.startsWith("<!--") &&
!value.startsWith("<!") &&
!value.startsWith("<?") &&
!isInlineElement(value)
) {
const formattedTag = formatOpeningTag(value, unit, depth, printWidth);
@@ -256,10 +364,17 @@ function formatWrn(source, options = {}) {
output.push(`${unit.repeat(depth)}${value}`);
}
if (isWrnBlockOpening(value)) {
wrnDepth += 1;
if (isClosingTag(value)) {
htmlDepth = lineHtmlDepth;
}
const braces = countStructuralBraces(value);
codeDepth = Math.max(
0,
lineCodeDepth + braces.openings - Math.max(0, braces.closings - leadingClosingBraces),
);
index += 1;
}
@@ -271,6 +386,9 @@ function formatWrn(source, options = {}) {
}
module.exports = {
countStructuralBraces,
formatOpeningTag,
formatWrn,
parseAttributes,
parseOpeningTag,
};