544 lines
14 KiB
JavaScript
544 lines
14 KiB
JavaScript
"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 {",
|
|
" <div></div>",
|
|
" }",
|
|
"}",
|
|
].join("\n"),
|
|
},
|
|
{
|
|
label: "layout",
|
|
detail: "WRN layout",
|
|
documentation: "Create a reusable WRN layout.",
|
|
snippet: [
|
|
"layout ${1:LayoutName} {",
|
|
" view {",
|
|
" <main>",
|
|
" {content}",
|
|
" </main>",
|
|
" }",
|
|
"}",
|
|
].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,
|
|
};
|