release: WRNexusJS 0.6.0

This commit is contained in:
2026-08-01 01:09:58 +05:30
parent 3e565e8d03
commit 687d345882
502 changed files with 33038 additions and 11358 deletions
+3 -3
View File
@@ -1,8 +1,8 @@
{
"name": "wrnexus",
"displayName": "WRNexus Language Support",
"description": "Complete language support for WRNexus .wrn files, including highlighting, formatting, diagnostics, snippets, lifecycle hooks, state watchers, component functions, completions, and definition navigation.",
"version": "0.5.1",
"description": "Complete WRNexus v0.6 language support for typed imports, props, state, outputs, runtime functions, stores, diagnostics, formatting, navigation, and migration assistance.",
"version": "0.6.0",
"publisher": "wrnexus",
"private": true,
"license": "SEE LICENSE IN LICENSE",
@@ -68,7 +68,7 @@
"extensions": [
".wrn"
],
"firstLine": "^\\s*(?:(?:import\\b[^;\\n]*(?:;|\\n)\\s*)*)(?:page|component|layout)\\s+[A-Za-z_$][A-Za-z0-9_$]*\\s*\\{",
"firstLine": "^\\s*(?:(?:import\\b[^;\\n]*(?:;|\\n)\\s*)*)(?:(?:global|page)\\s+store|page|component|layout)\\s+[A-Za-z_$][A-Za-z0-9_$]*\\s*\\{",
"configuration": "./language-configuration.json",
"icon": {
"light": "./icons/wrn.png",
+83
View File
@@ -406,5 +406,88 @@
"prefix": ["wrn-action", "action"],
"description": "Declare a named server action",
"body": ["action ${1:save}(${2:input}) {", " $0", "}"]
},
"WRN type import": {
"prefix": ["wrn-import-type"],
"description": "Import an application TypeScript type",
"body": ["import type {", " ${1:PublicUser}", "} from \"@/types/${2:user}.ts\"", "$0"]
},
"WRN component import": {
"prefix": ["wrn-import-component"],
"description": "Import a WRN component",
"body": ["import ${1:UserCard}", " from \"@/components/${1:UserCard}.wrn\"", "$0"]
},
"WRN outputs": {
"prefix": ["wrn-outputs"],
"description": "Declare typed callable outputs",
"body": [
"outputs {",
" ${1:confirm}(payload: ${2:ConfirmPayload})",
" ${3:cancel}()",
"}"
]
},
"WRN client function": {
"prefix": ["wrn-client-function"],
"description": "Declare a browser-only function",
"body": ["client ${1|,async |}function ${2:name}(${3}): ${4:void} {", " $0", "}"]
},
"WRN server function": {
"prefix": ["wrn-server-function"],
"description": "Declare a server-only function",
"body": ["server ${1|,async |}function ${2:name}(${3}): ${4:void} {", " $0", "}"]
},
"WRN shared function": {
"prefix": ["wrn-shared-function"],
"description": "Declare a shared function",
"body": ["shared function ${1:name}(${2}): ${3:void} {", " $0", "}"]
},
"WRN state block": {
"prefix": ["wrn-state-block"],
"description": "Declare grouped state",
"body": ["state {", " ${1:name}: ${2:string} = ${3:\"\"}", "}"]
},
"WRN client state": {
"prefix": ["wrn-client-state"],
"description": "Declare client state",
"body": ["client state {", " ${1:menuOpen}: boolean = false", "}"]
},
"WRN server state": {
"prefix": ["wrn-server-state"],
"description": "Declare server state",
"body": ["server state {", " ${1:sessionId}: string | null = null", "}"]
},
"WRN global store": {
"prefix": ["wrn-global-store"],
"description": "Declare a request-scoped global store",
"body": [
"global store ${1:UserStore} {",
" state {",
" ${2:user}: ${3:PublicUser | null} = null",
" }",
"}"
]
},
"WRN page store": {
"prefix": ["wrn-page-store"],
"description": "Declare a route-scoped page store",
"body": [
"page store ${1:SearchStore} {",
" state {",
" ${2:query}: string = \"\"",
" }",
"}"
]
},
"WRN persist": {
"prefix": ["wrn-persist"],
"description": "Declare store persistence",
"body": [
"persist {",
" storage = \"${1|memory,session,local|}\"",
" include = [\"${2:field}\"]",
" version = ${3:1}",
"}"
]
}
}
File diff suppressed because it is too large Load Diff
+86
View File
@@ -231,6 +231,92 @@ const BLOCK_COMPLETIONS = [
documentation: "Declare styles for the current WRN declaration.",
snippet: ["style {", " $0", "}"].join("\n"),
},
{
label: "import type",
detail: "WRN v0.6 TypeScript type import",
snippet: 'import type { ${1:PublicUser} } from "${2:@/types/user.ts}"\n\n$0',
},
{
label: "outputs",
detail: "Typed callable component outputs",
snippet: [
"outputs {",
" ${1:confirm}(payload: ${2:ConfirmPayload})",
" ${3:cancel}()",
"}",
].join("\n"),
},
{
label: "client function",
detail: "Browser-only typed function",
snippet: ["client ${1|,async |}function ${2:name}(${3}): ${4:void} {", " $0", "}"].join(
"\n",
),
},
{
label: "server function",
detail: "Server-only typed function",
snippet: ["server ${1|,async |}function ${2:name}(${3}): ${4:void} {", " $0", "}"].join(
"\n",
),
},
{
label: "shared function",
detail: "Server-and-browser typed function",
snippet: ["shared function ${1:name}(${2}): ${3:void} {", " $0", "}"].join("\n"),
},
{
label: "state block",
detail: "Grouped typed reactive state",
snippet: ["state {", ' ${1:name}: ${2:string} = ${3:""}', "}"].join("\n"),
},
{
label: "client state",
detail: "Browser-only state block",
snippet: ["client state {", " ${1:menuOpen}: boolean = false", "}"].join("\n"),
},
{
label: "server state",
detail: "Server-only state block",
snippet: ["server state {", " ${1:sessionId}: string | null = null", "}"].join("\n"),
},
{
label: "global store",
detail: "Request-scoped global store",
snippet: [
"global store ${1:UserStore} {",
" state {",
" ${2:user}: ${3:PublicUser | null} = null",
" }",
"",
" computed {",
" ${4:authenticated}: boolean = ${2:user} !== null",
" }",
"}",
].join("\n"),
},
{
label: "page store",
detail: "Route-scoped page store",
snippet: [
"page store ${1:PageStore} {",
" state {",
" ${2:loading}: boolean = false",
" }",
"}",
].join("\n"),
},
{
label: "persist",
detail: "Include-only store persistence",
snippet: [
"persist {",
' storage = "${1|memory,session,local|}"',
' include = ["${2:field}"]',
" version = ${3:1}",
"}",
].join("\n"),
},
];
const ATTRIBUTE_COMPLETIONS = [
@@ -1,6 +1,7 @@
"use strict";
const vscode = require("vscode");
const path = require("node:path");
const {
parseComponentMetadata,
parseComponentTags,
@@ -75,6 +76,38 @@ function openingTagAt(source, offset) {
return match ? { name: match[1], fragment } : null;
}
function componentAlreadyImported(source, name) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return (
new RegExp(`\\bimport[\\s\\S]*?\\b${escaped}\\b[\\s\\S]*?\\bfrom\\b`).test(source) ||
new RegExp(`\\b(?:component|layout)\\s+${escaped}\\b`).test(source)
);
}
function importEditForComponent(document, component) {
if (!component?.uri || componentAlreadyImported(document.getText(), component.name)) return null;
const target = component.uri.fsPath.replace(/\\/g, "/");
const current = document.uri.fsPath.replace(/\\/g, "/");
let statement;
if (
target.includes("/node_modules/@wrnexus/ui/components/") ||
target.includes("/packages/ui/components/")
) {
statement = `import { ${component.name} } from "@wrnexus/ui"\n`;
} else {
const appMarker = "/app/";
const appIndex = target.lastIndexOf(appMarker);
if (appIndex !== -1) {
statement = `import ${component.name} from "@/${target.slice(appIndex + appMarker.length)}"\n`;
} else {
let relative = path.posix.relative(path.posix.dirname(current), target);
if (!relative.startsWith(".")) relative = `./${relative}`;
statement = `import ${component.name} from "${relative}"\n`;
}
}
return vscode.TextEdit.insert(new vscode.Position(0, 0), statement);
}
function propSnippet(prop) {
if (prop.type === "boolean") return `${prop.name}="{\${1:false}}"`;
if (prop.type === "number") return `${prop.name}="{\${1:0}}"`;
@@ -127,6 +160,8 @@ async function provideComponentCompletions(document, position) {
item.insertText = new vscode.SnippetString(
`${component.name}${required.map((prop, index) => ` ${prop.name}="\${${index + 1}}"`).join("")} />`,
);
const importEdit = importEditForComponent(document, component);
if (importEdit) item.additionalTextEdits = [importEdit];
items.push(item);
}
return items;
@@ -236,7 +271,9 @@ function registerComponentIntelligence(context) {
}
module.exports = {
componentAlreadyImported,
componentMarkdown,
importEditForComponent,
openingTagAt,
propDocumentation,
propSnippet,
+38 -8
View File
@@ -54,6 +54,14 @@ function stringLiteral(value) {
return match ? (match[1] ?? match[2] ?? match[3]) : null;
}
function declaredOptions(type) {
const value = String(type || "").trim();
if (!value) return [];
const parts = value.split("|").map((part) => part.trim());
if (!parts.every((part) => /^(?:"[^"]*"|'[^']*')$/.test(part))) return [];
return parts.map((part) => part.slice(1, -1));
}
function inferOptions(source, propName) {
const escaped = propName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const options = new Set();
@@ -77,22 +85,27 @@ function parseComponentMetadata(source, uri = null) {
const bodyEnd = closingBrace === -1 ? source.length : closingBrace;
const body = source.slice(openingBrace + 1, bodyEnd);
const linePattern =
/^(?:\s*\/\/\s*@required\s*\r?\n)?\s*([A-Za-z_$][\w$]*)(?:\s*:\s*([^=\r\n]+?))?(?:\s*=\s*(.*?))?\s*$/gm;
/^(?:\s*\/\/\s*@required\s*\r?\n)?\s*([A-Za-z_$][\w$]*)(\?)?(?:\s*:\s*([^=\r\n]+?))?(?:\s*=\s*(.*?))?\s*$/gm;
let propMatch;
while ((propMatch = linePattern.exec(body)) !== null) {
const annotation = propMatch[2] && propMatch[2].trim();
const hasDefault = propMatch[3] !== undefined;
const defaultValue = hasDefault ? propMatch[3] : "undefined";
const optional = Boolean(propMatch[2]);
const annotation = propMatch[3] && propMatch[3].trim();
const hasDefault = propMatch[4] !== undefined;
const defaultValue = hasDefault ? propMatch[4] : "undefined";
const name = propMatch[1];
props.push({
name,
defaultValue,
required:
!hasDefault ||
defaultValue.trim() === "undefined" ||
/^\s*\/\/\s*@required/m.test(propMatch[0]),
!optional &&
(!hasDefault ||
defaultValue.trim() === "undefined" ||
/^\s*\/\/\s*@required/m.test(propMatch[0])),
type: annotation || inferType(defaultValue),
options: inferOptions(source, name),
options:
declaredOptions(annotation).length > 0
? declaredOptions(annotation)
: inferOptions(source, name),
});
}
const eventPattern = /^\s*@event\s+([A-Za-z_$][\w$]*)\s*=\s*function\s*$/gm;
@@ -100,6 +113,22 @@ function parseComponentMetadata(source, uri = null) {
while ((eventMatch = eventPattern.exec(body)) !== null) events.push(eventMatch[1]);
}
const outputsKeyword = /\boutputs\s*\{/.exec(source.slice(declaration.index));
if (outputsKeyword) {
const start = declaration.index + outputsKeyword.index;
const openingBrace = source.indexOf("{", start);
const closingBrace = findMatchingBrace(source, openingBrace);
const outputBody = source.slice(
openingBrace + 1,
closingBrace === -1 ? source.length : closingBrace,
);
const outputPattern = /(?:^|\s)([A-Za-z_$][\w$]*)\s*\(/g;
let outputMatch;
while ((outputMatch = outputPattern.exec(outputBody)) !== null) {
if (!events.includes(outputMatch[1])) events.push(outputMatch[1]);
}
}
return { kind: declaration[1], name: declaration[2], props, events, uri };
}
@@ -243,6 +272,7 @@ function validateComponentTags(source, components) {
module.exports = {
attributeValueType,
declaredOptions,
inferType,
runtimeType,
isTypeCompatible,
+77 -2
View File
@@ -5,9 +5,16 @@ const vscode = require("vscode");
const COLLECTION_NAME = "wrnexus";
const WRN_LANGUAGE_ID = "wrn";
const TOP_LEVEL_PATTERN = /^\s*(page|component|layout)\s+([A-Za-z_$][\w$]*)\s*\{/;
const TOP_LEVEL_PATTERN =
/^\s*((?:global|page)\s+store|page|component|layout)\s+([A-Za-z_$][\w$]*)\s*\{/;
const VALID_TOP_LEVEL_KINDS = new Set(["page", "component", "layout"]);
const VALID_TOP_LEVEL_KINDS = new Set([
"page",
"component",
"layout",
"global store",
"page store",
]);
const VALID_LIFECYCLE_HOOKS = new Set(["mount", "update", "unmount"]);
@@ -18,7 +25,9 @@ const ROOT_MEMBER_NAMES = [
"client",
"types",
"props",
"outputs",
"state",
"persist",
"computed",
"effect",
"watch",
@@ -39,6 +48,8 @@ const VALID_MEMBERS = {
page: new Set(ROOT_MEMBER_NAMES),
component: new Set(ROOT_MEMBER_NAMES),
layout: new Set(ROOT_MEMBER_NAMES),
"global store": new Set(ROOT_MEMBER_NAMES),
"page store": new Set(ROOT_MEMBER_NAMES),
};
function createDiagnostic(
@@ -1181,6 +1192,68 @@ function validateLayoutUsage(document, source, rootKind, rootMatch) {
return diagnostics;
}
function validateV060Features(document, source) {
const diagnostics = [];
const duplicateRuntimeFunctions = new Map();
for (const match of source.matchAll(
/\b(client|server|shared)\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g,
)) {
const key = `${match[1]}:${match[2]}`;
if (duplicateRuntimeFunctions.has(key)) {
diagnostics.push(
createDiagnostic(
document,
match.index,
match.index + match[0].length,
`Duplicate ${match[1]} function '${match[2]}'.`,
vscode.DiagnosticSeverity.Error,
"WRN-FUNCTION-DUPLICATE",
),
);
} else duplicateRuntimeFunctions.set(key, match.index);
}
for (const match of source.matchAll(/\$emit\s*\(/g)) {
diagnostics.push(
createDiagnostic(
document,
match.index,
match.index + match[0].length,
"Use typed output.name(payload) instead of deprecated $emit().",
vscode.DiagnosticSeverity.Warning,
"WRN-OUTPUT-LEGACY-EMIT",
),
);
}
for (const match of source.matchAll(/\bevent\.detail\b/g)) {
diagnostics.push(
createDiagnostic(
document,
match.index,
match.index + match[0].length,
"Component output handlers receive payload directly.",
vscode.DiagnosticSeverity.Warning,
"WRN-OUTPUT-LEGACY-DETAIL",
),
);
}
for (const match of source.matchAll(
/\bserver\s+(?:async\s+)?function\b[\s\S]*?\b(window|document|localStorage|navigator)\b/g,
)) {
const offset = match.index + match[0].lastIndexOf(match[1]);
diagnostics.push(
createDiagnostic(
document,
offset,
offset + match[1].length,
`Browser API '${match[1]}' is unavailable in a server function.`,
vscode.DiagnosticSeverity.Error,
"WRN-SERVER-BROWSER-API",
),
);
}
return diagnostics;
}
function validateDocument(document) {
if (document.languageId !== WRN_LANGUAGE_ID) {
return [];
@@ -1234,6 +1307,8 @@ function validateDocument(document) {
diagnostics.push(...validateLayoutUsage(document, source, declaration.kind, declaration.match));
diagnostics.push(...validateV060Features(document, source));
return diagnostics;
}
+3
View File
@@ -13,6 +13,8 @@ const { registerComponentIntelligence } = require("./component-intelligence");
const { registerDiagnostics } = require("./diagnostics");
const { registerV060LanguageFeatures } = require("./v060-language");
/**
* The WRN compiler is bundled to CommonJS using:
*
@@ -345,6 +347,7 @@ function activate(context) {
registerComponentIntelligence(context);
registerFormatter(context);
registerSemanticTokens(context);
registerV060LanguageFeatures(context);
}
function deactivate() {}
+84 -7
View File
@@ -26,6 +26,8 @@ function splitPropDeclarations(value) {
let square = 0;
let brace = 0;
let paren = 0;
let segmentHasColon = false;
let segmentHasEquals = false;
const isIdentifierStart = (character) => /[A-Za-z_]/.test(character || "");
const isIdentifierPart = (character) => /[A-Za-z0-9_]/.test(character || "");
@@ -41,13 +43,14 @@ function splitPropDeclarations(value) {
cursor += 1;
while (cursor < value.length && isIdentifierPart(value[cursor])) cursor += 1;
while (cursor < value.length && /[ \t]/.test(value[cursor])) cursor += 1;
return value[cursor] === "=";
return value[cursor] === "=" ? "=" : null;
}
if (!isIdentifierStart(value[cursor])) return false;
cursor += 1;
while (cursor < value.length && isIdentifierPart(value[cursor])) cursor += 1;
if (value[cursor] === "?") cursor += 1;
while (cursor < value.length && /[ \t]/.test(value[cursor])) cursor += 1;
return value[cursor] === "=" || value[cursor] === ":";
return value[cursor] === "=" || value[cursor] === ":" ? value[cursor] : null;
};
while (index < value.length) {
@@ -74,18 +77,27 @@ function splitPropDeclarations(value) {
else if (character === "(") paren += 1;
else if (character === ")" && paren > 0) paren -= 1;
const topLevel = square === 0 && brace === 0 && paren === 0;
if (topLevel && character === ":") segmentHasColon = true;
if (topLevel && character === "=") segmentHasEquals = true;
const candidateDelimiter = topLevel && /\s/.test(character) ? beginsDeclaration(index) : null;
const beginsNext =
candidateDelimiter === ":" ||
(candidateDelimiter === "=" && (segmentHasEquals || !segmentHasColon));
if (
square === 0 &&
brace === 0 &&
paren === 0 &&
topLevel &&
/\s/.test(character) &&
value.slice(start, index).trim() !== "@event" &&
beginsDeclaration(index)
beginsNext
) {
const declaration = value.slice(start, index).trim();
if (declaration) declarations.push(declaration);
while (index < value.length && /\s/.test(value[index])) index += 1;
start = index;
segmentHasColon = false;
segmentHasEquals = false;
continue;
}
@@ -97,6 +109,68 @@ function splitPropDeclarations(value) {
return declarations;
}
function splitOutputDeclarations(value) {
const declarations = [];
let start = 0;
let paren = 0;
let angle = 0;
let square = 0;
let quote = null;
let escaped = false;
const startsOutput = (position) => {
let cursor = position;
while (cursor < value.length && /\s/.test(value[cursor])) cursor += 1;
if (!/[A-Za-z_$]/.test(value[cursor] || "")) return false;
cursor += 1;
while (cursor < value.length && /[A-Za-z0-9_$]/.test(value[cursor] || "")) cursor += 1;
while (cursor < value.length && /\s/.test(value[cursor])) cursor += 1;
return value[cursor] === "(";
};
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (quote !== null) {
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === quote) quote = null;
continue;
}
if (character === '"' || character === "'" || character === "`") {
quote = character;
continue;
}
if (character === "(") paren += 1;
else if (character === ")" && paren > 0) paren -= 1;
else if (character === "[") square += 1;
else if (character === "]" && square > 0) square -= 1;
else if (character === "<") angle += 1;
else if (character === ">" && angle > 0) angle -= 1;
if (paren === 0 && square === 0 && angle === 0 && /\s/.test(character) && startsOutput(index)) {
const declaration = value.slice(start, index).trim();
if (declaration) declarations.push(declaration);
while (index < value.length && /\s/.test(value[index])) index += 1;
start = index;
index -= 1;
}
}
const finalDeclaration = value.slice(start).trim();
if (finalDeclaration) declarations.push(finalDeclaration);
return declarations;
}
function formatInlineDeclarationBlock(value, unit, depth) {
const match = /^(props|state|computed|outputs)\s*\{([\s\S]*)\}$/.exec(value.trim());
if (!match) return null;
const declarations =
match[1] === "outputs"
? splitOutputDeclarations(match[2].trim())
: splitPropDeclarations(match[2].trim());
return [
`${unit.repeat(depth)}${match[1]} {`,
...declarations.map((declaration) => `${unit.repeat(depth + 1)}${declaration}`),
`${unit.repeat(depth)}}`,
];
}
function formatInlinePropsBlock(value, unit, depth) {
const match = /^props\s*\{([\s\S]*)\}$/.exec(value.trim());
if (!match) return null;
@@ -618,7 +692,9 @@ function formatWrn(source, options = {}) {
index = collected.endIndex;
}
const inlineProps = formatInlinePropsBlock(value, unit, codeDepth + htmlDepth);
const inlineDeclaration = formatInlineDeclarationBlock(value, unit, codeDepth + htmlDepth);
const inlineProps =
inlineDeclaration ?? formatInlinePropsBlock(value, unit, codeDepth + htmlDepth);
if (inlineProps) {
output.push(...inlineProps);
index += 1;
@@ -696,5 +772,6 @@ module.exports = {
formatWrn,
parseAttributes,
parseOpeningTag,
splitOutputDeclarations,
splitPropDeclarations,
};
+201
View File
@@ -0,0 +1,201 @@
// @ts-check
"use strict";
const vscode = require("vscode");
const selector = { language: "wrn" };
const WRN_GLOB = "**/*.wrn";
const EXCLUDE_GLOB = "**/{node_modules,dist,.wrnexus,.git}/**";
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function identifierAt(document, position) {
const range = document.getWordRangeAtPosition(position, /[A-Za-z_$][A-Za-z0-9_$]*/);
if (!range) return null;
return { name: document.getText(range), range };
}
function occurrences(document, name) {
const result = [];
const pattern = new RegExp(`\\b${escapeRegExp(name)}\\b`, "g");
const source = document.getText();
let match;
while ((match = pattern.exec(source)) !== null) {
result.push(
new vscode.Range(
document.positionAt(match.index),
document.positionAt(match.index + name.length),
),
);
}
return result;
}
function declarationRanges(document, name) {
const source = document.getText();
const escaped = escapeRegExp(name);
const patterns = [
new RegExp(`\\b(?:page|component|layout)\\s+(${escaped})\\b`, "g"),
new RegExp(`\\b(?:global|page)\\s+store\\s+(${escaped})\\b`, "g"),
new RegExp(`\\b(?:client|server|shared)?\\s*(?:async\\s+)?function\\s+(${escaped})\\b`, "g"),
new RegExp(`\\b(?:client|server|shared)?\\s*state\\s+(${escaped})\\b`, "g"),
new RegExp(`\\boutputs\\s*\\{[\\s\\S]*?\\b(${escaped})\\s*\\(`, "g"),
new RegExp(
`\\b(?:props|state|computed)\\s*\\{[\\s\\S]*?\\b(${escaped})(?:\\?|\\s)*(?=[:=])`,
"g",
),
new RegExp(`\\bimport(?:\\s+type)?[\\s\\S]*?\\b(${escaped})\\b[\\s\\S]*?\\bfrom\\b`, "g"),
];
const ranges = [];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(source)) !== null) {
const relative = match[0].lastIndexOf(match[1]);
const start = match.index + relative;
ranges.push(
new vscode.Range(document.positionAt(start), document.positionAt(start + name.length)),
);
}
}
return ranges;
}
async function wrnDocuments() {
const uris = await vscode.workspace.findFiles(WRN_GLOB, EXCLUDE_GLOB);
const open = new Map(
vscode.workspace.textDocuments
.filter((item) => item.languageId === "wrn")
.map((item) => [item.uri.toString(), item]),
);
return Promise.all(
uris.map((uri) => open.get(uri.toString()) || vscode.workspace.openTextDocument(uri)),
);
}
async function provideReferences(document, position, context, token) {
const symbol = identifierAt(document, position);
if (!symbol || token.isCancellationRequested) return [];
const locations = [];
for (const candidate of await wrnDocuments()) {
if (token.isCancellationRequested) return locations;
for (const range of occurrences(candidate, symbol.name)) {
if (
!context.includeDeclaration &&
declarationRanges(candidate, symbol.name).some((decl) => decl.isEqual(range))
)
continue;
locations.push(new vscode.Location(candidate.uri, range));
}
}
return locations;
}
async function prepareRename(document, position) {
const symbol = identifierAt(document, position);
if (!symbol) throw new Error("Place the cursor on a WRN identifier.");
return { range: symbol.range, placeholder: symbol.name };
}
async function provideRenameEdits(document, position, newName, token) {
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(newName))
throw new Error("The new WRN name is not a valid identifier.");
const symbol = identifierAt(document, position);
if (!symbol) return null;
const edit = new vscode.WorkspaceEdit();
for (const candidate of await wrnDocuments()) {
if (token.isCancellationRequested) return edit;
for (const range of occurrences(candidate, symbol.name))
edit.replace(candidate.uri, range, newName);
}
return edit;
}
function importRanges(source) {
const ranges = [];
const pattern =
/^import(?:\s+type)?[\s\S]*?(?:;\s*|\n(?=import|\s*(?:page|component|layout|global\s+store|page\s+store)\b))/gm;
let match;
while ((match = pattern.exec(source)) !== null)
ranges.push({ start: match.index, end: match.index + match[0].length, text: match[0].trim() });
return ranges;
}
function organizeImportsEdit(document) {
const source = document.getText();
const imports = importRanges(source);
if (imports.length < 2) return null;
const ordered = [...imports].sort((a, b) => {
const aType = /^import\s+type\b/.test(a.text) ? 0 : 1;
const bType = /^import\s+type\b/.test(b.text) ? 0 : 1;
return aType - bType || a.text.localeCompare(b.text);
});
const replacement = ordered.map((entry) => entry.text.replace(/;$/, "")).join("\n") + "\n";
const start = imports[0].start;
const end = imports.at(-1).end;
if (source.slice(start, end) === replacement) return null;
return vscode.TextEdit.replace(
new vscode.Range(document.positionAt(start), document.positionAt(end)),
replacement,
);
}
function provideCodeActions(document, range, context) {
const actions = [];
const organizeEdit = organizeImportsEdit(document);
if (organizeEdit) {
const action = new vscode.CodeAction(
"Organize WRN imports",
vscode.CodeActionKind.SourceOrganizeImports,
);
action.edit = new vscode.WorkspaceEdit();
action.edit.set(document.uri, [organizeEdit]);
actions.push(action);
}
for (const diagnostic of context.diagnostics) {
if (diagnostic.code === "WRN-OUTPUT-LEGACY-EMIT") {
const source = document.getText(diagnostic.range);
const match = /\$emit\(\s*["']([A-Za-z_$][\w$]*)["']\s*,?/.exec(source);
if (!match) continue;
const action = new vscode.CodeAction(
`Convert $emit to output.${match[1]}`,
vscode.CodeActionKind.QuickFix,
);
action.diagnostics = [diagnostic];
action.isPreferred = true;
action.edit = new vscode.WorkspaceEdit();
action.edit.replace(
document.uri,
diagnostic.range,
source.replace(/\$emit\(\s*["'][A-Za-z_$][\w$]*["']\s*,?\s*/, `output.${match[1]}(`),
);
actions.push(action);
}
}
return actions;
}
function registerV060LanguageFeatures(context) {
context.subscriptions.push(
vscode.languages.registerReferenceProvider(selector, { provideReferences }),
vscode.languages.registerRenameProvider(selector, { prepareRename, provideRenameEdits }),
vscode.languages.registerCodeActionsProvider(
selector,
{ provideCodeActions },
{
providedCodeActionKinds: [
vscode.CodeActionKind.QuickFix,
vscode.CodeActionKind.SourceOrganizeImports,
],
},
),
);
}
module.exports = {
declarationRanges,
importRanges,
organizeImportsEdit,
registerV060LanguageFeatures,
};
+180 -8
View File
@@ -6,6 +6,9 @@
{
"include": "#comments"
},
{
"include": "#v060-keywords"
},
{
"include": "#imports"
},
@@ -46,12 +49,18 @@
]
},
"declaration": {
"match": "\\b(page|component|layout)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)?",
"match": "\\b(?:(global|page)\\s+(store)|(page|component|layout))\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)?",
"captures": {
"1": {
"name": "storage.type.wrn keyword.control.wrn"
"name": "storage.modifier.wrn"
},
"2": {
"name": "storage.type.wrn keyword.control.wrn"
},
"3": {
"name": "storage.type.wrn keyword.control.wrn"
},
"4": {
"name": "entity.name.type.wrn"
}
}
@@ -73,6 +82,9 @@
{
"include": "#props-block"
},
{
"include": "#outputs-block"
},
{
"include": "#lifecycle-block"
},
@@ -112,6 +124,12 @@
{
"include": "#state-decl"
},
{
"include": "#grouped-state-block"
},
{
"include": "#persist-block"
},
{
"include": "#layout-decl"
},
@@ -201,7 +219,7 @@
"include": "#comments"
},
{
"match": "(@event)\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s*(=)\\s*(function)\\b",
"match": "(outputs|@event)\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s*(=)\\s*(function)\\b",
"captures": {
"1": {
"name": "keyword.control.event.wrn"
@@ -333,15 +351,18 @@
"contentName": "meta.embedded.block.ts",
"patterns": [
{
"match": "\\b(async\\s+)?(function)\\s+([A-Za-z_$][A-Za-z0-9_$]*)",
"match": "\\b(?:(client|server|shared)\\s+)?(async\\s+)?(function)\\s+([A-Za-z_$][A-Za-z0-9_$]*)",
"captures": {
"1": {
"name": "storage.modifier.async.ts"
"name": "storage.modifier.runtime.wrn"
},
"2": {
"name": "storage.type.function.ts"
"name": "storage.modifier.async.ts"
},
"3": {
"name": "storage.type.function.ts"
},
"4": {
"name": "entity.name.function.wrn"
}
}
@@ -375,7 +396,7 @@
"include": "#comments"
},
{
"begin": "\\b(mount|update|unmount)\\b\\s*(\\{)",
"begin": "\\b(mount|update|unmount|serverInit|clientInit|hydrate|dispose)\\b\\s*(\\{)",
"beginCaptures": {
"1": {
"name": "entity.name.function.lifecycle.wrn keyword.control.wrn"
@@ -841,12 +862,18 @@
"include": "#comments"
},
{
"match": "\\b([A-Za-z_$][A-Za-z0-9_$]*)\\s*(=)",
"match": "\\b([A-Za-z_$][A-Za-z0-9_$]*)(?:\\s*(:)\\s*([^=\\r\\n]+?))?\\s*(=)",
"captures": {
"1": {
"name": "variable.other.readwrite.declaration.wrn"
},
"2": {
"name": "punctuation.separator.type.wrn"
},
"3": {
"name": "storage.type.wrn"
},
"4": {
"name": "keyword.operator.assignment.wrn"
}
}
@@ -978,6 +1005,151 @@
"include": "source.ts"
}
]
},
"v060-keywords": {
"patterns": [
{
"name": "storage.modifier.runtime.wrn",
"match": "\\b(client|server|shared)\\b(?=\\s+(?:async\\s+)?function|\\s+state\\b)"
},
{
"name": "support.variable.language.wrn",
"match": "\\b(output|server|props|refs|payload)\\b"
},
{
"name": "keyword.control.wrn",
"match": "\\b(outputs|persist)\\b(?=\\s*\\{)"
}
]
},
"outputs-block": {
"begin": "\\b(outputs)\\b\\s*(\\{)",
"beginCaptures": {
"1": {
"name": "keyword.control.wrn"
},
"2": {
"name": "punctuation.definition.block.begin.wrn"
}
},
"end": "\\}",
"endCaptures": {
"0": {
"name": "punctuation.definition.block.end.wrn"
}
},
"patterns": [
{
"include": "#comments"
},
{
"match": "\\b([A-Za-z_$][A-Za-z0-9_$]*)\\s*(\\()\\s*(?:([A-Za-z_$][A-Za-z0-9_$]*)(\\??)\\s*(:)\\s*([^\\)]+))?\\s*(\\))",
"captures": {
"1": {
"name": "entity.name.function.event.wrn"
},
"2": {
"name": "punctuation.definition.parameters.begin.wrn"
},
"3": {
"name": "variable.parameter.wrn"
},
"4": {
"name": "keyword.operator.optional.wrn"
},
"5": {
"name": "punctuation.separator.type.wrn"
},
"6": {
"name": "storage.type.wrn"
},
"7": {
"name": "punctuation.definition.parameters.end.wrn"
}
}
}
]
},
"grouped-state-block": {
"begin": "\\b(?:(client|server|shared)\\s+)?(state)\\b\\s*(\\{)",
"beginCaptures": {
"1": {
"name": "storage.modifier.runtime.wrn"
},
"2": {
"name": "keyword.control.wrn"
},
"3": {
"name": "punctuation.definition.block.begin.wrn"
}
},
"end": "\\}",
"endCaptures": {
"0": {
"name": "punctuation.definition.block.end.wrn"
}
},
"patterns": [
{
"include": "#comments"
},
{
"match": "\\b([A-Za-z_$][A-Za-z0-9_$]*)(?:\\s*(:)\\s*([^=\\r\\n]+?))?\\s*(=)",
"captures": {
"1": {
"name": "variable.other.readwrite.declaration.wrn"
},
"2": {
"name": "punctuation.separator.type.wrn"
},
"3": {
"name": "storage.type.wrn"
},
"4": {
"name": "keyword.operator.assignment.wrn"
}
}
},
{
"include": "source.ts"
}
]
},
"persist-block": {
"begin": "\\b(persist)\\b\\s*(\\{)",
"beginCaptures": {
"1": {
"name": "keyword.control.wrn"
},
"2": {
"name": "punctuation.definition.block.begin.wrn"
}
},
"end": "\\}",
"endCaptures": {
"0": {
"name": "punctuation.definition.block.end.wrn"
}
},
"patterns": [
{
"include": "#comments"
},
{
"match": "\\b(storage|include|version)\\b\\s*(=)",
"captures": {
"1": {
"name": "support.type.property-name.wrn"
},
"2": {
"name": "keyword.operator.assignment.wrn"
}
}
},
{
"include": "source.ts"
}
]
}
}
}
@@ -128,3 +128,24 @@ test("extracts declared events and validates component event bindings", () => {
["wrn-unknown-component-event"],
);
});
test("extracts v0.6 optional props, declared union options, and outputs", () => {
const metadata = parseComponentMetadata(`component Dialog {
outputs { confirm(payload: string) cancel() }
props {
title: string
description?: string
size: "small" | "large" = "small"
}
view { <div></div> }
}`);
assert.deepEqual(metadata.events, ["confirm", "cancel"]);
assert.deepEqual(
metadata.props.map(({ name, required, options }) => ({ name, required, options })),
[
{ name: "title", required: true, options: [] },
{ name: "description", required: false, options: [] },
{ name: "size", required: false, options: ["small", "large"] },
],
);
});
+10
View File
@@ -245,3 +245,13 @@ test("expands inline if blocks around HTML", () => {
assert.equal(formatWrn(source, options), expected);
assert.equal(formatWrn(expected, options), expected);
});
test("formats v0.6 grouped state and outputs blocks", () => {
const source = `component Demo {\nprops { title: string open: boolean = false }\nstate { count: number = 0 loading = false }\noutputs { confirm(payload: ConfirmPayload) cancel() }\nview { <div/> }\n}`;
const formatted = formatWrn(source, { tabSize: 2, insertSpaces: true });
assert.match(formatted, /\n {2}state \{\n {4}count: number = 0\n {4}loading = false\n {2}\}/);
assert.match(
formatted,
/\n {2}outputs \{\n {4}confirm\(payload: ConfirmPayload\)\n {4}cancel\(\)\n {2}\}/,
);
});