release: Vs Code New Extension with layout Support
This commit is contained in:
@@ -46,7 +46,7 @@
|
||||
"folding": {
|
||||
"offSide": false,
|
||||
"markers": {
|
||||
"start": "^\\s*(?:page|component|api|middleware|realtime|view|seo|props|functions)\\b.*\\{\\s*$",
|
||||
"start": "^\\s*(?:page|component|layout|api|middleware|realtime|view|seo|props|functions)\\b.*\\{\\s*$",
|
||||
"end": "^\\s*\\}\\s*$"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "wrnexus",
|
||||
"displayName": "WRNexus Language Support",
|
||||
"description": "Syntax highlighting, formatting, diagnostics, snippets and completions for WrNexus .wrn files.",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.5",
|
||||
"publisher": "wrnexus",
|
||||
"private": true,
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
|
||||
@@ -56,6 +56,20 @@
|
||||
]
|
||||
},
|
||||
|
||||
"WRN layout": {
|
||||
"prefix": ["wrn-layout", "layout"],
|
||||
"description": "Create a WRN layout",
|
||||
"body": [
|
||||
"layout ${1:LayoutName} {",
|
||||
" view {",
|
||||
" <main>",
|
||||
" {content}",
|
||||
" </main>",
|
||||
" }",
|
||||
"}"
|
||||
]
|
||||
},
|
||||
|
||||
"WRN state": {
|
||||
"prefix": ["wrn-state", "state"],
|
||||
"description": "Create reactive state",
|
||||
|
||||
+103
-26
@@ -274,10 +274,10 @@ function parse(source) {
|
||||
};
|
||||
try {
|
||||
const opener = lx.next();
|
||||
if (opener.type !== "ident" || opener.value !== "page" && opener.value !== "component") {
|
||||
throw new ParseError(`Expected 'page' or 'component' but got '${opener.value || opener.type}' at offset ${opener.pos}`);
|
||||
if (opener.type !== "ident" || !["page", "component", "layout"].includes(opener.value)) {
|
||||
throw new ParseError(`Expected 'page', 'component', or 'layout' but got '${opener.value || opener.type}' at offset ${opener.pos}`);
|
||||
}
|
||||
const kind = opener.value === "component" ? "component" : "page";
|
||||
const kind = opener.value;
|
||||
const name = expect("ident").value;
|
||||
expect("lbrace");
|
||||
let layout;
|
||||
@@ -448,12 +448,12 @@ function parse(source) {
|
||||
function parseHtmlView(src, pos) {
|
||||
let i = pos;
|
||||
const isNameStart = (c) => /[A-Za-z_]/.test(c);
|
||||
const isNamePart = (c) => /[A-Za-z0-9_:.[\]%-]/.test(c);
|
||||
const isTagNamePart = (c) => /[A-Za-z0-9_$:.-]/.test(c);
|
||||
const isAttributeNamePart = (c, next) => {
|
||||
if (c === "/") {
|
||||
return next !== ">";
|
||||
}
|
||||
return isNamePart(c);
|
||||
return /[A-Za-z0-9_$:.[\]%-]/.test(c);
|
||||
};
|
||||
const isWs2 = (c) => c === " " || c === "\t" || c === `
|
||||
` || c === "\r";
|
||||
@@ -491,9 +491,21 @@ function parseHtmlView(src, pos) {
|
||||
i++;
|
||||
return value;
|
||||
};
|
||||
const readName = () => {
|
||||
if (i >= src.length || !isNameStart(src[i]))
|
||||
return fail("Expected a tag or attribute name");
|
||||
const readTagName = () => {
|
||||
if (i >= src.length || !isNameStart(src[i])) {
|
||||
return fail("Expected a tag name");
|
||||
}
|
||||
const start = i++;
|
||||
while (i < src.length && isTagNamePart(src[i])) {
|
||||
i++;
|
||||
}
|
||||
return src.slice(start, i);
|
||||
};
|
||||
const readAttributeName = () => {
|
||||
const first = src[i];
|
||||
if (i >= src.length || !isNameStart(first) && first !== ":" && first !== "$") {
|
||||
return fail("Expected an attribute name");
|
||||
}
|
||||
const start = i++;
|
||||
while (i < src.length && isAttributeNamePart(src[i], src[i + 1])) {
|
||||
i++;
|
||||
@@ -502,7 +514,7 @@ function parseHtmlView(src, pos) {
|
||||
};
|
||||
const parseTag = () => {
|
||||
i++;
|
||||
const tag = readName();
|
||||
const tag = readTagName();
|
||||
const attrs = [];
|
||||
for (;; ) {
|
||||
skipWs();
|
||||
@@ -519,7 +531,7 @@ function parseHtmlView(src, pos) {
|
||||
}
|
||||
if (c === "@") {
|
||||
i++;
|
||||
const name2 = readName();
|
||||
const name2 = readAttributeName();
|
||||
skipWs();
|
||||
if (src[i] !== "=")
|
||||
return fail(`Expected '=' after @${name2}`);
|
||||
@@ -528,7 +540,7 @@ function parseHtmlView(src, pos) {
|
||||
attrs.push({ name: name2, value: readQuoted(), event: true });
|
||||
continue;
|
||||
}
|
||||
const name = readName();
|
||||
const name = readAttributeName();
|
||||
skipWs();
|
||||
if (src[i] === "=") {
|
||||
i++;
|
||||
@@ -546,7 +558,7 @@ function parseHtmlView(src, pos) {
|
||||
return fail(`Expected </${tag}>`);
|
||||
i += 2;
|
||||
skipWs();
|
||||
const close = readName();
|
||||
const close = readTagName();
|
||||
if (close !== tag)
|
||||
return fail(`Mismatched </${close}>, expected </${tag}>`);
|
||||
skipWs();
|
||||
@@ -674,6 +686,9 @@ function parseHtmlView(src, pos) {
|
||||
}
|
||||
|
||||
// ../../packages/compiler/src/codegen.ts
|
||||
function isComponentTag(tag) {
|
||||
return /^[A-Z][A-Za-z0-9_$]*$/.test(tag);
|
||||
}
|
||||
function attrEscape(value) {
|
||||
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
@@ -804,21 +819,30 @@ function bakeLoopAttr(raw) {
|
||||
return out + escLit(attrEscape(raw.slice(last)));
|
||||
}
|
||||
function renderLoopBody(node) {
|
||||
if (node.type === "text")
|
||||
if (node.type === "text") {
|
||||
return bakeLoopText(node.value);
|
||||
if (node.type === "each")
|
||||
}
|
||||
if (node.type === "each") {
|
||||
return compileEachExpr(node);
|
||||
if (node.type === "if")
|
||||
}
|
||||
if (node.type === "if") {
|
||||
return compileIfExpr(node);
|
||||
const attrs = node.attrs.map((a) => {
|
||||
const name = a.event ? eventAttribute(a.name) : a.name;
|
||||
if (a.boolean)
|
||||
}
|
||||
const componentTag = isComponentTag(node.tag);
|
||||
const attrs = node.attrs.filter((attr) => attr.name !== "data-component").map((attr) => {
|
||||
const name = attr.event ? eventAttribute(attr.name) : attr.name;
|
||||
if (attr.boolean) {
|
||||
return escLit(` ${name}`);
|
||||
return escLit(` ${name}="`) + bakeLoopAttr(a.value) + escLit(`"`);
|
||||
}
|
||||
return escLit(` ${name}="`) + bakeLoopAttr(attr.value) + escLit(`"`);
|
||||
}).join("");
|
||||
if (VOID_ELEMENTS.has(node.tag.toLowerCase()))
|
||||
return escLit(`<${node.tag}`) + attrs + escLit(">");
|
||||
const inner = node.children.map(renderLoopBody).join("");
|
||||
if (componentTag) {
|
||||
return escLit(`<div data-component="${attrEscape(node.tag)}"`) + attrs + escLit(">") + inner + escLit("</div>");
|
||||
}
|
||||
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) {
|
||||
return escLit(`<${node.tag}`) + attrs + escLit(">");
|
||||
}
|
||||
return escLit(`<${node.tag}`) + attrs + escLit(">") + inner + escLit(`</${node.tag}>`);
|
||||
}
|
||||
function compileEachExpr(node) {
|
||||
@@ -862,6 +886,9 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
|
||||
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
|
||||
return `\x00WRNEACH${loops.length - 1}\x00`;
|
||||
}
|
||||
if (isComponentTag(node.tag)) {
|
||||
return renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive);
|
||||
}
|
||||
const apiName = attrValue(node.attrs, "api");
|
||||
const apiBinding = apiName ? apiBindings.get(apiName) : undefined;
|
||||
if (apiName && !apiBinding) {
|
||||
@@ -888,6 +915,35 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
|
||||
}) : node.children.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive)).join("");
|
||||
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive)}>${inner}</${node.tag}>`;
|
||||
}
|
||||
function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive) {
|
||||
const attrs = node.attrs.filter((attr) => attr.name !== "data-component");
|
||||
const inner = node.children.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive)).join("");
|
||||
return `<div data-component="${attrEscape(node.tag)}"${renderAttrs(attrs, undefined, reactive)}>${inner}</div>`;
|
||||
}
|
||||
function renderNestedComponentInvocation(node, ctx) {
|
||||
let bindIndex = 0;
|
||||
const attrs = node.attrs.filter((attr) => attr.name !== "data-component").map((attr) => {
|
||||
if (attr.event) {
|
||||
return ` ${eventAttribute(attr.name)}="${compileAttrValue(attr.value, ctx)}"`;
|
||||
}
|
||||
if (attr.boolean) {
|
||||
return ` ${attr.name}`;
|
||||
}
|
||||
const rendered = ` ${attr.name}="` + `${compileAttrValue(attr.value, ctx)}"`;
|
||||
if (!attr.value.includes("{") || !exprRefsState(attr.value, ctx.stateNames)) {
|
||||
return rendered;
|
||||
}
|
||||
const marker = attrEscape(JSON.stringify([attr.name, attr.value]));
|
||||
return rendered + ` data-wrn-bind-${bindIndex++}="${escLit(marker)}"`;
|
||||
}).join("");
|
||||
const loops = loopVarsOf(node);
|
||||
const childCtx = loops.length > 0 ? {
|
||||
...ctx,
|
||||
loopVars: new Set([...ctx.loopVars ?? [], ...loops])
|
||||
} : ctx;
|
||||
const inner = node.children.map((child) => renderComponentNode(child, childCtx)).join("");
|
||||
return `<div data-component="${attrEscape(node.tag)}"${attrs}>${inner}</div>`;
|
||||
}
|
||||
function ssrMarker(bindings, binding) {
|
||||
const marker = `<!--wrnexus-ssr:${bindings.length}-->`;
|
||||
bindings.push({ marker, ...binding });
|
||||
@@ -1000,8 +1056,9 @@ async function __wrnexusRenderSsrBindings(html: string, ctx: any): Promise<strin
|
||||
}`;
|
||||
}
|
||||
function generate(ast) {
|
||||
if (ast.kind === "component")
|
||||
if (ast.kind === "component" || ast.kind === "layout") {
|
||||
return generateComponent(ast);
|
||||
}
|
||||
const out = [];
|
||||
const ssrBindings = [];
|
||||
const csrBindings = [];
|
||||
@@ -1230,6 +1287,8 @@ function compileText(raw, ctx) {
|
||||
out += escLit(`{${expr}}`);
|
||||
} else if (exprRefsState(expr, ctx.stateNames)) {
|
||||
out += escLit(`<span data-text="${attrEscape(expr)}">`) + `\${__wireHtml(${ctx.resolveExpr(expr)})}` + escLit(`</span>`);
|
||||
} else if (expr === "content") {
|
||||
out += `\${__wireRaw(${ctx.resolveExpr(expr)})}`;
|
||||
} else {
|
||||
out += `\${__wireHtml(${ctx.resolveExpr(expr)})}`;
|
||||
}
|
||||
@@ -1262,6 +1321,9 @@ function renderComponentNode(node, ctx) {
|
||||
if (node.type === "each" || node.type === "if") {
|
||||
throw new Error("Server `{#each}` / `{#if}` blocks are supported in pages, not components. Move them into a page (or use data-for / data-show on the client).");
|
||||
}
|
||||
if (isComponentTag(node.tag)) {
|
||||
return renderNestedComponentInvocation(node, ctx);
|
||||
}
|
||||
let bindIndex = 0;
|
||||
const staticClasses = [];
|
||||
const conditionalClasses = [];
|
||||
@@ -1309,10 +1371,18 @@ function renderComponentNode(node, ctx) {
|
||||
}
|
||||
function generateComponent(ast) {
|
||||
const out = [];
|
||||
const effectiveProps = ast.kind === "layout" && !ast.props.some((prop) => prop.name === "content") ? [
|
||||
{
|
||||
name: "content",
|
||||
default: '""'
|
||||
},
|
||||
...ast.props
|
||||
] : ast.props;
|
||||
const stateNames = new Set(ast.states.map((s) => s.name));
|
||||
const nameRefs = new Map;
|
||||
for (const p of ast.props)
|
||||
for (const p of effectiveProps) {
|
||||
nameRefs.set(p.name, safeRef(p.name));
|
||||
}
|
||||
for (const s of ast.states)
|
||||
nameRefs.set(s.name, safeRef(s.name));
|
||||
const resolveExpr = (expr) => {
|
||||
@@ -1331,9 +1401,12 @@ ${styles.map(styleEscape).join(`
|
||||
`)}
|
||||
</style>`) : "";
|
||||
const needsScope = ast.states.length > 0 || viewHasEvents(ast.view);
|
||||
const scopeKeys = [...ast.props.map((p) => p.name), ...ast.states.map((s) => s.name)];
|
||||
const scopeKeys = [
|
||||
...effectiveProps.map((prop) => prop.name),
|
||||
...ast.states.map((state) => state.name)
|
||||
];
|
||||
const decls = [];
|
||||
for (const prop of ast.props) {
|
||||
for (const prop of effectiveProps) {
|
||||
decls.push(` const ${nameRefs.get(prop.name)} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}));`);
|
||||
}
|
||||
for (const state of ast.states) {
|
||||
@@ -1343,7 +1416,11 @@ ${styles.map(styleEscape).join(`
|
||||
const scopeLine = needsScope && scopeKeys.length > 0 ? ` const __scope = __wrnexusScopeDecl({ ${scopeKeys.map((k) => `${JSON.stringify(k)}: ${nameRefs.get(k)}`).join(", ")} });
|
||||
` : needsScope ? ` const __scope = "";
|
||||
` : "";
|
||||
out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`);
|
||||
if (ast.kind === "layout") {
|
||||
out.push(`export const __wrnexusLayout = ${JSON.stringify(ast.name)};`);
|
||||
} else {
|
||||
out.push(`export const __wrnexusComponent = ${JSON.stringify(ast.name)};`);
|
||||
}
|
||||
out.push(`function __coerce(v: any, def: any): any {
|
||||
if (v === undefined || v === null) return def;
|
||||
if (typeof def === "number") return Number(v);
|
||||
|
||||
@@ -33,6 +33,20 @@ const BLOCK_COMPLETIONS = [
|
||||
"}",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
label: "layout",
|
||||
detail: "WRN layout",
|
||||
documentation: "Create a reusable WRN layout.",
|
||||
snippet: [
|
||||
"layout ${1:LayoutName} {",
|
||||
" view {",
|
||||
" <main>",
|
||||
" {content}",
|
||||
" </main>",
|
||||
" }",
|
||||
"}",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
label: "seo",
|
||||
detail: "SEO metadata block",
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"use strict";
|
||||
|
||||
const vscode = require("vscode");
|
||||
|
||||
const COMPONENT_DECLARATION =
|
||||
/^\s*(component|layout)\s+([A-Za-z_$][\w$]*)\s*\{/gm;
|
||||
|
||||
function getTagAtPosition(document, position) {
|
||||
const range = document.getWordRangeAtPosition(
|
||||
position,
|
||||
/[A-Za-z_$][\w$]*/,
|
||||
);
|
||||
|
||||
if (!range) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = document.getText(range);
|
||||
|
||||
if (!/^[A-Z][A-Za-z0-9_$]*$/.test(name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const line = document.lineAt(position.line).text;
|
||||
const offset = document.offsetAt(position);
|
||||
const lineStart = document.offsetAt(
|
||||
new vscode.Position(position.line, 0),
|
||||
);
|
||||
|
||||
const characterOffset = offset - lineStart;
|
||||
const before = line.slice(0, characterOffset);
|
||||
const after = line.slice(characterOffset);
|
||||
|
||||
const insideTag =
|
||||
before.lastIndexOf("<") > before.lastIndexOf(">") &&
|
||||
after.includes(">");
|
||||
|
||||
if (!insideTag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
range,
|
||||
};
|
||||
}
|
||||
|
||||
async function findDeclaration(name) {
|
||||
const files = await vscode.workspace.findFiles(
|
||||
"**/*.wrn",
|
||||
"**/{node_modules,dist,.wrnexus,.git}/**",
|
||||
);
|
||||
|
||||
const matches = [];
|
||||
|
||||
for (const uri of files) {
|
||||
let document;
|
||||
|
||||
try {
|
||||
document = await vscode.workspace.openTextDocument(uri);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const source = document.getText();
|
||||
|
||||
COMPONENT_DECLARATION.lastIndex = 0;
|
||||
|
||||
let match;
|
||||
|
||||
while ((match = COMPONENT_DECLARATION.exec(source)) !== null) {
|
||||
const declarationName = match[2];
|
||||
|
||||
if (declarationName !== name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nameOffset =
|
||||
match.index +
|
||||
match[0].indexOf(declarationName);
|
||||
|
||||
const start = document.positionAt(nameOffset);
|
||||
const end = document.positionAt(
|
||||
nameOffset + declarationName.length,
|
||||
);
|
||||
|
||||
matches.push(
|
||||
new vscode.Location(
|
||||
uri,
|
||||
new vscode.Range(start, end),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
async function provideDefinition(document, position) {
|
||||
const tag = getTagAtPosition(document, position);
|
||||
|
||||
if (!tag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matches = await findDeclaration(tag.name);
|
||||
|
||||
if (matches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return matches.length === 1 ? matches[0] : matches;
|
||||
}
|
||||
|
||||
function registerDefinitionProvider(context) {
|
||||
const disposable = vscode.languages.registerDefinitionProvider(
|
||||
{
|
||||
language: "wrn",
|
||||
scheme: "file",
|
||||
},
|
||||
{
|
||||
provideDefinition,
|
||||
},
|
||||
);
|
||||
|
||||
context.subscriptions.push(disposable);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findDeclaration,
|
||||
getTagAtPosition,
|
||||
provideDefinition,
|
||||
registerDefinitionProvider,
|
||||
};
|
||||
@@ -0,0 +1,761 @@
|
||||
"use strict";
|
||||
|
||||
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 VALID_TOP_LEVEL_KINDS = new Set([
|
||||
"page",
|
||||
"component",
|
||||
"layout",
|
||||
]);
|
||||
|
||||
const VALID_MEMBERS = {
|
||||
page: new Set([
|
||||
"layout",
|
||||
"state",
|
||||
"view",
|
||||
"seo",
|
||||
"style",
|
||||
"functions",
|
||||
"api",
|
||||
"ssr",
|
||||
"client",
|
||||
"realtime",
|
||||
]),
|
||||
|
||||
component: new Set([
|
||||
"props",
|
||||
"state",
|
||||
"view",
|
||||
"style",
|
||||
"functions",
|
||||
]),
|
||||
|
||||
layout: new Set([
|
||||
"props",
|
||||
"state",
|
||||
"view",
|
||||
"style",
|
||||
"functions",
|
||||
]),
|
||||
};
|
||||
|
||||
function createDiagnostic(
|
||||
document,
|
||||
startOffset,
|
||||
endOffset,
|
||||
message,
|
||||
severity = vscode.DiagnosticSeverity.Error,
|
||||
code,
|
||||
) {
|
||||
const diagnostic = new vscode.Diagnostic(
|
||||
new vscode.Range(
|
||||
document.positionAt(startOffset),
|
||||
document.positionAt(endOffset),
|
||||
),
|
||||
message,
|
||||
severity,
|
||||
);
|
||||
|
||||
diagnostic.source = "WRNexus";
|
||||
|
||||
if (code) {
|
||||
diagnostic.code = code;
|
||||
}
|
||||
|
||||
return diagnostic;
|
||||
}
|
||||
|
||||
function lineDiagnostic(
|
||||
document,
|
||||
lineNumber,
|
||||
message,
|
||||
severity = vscode.DiagnosticSeverity.Error,
|
||||
code,
|
||||
) {
|
||||
const line = document.lineAt(lineNumber);
|
||||
|
||||
const diagnostic = new vscode.Diagnostic(
|
||||
line.range,
|
||||
message,
|
||||
severity,
|
||||
);
|
||||
|
||||
diagnostic.source = "WRNexus";
|
||||
|
||||
if (code) {
|
||||
diagnostic.code = code;
|
||||
}
|
||||
|
||||
return diagnostic;
|
||||
}
|
||||
|
||||
function stripComments(source) {
|
||||
return source.replace(/<!--[\s\S]*?-->/g, (comment) =>
|
||||
comment.replace(/[^\n]/g, " "),
|
||||
);
|
||||
}
|
||||
|
||||
function findTopLevelDeclaration(document, source) {
|
||||
const match = TOP_LEVEL_PATTERN.exec(source);
|
||||
|
||||
if (!match) {
|
||||
const firstMeaningfulLine = source
|
||||
.split(/\r?\n/)
|
||||
.findIndex((line) => line.trim().length > 0);
|
||||
|
||||
return {
|
||||
diagnostic: lineDiagnostic(
|
||||
document,
|
||||
Math.max(0, firstMeaningfulLine),
|
||||
"A .wrn file must start with `page`, `component`, or `layout`.",
|
||||
vscode.DiagnosticSeverity.Error,
|
||||
"wrn-invalid-root",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: match[1],
|
||||
name: match[2],
|
||||
match,
|
||||
};
|
||||
}
|
||||
|
||||
function validateBalancedCharacters(document, source) {
|
||||
const diagnostics = [];
|
||||
const stack = [];
|
||||
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
|
||||
const pairs = {
|
||||
"}": "{",
|
||||
"]": "[",
|
||||
")": "(",
|
||||
};
|
||||
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
const character = source[index];
|
||||
|
||||
if (quote !== null) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === quote) {
|
||||
quote = null;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"' || character === "'") {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
source.startsWith("<!--", index)
|
||||
) {
|
||||
const commentEnd = source.indexOf("-->", index + 4);
|
||||
|
||||
if (commentEnd === -1) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
document,
|
||||
index,
|
||||
Math.min(source.length, index + 4),
|
||||
"Unclosed HTML comment.",
|
||||
vscode.DiagnosticSeverity.Error,
|
||||
"wrn-unclosed-comment",
|
||||
),
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
index = commentEnd + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
character === "{" ||
|
||||
character === "[" ||
|
||||
character === "("
|
||||
) {
|
||||
stack.push({
|
||||
character,
|
||||
offset: index,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
character === "}" ||
|
||||
character === "]" ||
|
||||
character === ")"
|
||||
) {
|
||||
const expectedOpening = pairs[character];
|
||||
const opening = stack.pop();
|
||||
|
||||
if (!opening || opening.character !== expectedOpening) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
document,
|
||||
index,
|
||||
index + 1,
|
||||
`Unexpected \`${character}\`.`,
|
||||
vscode.DiagnosticSeverity.Error,
|
||||
"wrn-unexpected-closing",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const opening of stack) {
|
||||
const expectedClosing =
|
||||
opening.character === "{"
|
||||
? "}"
|
||||
: opening.character === "["
|
||||
? "]"
|
||||
: ")";
|
||||
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
document,
|
||||
opening.offset,
|
||||
opening.offset + 1,
|
||||
`Missing closing \`${expectedClosing}\`.`,
|
||||
vscode.DiagnosticSeverity.Error,
|
||||
"wrn-missing-closing",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (quote !== null) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
document,
|
||||
Math.max(0, source.length - 1),
|
||||
source.length,
|
||||
`Unclosed ${quote === '"' ? "double" : "single"} quote.`,
|
||||
vscode.DiagnosticSeverity.Error,
|
||||
"wrn-unclosed-string",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function validateHtmlTags(document, source) {
|
||||
const diagnostics = [];
|
||||
const stack = [];
|
||||
|
||||
const voidElements = new Set([
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
||||
|
||||
const cleaned = stripComments(source);
|
||||
const tagPattern =
|
||||
/<\/?([A-Za-z][A-Za-z0-9_$:.-]*)(?:\s[\s\S]*?)?\/?>/g;
|
||||
|
||||
let match;
|
||||
|
||||
while ((match = tagPattern.exec(cleaned)) !== null) {
|
||||
const completeTag = match[0];
|
||||
const tagName = match[1];
|
||||
const lowerTag = tagName.toLowerCase();
|
||||
|
||||
const isClosing = completeTag.startsWith("</");
|
||||
const isSelfClosing = completeTag.endsWith("/>");
|
||||
const isVoid = voidElements.has(lowerTag);
|
||||
|
||||
if (isClosing) {
|
||||
const last = stack.pop();
|
||||
|
||||
if (!last) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
document,
|
||||
match.index,
|
||||
match.index + completeTag.length,
|
||||
`Unexpected closing tag </${tagName}>.`,
|
||||
vscode.DiagnosticSeverity.Error,
|
||||
"wrn-unexpected-html-close",
|
||||
),
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (last.tagName !== tagName) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
document,
|
||||
match.index,
|
||||
match.index + completeTag.length,
|
||||
`Mismatched closing tag </${tagName}>. Expected </${last.tagName}>.`,
|
||||
vscode.DiagnosticSeverity.Error,
|
||||
"wrn-mismatched-html-tag",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isSelfClosing && !isVoid) {
|
||||
stack.push({
|
||||
tagName,
|
||||
offset: match.index,
|
||||
length: completeTag.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const tag of stack) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
document,
|
||||
tag.offset,
|
||||
tag.offset + tag.length,
|
||||
`Missing closing tag </${tag.tagName}>.`,
|
||||
vscode.DiagnosticSeverity.Error,
|
||||
"wrn-missing-html-close",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function getRootBodyRange(source, rootMatch) {
|
||||
const openingBrace =
|
||||
rootMatch.index + rootMatch[0].lastIndexOf("{");
|
||||
|
||||
let depth = 0;
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
|
||||
for (
|
||||
let index = openingBrace;
|
||||
index < source.length;
|
||||
index += 1
|
||||
) {
|
||||
const character = source[index];
|
||||
|
||||
if (quote !== null) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === quote) {
|
||||
quote = null;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"' || character === "'") {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "{") {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "}") {
|
||||
depth -= 1;
|
||||
|
||||
if (depth === 0) {
|
||||
return {
|
||||
start: openingBrace + 1,
|
||||
end: index,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start: openingBrace + 1,
|
||||
end: source.length,
|
||||
};
|
||||
}
|
||||
|
||||
function findRootMembers(source, bodyStart, bodyEnd) {
|
||||
const members = [];
|
||||
let index = bodyStart;
|
||||
let depth = 0;
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
|
||||
while (index < bodyEnd) {
|
||||
const character = source[index];
|
||||
|
||||
if (quote !== null) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "\\") {
|
||||
escaped = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === quote) {
|
||||
quote = null;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"' || character === "'") {
|
||||
quote = character;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source.startsWith("<!--", index)) {
|
||||
const end = source.indexOf("-->", index + 4);
|
||||
index = end === -1 ? bodyEnd : end + 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "{") {
|
||||
depth += 1;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "}") {
|
||||
depth = Math.max(0, depth - 1);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
depth === 0 &&
|
||||
/[A-Za-z_]/.test(character)
|
||||
) {
|
||||
const start = index;
|
||||
index += 1;
|
||||
|
||||
while (
|
||||
index < bodyEnd &&
|
||||
/[A-Za-z0-9_-]/.test(source[index])
|
||||
) {
|
||||
index += 1;
|
||||
}
|
||||
|
||||
const name = source.slice(start, index);
|
||||
|
||||
members.push({
|
||||
name,
|
||||
start,
|
||||
end: index,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return members;
|
||||
}
|
||||
|
||||
function validateRootMembers(
|
||||
document,
|
||||
source,
|
||||
rootKind,
|
||||
rootMatch,
|
||||
) {
|
||||
const diagnostics = [];
|
||||
const allowed = VALID_MEMBERS[rootKind];
|
||||
|
||||
if (!allowed) {
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
const bodyRange = getRootBodyRange(source, rootMatch);
|
||||
const members = findRootMembers(
|
||||
source,
|
||||
bodyRange.start,
|
||||
bodyRange.end,
|
||||
);
|
||||
|
||||
for (const member of members) {
|
||||
if (allowed.has(member.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
document,
|
||||
member.start,
|
||||
member.end,
|
||||
`Unknown ${rootKind} member \`${member.name}\`.`,
|
||||
vscode.DiagnosticSeverity.Error,
|
||||
"wrn-unknown-member",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function validateRequiredView(
|
||||
document,
|
||||
source,
|
||||
rootKind,
|
||||
rootMatch,
|
||||
) {
|
||||
const bodyRange = getRootBodyRange(source, rootMatch);
|
||||
const body = source.slice(bodyRange.start, bodyRange.end);
|
||||
|
||||
if (/\bview\s*\{/.test(body)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
createDiagnostic(
|
||||
document,
|
||||
rootMatch.index,
|
||||
rootMatch.index + rootMatch[0].length,
|
||||
`The ${rootKind} \`${rootMatch[2]}\` does not contain a \`view { ... }\` block.`,
|
||||
vscode.DiagnosticSeverity.Warning,
|
||||
"wrn-missing-view",
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function validateLayoutUsage(
|
||||
document,
|
||||
source,
|
||||
rootKind,
|
||||
) {
|
||||
const diagnostics = [];
|
||||
|
||||
if (
|
||||
rootKind !== "page" &&
|
||||
/^\s*layout\s*=/m.test(source)
|
||||
) {
|
||||
const match = /^\s*layout\s*=/m.exec(source);
|
||||
|
||||
if (match) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
document,
|
||||
match.index,
|
||||
match.index + match[0].length,
|
||||
"`layout = \"...\"` is only valid inside a page.",
|
||||
vscode.DiagnosticSeverity.Error,
|
||||
"wrn-invalid-layout-member",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function validateDocument(document) {
|
||||
if (document.languageId !== WRN_LANGUAGE_ID) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const source = document.getText();
|
||||
|
||||
if (!source.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const diagnostics = [];
|
||||
const declaration = findTopLevelDeclaration(
|
||||
document,
|
||||
source,
|
||||
);
|
||||
|
||||
if (declaration.diagnostic) {
|
||||
diagnostics.push(declaration.diagnostic);
|
||||
diagnostics.push(
|
||||
...validateBalancedCharacters(document, source),
|
||||
);
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
if (!VALID_TOP_LEVEL_KINDS.has(declaration.kind)) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
document,
|
||||
declaration.match.index,
|
||||
declaration.match.index +
|
||||
declaration.match[0].length,
|
||||
`Unsupported WRN declaration \`${declaration.kind}\`.`,
|
||||
vscode.DiagnosticSeverity.Error,
|
||||
"wrn-invalid-kind",
|
||||
),
|
||||
);
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
diagnostics.push(
|
||||
...validateBalancedCharacters(document, source),
|
||||
);
|
||||
|
||||
diagnostics.push(
|
||||
...validateHtmlTags(document, source),
|
||||
);
|
||||
|
||||
diagnostics.push(
|
||||
...validateRootMembers(
|
||||
document,
|
||||
source,
|
||||
declaration.kind,
|
||||
declaration.match,
|
||||
),
|
||||
);
|
||||
|
||||
diagnostics.push(
|
||||
...validateRequiredView(
|
||||
document,
|
||||
source,
|
||||
declaration.kind,
|
||||
declaration.match,
|
||||
),
|
||||
);
|
||||
|
||||
diagnostics.push(
|
||||
...validateLayoutUsage(
|
||||
document,
|
||||
source,
|
||||
declaration.kind,
|
||||
),
|
||||
);
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function registerDiagnostics(context) {
|
||||
const collection =
|
||||
vscode.languages.createDiagnosticCollection(
|
||||
COLLECTION_NAME,
|
||||
);
|
||||
|
||||
const timers = new Map();
|
||||
|
||||
const update = (document) => {
|
||||
if (document.languageId !== WRN_LANGUAGE_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousTimer = timers.get(
|
||||
document.uri.toString(),
|
||||
);
|
||||
|
||||
if (previousTimer) {
|
||||
clearTimeout(previousTimer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timers.delete(document.uri.toString());
|
||||
|
||||
collection.set(
|
||||
document.uri,
|
||||
validateDocument(document),
|
||||
);
|
||||
}, 150);
|
||||
|
||||
timers.set(document.uri.toString(), timer);
|
||||
};
|
||||
|
||||
for (const document of vscode.workspace.textDocuments) {
|
||||
update(document);
|
||||
}
|
||||
|
||||
context.subscriptions.push(
|
||||
collection,
|
||||
|
||||
vscode.workspace.onDidOpenTextDocument(update),
|
||||
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
update(event.document);
|
||||
}),
|
||||
|
||||
vscode.workspace.onDidSaveTextDocument(update),
|
||||
|
||||
vscode.workspace.onDidCloseTextDocument(
|
||||
(document) => {
|
||||
const key = document.uri.toString();
|
||||
const timer = timers.get(key);
|
||||
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timers.delete(key);
|
||||
}
|
||||
|
||||
collection.delete(document.uri);
|
||||
},
|
||||
),
|
||||
|
||||
{
|
||||
dispose() {
|
||||
for (const timer of timers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
timers.clear();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerDiagnostics,
|
||||
validateBalancedCharacters,
|
||||
validateDocument,
|
||||
validateHtmlTags,
|
||||
validateRootMembers,
|
||||
};
|
||||
@@ -4,6 +4,8 @@
|
||||
const vscode = require("vscode");
|
||||
const { formatWrn } = require("./formatter");
|
||||
const { registerCompletionProvider } = require("./completion");
|
||||
const { registerDefinitionProvider } = require("./definition");
|
||||
const { registerDiagnostics } = require("./diagnostics");
|
||||
|
||||
// The .wrn compiler, bundled to CJS by `bun run build:compiler`. Loaded
|
||||
// defensively so the rest of the extension (highlighting, snippets, completion)
|
||||
@@ -68,7 +70,9 @@ const EVENTS = [
|
||||
* @param {vscode.ExtensionContext} context
|
||||
*/
|
||||
function activate(context) {
|
||||
registerDiagnostics(context);
|
||||
registerCompletionProvider(context);
|
||||
registerDefinitionProvider(context);
|
||||
|
||||
const diagnostics = vscode.languages.createDiagnosticCollection("wrn");
|
||||
context.subscriptions.push(diagnostics);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
},
|
||||
|
||||
"declaration": {
|
||||
"match": "\\b(page|component)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)?",
|
||||
"match": "\\b(page|component|layout)\\b\\s+([A-Za-z_][A-Za-z0-9_]*)?",
|
||||
"captures": {
|
||||
"1": { "name": "storage.type.wrn keyword.control.wrn" },
|
||||
"2": { "name": "entity.name.type.wrn" }
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user