diff --git a/editors/vscode/language-configuration.json b/editors/vscode/language-configuration.json index 68fe0402..9302a17d 100644 --- a/editors/vscode/language-configuration.json +++ b/editors/vscode/language-configuration.json @@ -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*$" } } diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 562e6544..bfda88c2 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -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", diff --git a/editors/vscode/snippets/wrn.json b/editors/vscode/snippets/wrn.json index ae144f67..12952f52 100644 --- a/editors/vscode/snippets/wrn.json +++ b/editors/vscode/snippets/wrn.json @@ -56,6 +56,20 @@ ] }, + "WRN layout": { + "prefix": ["wrn-layout", "layout"], + "description": "Create a WRN layout", + "body": [ + "layout ${1:LayoutName} {", + " view {", + "
", + " {content}", + "
", + " }", + "}" + ] + }, + "WRN state": { "prefix": ["wrn-state", "state"], "description": "Create reactive state", diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index 8a053f29..efb59e19 100644 --- a/editors/vscode/src/compiler.cjs +++ b/editors/vscode/src/compiler.cjs @@ -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 `); i += 2; skipWs(); - const close = readName(); + const close = readTagName(); if (close !== tag) return fail(`Mismatched , expected `); 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, ">"); } @@ -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(`
") + inner + escLit("
"); + } + if (VOID_ELEMENTS.has(node.tag.toLowerCase())) { + return escLit(`<${node.tag}`) + attrs + escLit(">"); + } return escLit(`<${node.tag}`) + attrs + escLit(">") + inner + escLit(``); } 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}`; } +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 `
${inner}
`; +} +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 `
${inner}
`; +} function ssrMarker(bindings, binding) { const marker = ``; bindings.push({ marker, ...binding }); @@ -1000,8 +1056,9 @@ async function __wrnexusRenderSsrBindings(html: string, ctx: any): Promise`) + `\${__wireHtml(${ctx.resolveExpr(expr)})}` + escLit(``); + } 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(` `)} `) : ""; 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); diff --git a/editors/vscode/src/completion.js b/editors/vscode/src/completion.js index 9d20d4b2..8020fb00 100644 --- a/editors/vscode/src/completion.js +++ b/editors/vscode/src/completion.js @@ -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 {", + "
", + " {content}", + "
", + " }", + "}", + ].join("\n"), + }, { label: "seo", detail: "SEO metadata block", diff --git a/editors/vscode/src/definition.js b/editors/vscode/src/definition.js new file mode 100644 index 00000000..d4be17e7 --- /dev/null +++ b/editors/vscode/src/definition.js @@ -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, +}; \ No newline at end of file diff --git a/editors/vscode/src/diagnostics.js b/editors/vscode/src/diagnostics.js new file mode 100644 index 00000000..01b1d245 --- /dev/null +++ b/editors/vscode/src/diagnostics.js @@ -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(//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 + 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 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 .`, + 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 . Expected .`, + 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 .`, + 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 + 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, +}; \ No newline at end of file diff --git a/editors/vscode/src/extension.js b/editors/vscode/src/extension.js index 331d40e6..435f4d1c 100644 --- a/editors/vscode/src/extension.js +++ b/editors/vscode/src/extension.js @@ -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); diff --git a/editors/vscode/syntaxes/wrn.tmLanguage.json b/editors/vscode/syntaxes/wrn.tmLanguage.json index e7693513..134ed2e4 100644 --- a/editors/vscode/syntaxes/wrn.tmLanguage.json +++ b/editors/vscode/syntaxes/wrn.tmLanguage.json @@ -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" } diff --git a/editors/vscode/wrnexus-0.2.5.vsix b/editors/vscode/wrnexus-0.2.5.vsix new file mode 100644 index 00000000..17333bc2 Binary files /dev/null and b/editors/vscode/wrnexus-0.2.5.vsix differ diff --git a/packages/cli/src/generate.ts b/packages/cli/src/generate.ts index b4a92bdf..8644a254 100644 --- a/packages/cli/src/generate.ts +++ b/packages/cli/src/generate.ts @@ -111,7 +111,7 @@ export function runGenerate( ): void { const type = typeArg ? ALIASES[typeArg] : undefined; if (!type || !name) { - console.error("Usage: wrnexus generate "); + console.error("Usage: wrnexus generate "); process.exit(1); }