"use strict"; const vscode = require("vscode"); const BLOCK_COMPLETIONS = [ { 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: "props", detail: "Component or layout props block", documentation: "Declare values accepted by a component or layout.", snippet: ["props {", ' ${1:title} = "${2:Title}"', "}"].join("\n"), }, { label: "seo", detail: "SEO metadata block", documentation: "Declare title, description, and other page metadata.", snippet: [ "seo {", ' title = "${1:Page title}"', ' 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:"value"}', }, { label: "functions", detail: "Browser component functions block", documentation: "Declare functions that can be called from events, lifecycle hooks, and watchers.", snippet: ["functions {", " function ${1:handler}(${2}) {", " $0", " }", "}"].join( "\n", ), }, { label: "function", detail: "WRN component function", documentation: "Declare a browser-side function inside a functions block.", snippet: ["function ${1:name}(${2}) {", " $0", "}"].join("\n"), }, { label: "lifecycle", detail: "Component lifecycle block", documentation: "Declare mount, update, and unmount hooks for a component or layout.", snippet: [ "lifecycle {", " mount {", " $1", " }", "", " update {", " $2", " }", "", " unmount {", " $0", " }", "}", ].join("\n"), }, { label: "mount", detail: "Lifecycle mount hook", documentation: "Runs once after the component is connected and hydrated.", snippet: ["mount {", " $0", "}"].join("\n"), }, { label: "update", detail: "Lifecycle update hook", documentation: "Runs once after a batch of reactive state changes.", snippet: ["update {", " $0", "}"].join("\n"), }, { label: "unmount", detail: "Lifecycle unmount hook", documentation: "Runs before the component is removed. Use it to remove global listeners and release resources.", snippet: ["unmount {", " $0", "}"].join("\n"), }, { label: "watch", detail: "Reactive state watcher", documentation: "Run code when one declared state value changes. The watcher receives `value` and `previous`.", snippet: ["watch ${1:stateName} {", " console.log(value, previous)", " $0", "}"].join( "\n", ), }, { label: "style", detail: "Scoped style block", documentation: "Declare styles for the current WRN declaration.", snippet: ["style {", " $0", "}"].join("\n"), }, ]; const ATTRIBUTE_COMPLETIONS = [ ["@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"], ]; 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*=/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*=/gm)) { props.add(match[1]); } } return [...props]; } function extractFunctions(document) { const source = document.getText(); const functions = []; const seen = new Set(); const pattern = /(?:^|\s)(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(([^)]*)\)/g; let match; while ((match = pattern.exec(source)) !== null) { const name = match[1]; if (seen.has(name)) { continue; } seen.add(name); const parameters = match[2] .split(",") .map((parameter) => parameter.trim()) .filter(Boolean) .map((parameter) => parameter.replace(/=.*$/, "").trim()); functions.push({ name, parameters, }); } return functions; } function sourceBeforePosition(document, position) { return document.getText(new vscode.Range(new vscode.Position(0, 0), position)); } function isInsideNamedBlock(document, position, blockName) { const source = sourceBeforePosition(document, position); const tokenPattern = new RegExp(`\\b${blockName}\\s*\\{|\\{|\\}`, "g"); const stack = []; let match; while ((match = tokenPattern.exec(source)) !== null) { const token = match[0]; if (token.startsWith(blockName)) { stack.push(blockName); continue; } if (token === "{") { stack.push(null); continue; } if (token === "}") { stack.pop(); } } return stack.includes(blockName); } function isInsideLifecycle(document, position) { return isInsideNamedBlock(document, position, "lifecycle"); } function isInsideFunctions(document, position) { return isInsideNamedBlock(document, position, "functions"); } function isInsideWatch(document, position) { const source = sourceBeforePosition(document, position); const watchMatches = [...source.matchAll(/\bwatch\s+[A-Za-z_$][\w$]*\s*\{/g)]; if (watchMatches.length === 0) { return false; } const latest = watchMatches[watchMatches.length - 1]; const tail = source.slice(latest.index); let depth = 0; for (const character of tail) { if (character === "{") { depth += 1; } else if (character === "}") { depth -= 1; } } return depth > 0; } function isAfterWatchKeyword(document, position) { const linePrefix = document.lineAt(position.line).text.slice(0, position.character); return /^\s*watch\s+[A-Za-z0-9_$]*$/.test(linePrefix); } function addBlockCompletions(items, document, position) { const insideLifecycle = isInsideLifecycle(document, position); const insideFunctions = isInsideFunctions(document, position); for (const completion of BLOCK_COMPLETIONS) { if (insideLifecycle && !["mount", "update", "unmount"].includes(completion.label)) { continue; } if (insideFunctions && completion.label !== "function") { continue; } if (!insideLifecycle && ["mount", "update", "unmount"].includes(completion.label)) { continue; } if (!insideFunctions && completion.label === "function") { continue; } items.push( createCompletion( completion.label, completion.detail, completion.snippet, completion.documentation, ), ); } } function addStateCompletions(items, document, position) { const states = extractStates(document); const afterWatch = isAfterWatchKeyword(document, position); for (const state of states) { const item = createVariableCompletion( state, afterWatch ? "Declared WRN state available for watching" : "WRN reactive state", ); if (afterWatch) { item.sortText = `0-${state}`; } items.push(item); } } function addFunctionCompletions(items, document) { for (const fn of extractFunctions(document)) { items.push(createFunctionCompletion(fn.name, fn.parameters)); } } function provideCompletionItems(document, position) { const items = []; const linePrefix = document.lineAt(position.line).text.slice(0, position.character); const openingTag = getCurrentOpeningTag(document, position); 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", scheme: "file", }, { provideCompletionItems, }, "@", ":", ".", "<", " ", "(", ); context.subscriptions.push(provider); } module.exports = { extractFunctions, extractProps, extractRouteParams, extractStates, provideCompletionItems, registerCompletionProvider, };