"use strict";
const vscode = require("vscode");
const BLOCK_COMPLETIONS = [
{
label: "import",
detail: "WRN server module import",
documentation:
"Import a package or module before the page, component, or layout declaration for server rendering.",
snippet: 'import { ${1:appUrl} } from "${2:@wrnexus/helpers}";\n\n$0',
},
{
label: "page",
detail: "WRN page",
documentation: "Create a WRN page declaration.",
snippet: [
"page ${1:PageName} {",
' layout = "${2:default}"',
"",
" view {",
" $0",
" }",
"}",
].join("\n"),
},
{
label: "component",
detail: "WRN component",
documentation:
"Create a reusable WRN component with state, functions, lifecycle hooks, watchers, and a view.",
snippet: [
"component ${1:ComponentName} {",
" state ${2:ready} = false",
"",
" functions {",
" function ${3:initialize}() {",
" $0",
" }",
" }",
"",
" lifecycle {",
" mount {",
" ${3:initialize}()",
" }",
" }",
"",
" view {",
"
",
" }",
"}",
].join("\n"),
},
{
label: "layout",
detail: "WRN layout",
documentation: "Create a reusable WRN layout.",
snippet: [
"layout ${1:LayoutName} {",
" view {",
" ",
" {content}",
" ",
" }",
"}",
].join("\n"),
},
{
label: "types",
detail: "TypeScript type declarations block",
documentation: "Declare interfaces and type aliases used by props, state, and functions.",
snippet: [
"types {",
" interface ${1:Item} {",
" ${2:id}: ${3:string}",
" }",
"}",
].join("\n"),
},
{
label: "props",
detail: "Component or layout props block",
documentation: "Declare values accepted by a component or layout.",
snippet: ["props {", ' ${1:title}: ${2:string} = "${3:Title}"', "}"].join("\n"),
},
{
label: "seo",
detail: "SEO metadata block",
documentation: "Declare title, description, and other page metadata.",
snippet: [
"seo {",
' title = "${1:Page title}"',
' description = "${2:Page description}"',
"}",
].join("\n"),
},
{
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:string} = ${3:"value"}',
},
{
label: "runtime",
detail: "WRN execution target",
documentation: "Choose whether this declaration executes on the server, client, or both.",
snippet: 'runtime = "${1|universal,server,client|}"',
},
{
label: "hydrate",
detail: "WRN hydration strategy",
documentation: "Choose when client interactivity is initialized for this declaration.",
snippet: 'hydrate = "${1|load,idle,visible,interaction,none|}"',
},
{
label: "computed",
detail: "Derived reactive values block",
documentation:
"Declare values that are recomputed only when their reactive dependencies change.",
snippet: ["computed {", " ${1:displayName} = ${2:firstName + ' ' + lastName}", "}"].join(
"\n",
),
},
{
label: "effect",
detail: "Reactive side-effect block",
documentation: "Run browser-side code whenever its referenced reactive values change.",
snippet: ["effect {", " ${1:console.log(value)}", "}"].join("\n"),
},
{
label: "security",
detail: "Route security metadata block",
documentation: "Declare authentication, authorization, CSRF, and rate-limit policy metadata.",
snippet: [
"security {",
' auth = "${1|required,optional,public|}"',
' csrf = "${2:true}"',
"}",
].join("\n"),
},
{
label: "load",
detail: "Typed data-loading block",
documentation: "Load data on the server or client with an explicit execution boundary.",
snippet: ["load ${1|server,client|} {", " ${2:return {}}", "}"].join("\n"),
},
{
label: "action",
detail: "Named server action",
documentation: "Declare a callable mutation with an explicit name and arguments.",
snippet: ["action ${1:save}(${2:input}) {", " $0", "}"].join("\n"),
},
{
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:value}: ${3:unknown}): ${4:void} {",
" $0",
" }",
"}",
].join("\n"),
},
{
label: "function",
detail: "WRN component function",
documentation: "Declare a browser-side function inside a functions block.",
snippet: ["function ${1:name}(${2:value}: ${3:unknown}): ${4:void} {", " $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"),
},
{
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 = [
["@click", "Click event handler", '@click="${1:handler()}"'],
["@change", "Change event handler", '@change="${1:handler()}"'],
["@input", "Input event handler", '@input="${1:handler()}"'],
["@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}"'],
];
const CONTEXT_COMPLETIONS = [
["ctx.params", "Dynamic route parameters"],
["ctx.query", "URL query parameters"],
["ctx.request", "Current Request object"],
["ctx.user", "Authenticated user"],
["ctx.session", "Current session"],
["ctx.locals", "Request-local data"],
["ctx.tenant", "Resolved tenant context"],
["ctx.tracer", "Request tracing interface"],
];
const WATCH_VALUE_COMPLETIONS = [
["value", "Current watcher state value"],
["previous", "Previous watcher state value"],
];
function completionKindFor(label) {
if (label.startsWith("@")) {
return vscode.CompletionItemKind.Event;
}
if (label.startsWith("class:") || label.startsWith("data-")) {
return vscode.CompletionItemKind.Property;
}
if (label === "function" || label === "functions") {
return vscode.CompletionItemKind.Function;
}
return vscode.CompletionItemKind.Keyword;
}
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(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;
}
function getCurrentOpeningTag(document, position) {
const textBeforeCursor = document.getText(
new vscode.Range(new vscode.Position(position.line, 0), position),
);
const lastOpen = textBeforeCursor.lastIndexOf("<");
const lastClose = textBeforeCursor.lastIndexOf(">");
if (lastOpen > lastClose) {
return textBeforeCursor.slice(lastOpen);
}
return null;
}
function extractRouteParams(document) {
const fileName = document.fileName.replace(/\\/g, "/");
const matches = [...fileName.matchAll(/\[([A-Za-z_$][\w$]*)\]/g)];
return matches.map((match) => match[1]);
}
function extractStates(document) {
const source = document.getText();
const matches = [...source.matchAll(/^\s*state\s+([A-Za-z_$][\w$]*)(?:\s*:\s*[^=\r\n]+)?\s*=/gm)];
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*:\s*[^=\r\n]+)?(?:\s*=|\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(/=.*$/, "")
.replace(/\??\s*:\s*[\s\S]+$/, "")
.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;
}
/**
* Whether an offset sits inside a `view { }` block.
*
* The language server owns completion there and returns a merged list, so this
* provider stands down to avoid VS Code concatenating two independent lists.
* Quotes are only tracked inside a tag: `it's
` would otherwise open a
* string that never closes.
*/
function isInsideViewBlock(text, offset) {
const pattern = /\bview\s*\{/g;
let match;
while ((match = pattern.exec(text))) {
const start = match.index + match[0].length;
let depth = 1;
let inTag = false;
let quote = null;
let index = start;
for (; index < text.length && depth > 0; index += 1) {
const char = text[index];
if (quote) {
if (char === quote) quote = null;
continue;
}
if (inTag && (char === '"' || char === "'")) quote = char;
else if (char === "<") inTag = true;
else if (char === ">") inTag = false;
else if (char === "{") depth += 1;
else if (char === "}") depth -= 1;
}
if (offset >= start && offset <= index) return true;
pattern.lastIndex = index;
}
return false;
}
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) {
if (isInsideViewBlock(document.getText(), document.offsetAt(position))) return [];
const items = [];
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
const openingTag = getCurrentOpeningTag(document, position);
if (openingTag !== null) {
for (const [label, detail, snippet] of ATTRIBUTE_COMPLETIONS) {
items.push(createCompletion(label, detail, snippet));
}
} else if (isAfterWatchKeyword(document, position)) {
addStateCompletions(items, document, position);
} else {
addBlockCompletions(items, document, position);
}
if (linePrefix.includes("ctx.") || linePrefix.includes("ctx.params.")) {
for (const [label, detail] of CONTEXT_COMPLETIONS) {
items.push(createCompletion(label, detail, label));
}
}
for (const param of extractRouteParams(document)) {
items.push(createVariableCompletion(param, `Route parameter from [${param}].wrn`));
items.push(
createVariableCompletion(`ctx.params.${param}`, `Route parameter from [${param}].wrn`),
);
}
if (!isAfterWatchKeyword(document, position)) {
addStateCompletions(items, document, position);
}
for (const prop of extractProps(document)) {
items.push(createVariableCompletion(prop, "WRN component or layout prop"));
}
addFunctionCompletions(items, document);
if (isInsideWatch(document, position)) {
for (const [label, detail] of WATCH_VALUE_COMPLETIONS) {
items.push(createVariableCompletion(label, detail));
}
}
return items;
}
function registerCompletionProvider(context) {
const provider = vscode.languages.registerCompletionItemProvider(
{
language: "wrn",
},
{
provideCompletionItems,
},
"@",
":",
".",
"<",
" ",
"(",
);
context.subscriptions.push(provider);
}
module.exports = {
extractFunctions,
extractProps,
extractRouteParams,
extractStates,
isInsideViewBlock,
provideCompletionItems,
registerCompletionProvider,
};