"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_LIFECYCLE_HOOKS = new Set(["mount", "update", "unmount"]); const VALID_MEMBERS = { page: new Set([ "layout", "state", "view", "seo", "style", "functions", "api", "ssr", "client", "realtime", ]), component: new Set(["props", "state", "view", "style", "functions", "lifecycle", "watch"]), layout: new Set(["props", "state", "view", "style", "functions", "lifecycle", "watch"]), }; 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("{"); const closingBrace = findMatchingBrace(source, openingBrace); return { start: openingBrace + 1, end: closingBrace === -1 ? source.length : closingBrace, }; } function findMatchingBrace(source, openingBrace) { 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 (source.startsWith("", index + 4); if (commentEnd === -1) { return -1; } index = commentEnd + 2; continue; } if (character === "{") { depth += 1; continue; } if (character === "}") { depth -= 1; if (depth === 0) { return index; } } } return -1; } function skipWhitespace(source, index, end) { while (index < end && /\s/.test(source[index])) { index += 1; } return index; } function readIdentifier(source, index, end) { if (index >= end || !/[A-Za-z_$]/.test(source[index])) { return null; } const start = index; index += 1; while (index < end && /[A-Za-z0-9_$-]/.test(source[index])) { index += 1; } return { name: source.slice(start, index), start, end: index, }; } function findRootMembers(source, bodyStart, bodyEnd) { const members = []; let index = bodyStart; let depth = 0; let quote = null; let escaped = false; const skipLine = () => { while (index < bodyEnd && source[index] !== "\n" && source[index] !== "\r") { index += 1; } }; 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 identifier = readIdentifier(source, index, bodyEnd); if (!identifier) { index += 1; continue; } index = identifier.end; members.push({ name: identifier.name, start: identifier.start, end: identifier.end, }); if (identifier.name === "state" || identifier.name === "layout") { skipLine(); continue; } if (identifier.name === "watch") { index = skipWhitespace(source, index, bodyEnd); const watchedState = readIdentifier(source, index, bodyEnd); if (watchedState) { index = watchedState.end; } } continue; } index += 1; } return members; } function findNamedBlocks(source, bodyStart, bodyEnd, blockName) { const blocks = []; let index = bodyStart; while (index < bodyEnd) { index = skipWhitespace(source, index, bodyEnd); const identifier = readIdentifier(source, index, bodyEnd); if (!identifier) { index += 1; continue; } index = identifier.end; if (identifier.name !== blockName) { const possibleBrace = skipWhitespace(source, index, bodyEnd); if (source[possibleBrace] === "{") { const end = findMatchingBrace(source, possibleBrace); index = end === -1 ? bodyEnd : end + 1; } continue; } const openingBrace = skipWhitespace(source, index, bodyEnd); if (source[openingBrace] !== "{") { blocks.push({ name: blockName, nameStart: identifier.start, nameEnd: identifier.end, openingBrace: -1, closingBrace: -1, }); continue; } const closingBrace = findMatchingBrace(source, openingBrace); blocks.push({ name: blockName, nameStart: identifier.start, nameEnd: identifier.end, openingBrace, closingBrace, }); index = closingBrace === -1 ? bodyEnd : closingBrace + 1; } return blocks; } function findStateDeclarations(source, bodyStart, bodyEnd) { const states = new Map(); 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; } else if (character === "\\") { escaped = true; } else 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 && source.startsWith("state", index) && !/[A-Za-z0-9_$]/.test(source[index - 1] || "") && !/[A-Za-z0-9_$]/.test(source[index + 5] || "") ) { let cursor = skipWhitespace(source, index + 5, bodyEnd); const state = readIdentifier(source, cursor, bodyEnd); if (state) { states.set(state.name, state); index = state.end; continue; } } index += 1; } return states; } function findWatchDeclarations(source, bodyStart, bodyEnd) { const watches = []; 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; } else if (character === "\\") { escaped = true; } else 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 && source.startsWith("watch", index) && !/[A-Za-z0-9_$]/.test(source[index - 1] || "") && !/[A-Za-z0-9_$]/.test(source[index + 5] || "") ) { const watchStart = index; let cursor = skipWhitespace(source, index + 5, bodyEnd); const watchedState = readIdentifier(source, cursor, bodyEnd); if (!watchedState) { watches.push({ watchStart, watchEnd: index + 5, state: null, openingBrace: -1, closingBrace: -1, }); index += 5; continue; } cursor = skipWhitespace(source, watchedState.end, bodyEnd); const openingBrace = source[cursor] === "{" ? cursor : -1; const closingBrace = openingBrace === -1 ? -1 : findMatchingBrace(source, openingBrace); watches.push({ watchStart, watchEnd: index + 5, state: watchedState, openingBrace, closingBrace, }); index = closingBrace === -1 ? watchedState.end : closingBrace + 1; continue; } index += 1; } return watches; } 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 validateLifecycleBlocks(document, source, rootKind, rootMatch) { if (rootKind !== "component" && rootKind !== "layout") { return []; } const diagnostics = []; const bodyRange = getRootBodyRange(source, rootMatch); const blocks = findNamedBlocks(source, bodyRange.start, bodyRange.end, "lifecycle"); if (blocks.length > 1) { for (const block of blocks.slice(1)) { diagnostics.push( createDiagnostic( document, block.nameStart, block.nameEnd, "Only one `lifecycle { ... }` block is allowed.", vscode.DiagnosticSeverity.Error, "wrn-duplicate-lifecycle", ), ); } } for (const block of blocks) { if (block.openingBrace === -1) { diagnostics.push( createDiagnostic( document, block.nameStart, block.nameEnd, "`lifecycle` must be followed by a block.", vscode.DiagnosticSeverity.Error, "wrn-invalid-lifecycle", ), ); continue; } const lifecycleEnd = block.closingBrace === -1 ? bodyRange.end : block.closingBrace; let index = block.openingBrace + 1; const seenHooks = new Set(); while (index < lifecycleEnd) { index = skipWhitespace(source, index, lifecycleEnd); if (index >= lifecycleEnd) { break; } const hook = readIdentifier(source, index, lifecycleEnd); if (!hook) { index += 1; continue; } index = hook.end; if (!VALID_LIFECYCLE_HOOKS.has(hook.name)) { diagnostics.push( createDiagnostic( document, hook.start, hook.end, `Unknown lifecycle hook \`${hook.name}\`. Use \`mount\`, \`update\`, or \`unmount\`.`, vscode.DiagnosticSeverity.Error, "wrn-invalid-lifecycle-hook", ), ); } else if (seenHooks.has(hook.name)) { diagnostics.push( createDiagnostic( document, hook.start, hook.end, `Duplicate lifecycle hook \`${hook.name}\`.`, vscode.DiagnosticSeverity.Error, "wrn-duplicate-lifecycle-hook", ), ); } else { seenHooks.add(hook.name); } const openingBrace = skipWhitespace(source, index, lifecycleEnd); if (source[openingBrace] !== "{") { diagnostics.push( createDiagnostic( document, hook.start, hook.end, `Lifecycle hook \`${hook.name}\` must be followed by a block.`, vscode.DiagnosticSeverity.Error, "wrn-invalid-lifecycle-hook", ), ); index = hook.end; continue; } const closingBrace = findMatchingBrace(source, openingBrace); index = closingBrace === -1 ? lifecycleEnd : closingBrace + 1; } } return diagnostics; } function validateWatchBlocks(document, source, rootKind, rootMatch) { if (rootKind !== "component" && rootKind !== "layout") { return []; } const diagnostics = []; const bodyRange = getRootBodyRange(source, rootMatch); const states = findStateDeclarations(source, bodyRange.start, bodyRange.end); const watches = findWatchDeclarations(source, bodyRange.start, bodyRange.end); for (const watch of watches) { if (!watch.state) { diagnostics.push( createDiagnostic( document, watch.watchStart, watch.watchEnd, "`watch` must be followed by a declared state name.", vscode.DiagnosticSeverity.Error, "wrn-invalid-watch", ), ); continue; } if (!states.has(watch.state.name)) { diagnostics.push( createDiagnostic( document, watch.state.start, watch.state.end, `Cannot watch undeclared state \`${watch.state.name}\`.`, vscode.DiagnosticSeverity.Error, "wrn-unknown-watch-state", ), ); } if (watch.openingBrace === -1) { diagnostics.push( createDiagnostic( document, watch.state.start, watch.state.end, `Watcher for \`${watch.state.name}\` must be followed by a block.`, vscode.DiagnosticSeverity.Error, "wrn-invalid-watch", ), ); } } 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( ...validateLifecycleBlocks(document, source, declaration.kind, declaration.match), ); diagnostics.push(...validateWatchBlocks(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 key = document.uri.toString(); const previousTimer = timers.get(key); if (previousTimer) { clearTimeout(previousTimer); } const timer = setTimeout(() => { timers.delete(key); collection.set(document.uri, validateDocument(document)); }, 150); timers.set(key, 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, validateLifecycleBlocks, validateRootMembers, validateWatchBlocks, };