"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, };