release: WRNexusJS 0.6.0
This commit is contained in:
+1801
-83
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user