Files
WRNexusJS/editors/vscode/src/diagnostics.js
T
Clintchiz 72e4d3eceb
Quality / quality (ubuntu-latest) (push) Failing after 12m19s
Quality / quality (windows-latest) (push) Canceled after 0s
release: WRNexusJS 0.8.0
2026-08-04 12:19:09 +05:30

1453 lines
33 KiB
JavaScript

"use strict";
const vscode = require("vscode");
const COLLECTION_NAME = "wrnexus";
const WRN_LANGUAGE_ID = "wrn";
const TOP_LEVEL_PATTERN =
/^\s*((?:global|page)\s+store|page|component|layout)\s+([A-Za-z_$][\w$]*)\s*\{/;
const VALID_TOP_LEVEL_KINDS = new Set([
"page",
"component",
"layout",
"global store",
"page store",
]);
const VALID_LIFECYCLE_HOOKS = new Set(["mount", "update", "unmount"]);
const ROOT_MEMBER_NAMES = [
"layout",
"runtime",
"hydrate",
"client",
"types",
"props",
"outputs",
"state",
"persist",
"computed",
"effect",
"watch",
"lifecycle",
"view",
"seo",
"security",
"load",
"action",
"api",
"ssr",
"realtime",
"style",
"functions",
];
const VALID_MEMBERS = {
page: new Set(ROOT_MEMBER_NAMES),
component: new Set(ROOT_MEMBER_NAMES),
layout: new Set(ROOT_MEMBER_NAMES),
"global store": new Set(ROOT_MEMBER_NAMES),
"page store": new Set(ROOT_MEMBER_NAMES),
};
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 maskLeadingTrivia(source) {
const masked = [...source];
let offset = 0;
const importPattern = /import\s+(?:type\s+)?(?:[\s\S]*?\s+from\s+)?["'][^"'\r\n]+["']\s*;?/y;
while (offset < source.length) {
if (/\s/u.test(source[offset])) {
offset += 1;
continue;
}
importPattern.lastIndex = offset;
const importStatement = importPattern.exec(source);
if (importStatement) {
const end = importPattern.lastIndex;
while (offset < end) {
if (source[offset] !== "\n" && source[offset] !== "\r") masked[offset] = " ";
offset += 1;
}
continue;
}
if (!source.startsWith("//", offset)) break;
while (offset < source.length && source[offset] !== "\n") {
masked[offset] = " ";
offset += 1;
}
}
return masked.join("");
}
function findTopLevelDeclaration(document, source) {
const sourceWithoutLeadingTrivia = maskLeadingTrivia(source);
const match = TOP_LEVEL_PATTERN.exec(sourceWithoutLeadingTrivia);
if (!match) {
const firstMeaningfulLine = sourceWithoutLeadingTrivia
.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 (source.startsWith("//", index)) {
const lineEnd = source.indexOf("\n", index + 2);
index = lineEnd === -1 ? source.length : lineEnd;
continue;
}
// Apostrophes in rendered copy (for example, "person's name") are text,
// not the beginning of a WRN/HTML quoted value.
if (
character === "'" &&
/[\p{L}\p{N}]/u.test(source[index - 1] ?? "") &&
/[\p{L}\p{N}]/u.test(source[index + 1] ?? "")
) {
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 maskHtmlComments(source) {
const masked = [...source];
let index = 0;
while (index < source.length) {
if (!source.startsWith("<!--", index)) {
index += 1;
continue;
}
const commentEnd = source.indexOf("-->", index + 4);
const end = commentEnd === -1 ? source.length : commentEnd + 3;
for (let cursor = index; cursor < end; cursor += 1) {
if (source[cursor] !== "\n" && source[cursor] !== "\r") masked[cursor] = " ";
}
index = end;
}
return masked.join("");
}
function maskTemplateExpressions(source) {
const masked = [...source];
let index = 0;
while (index < source.length) {
if (source[index] !== "{") {
index += 1;
continue;
}
const start = index;
let depth = 0;
let quote = null;
let escaped = false;
while (index < source.length) {
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 === "'" || character === "`") {
quote = character;
index += 1;
continue;
}
if (character === "{") depth += 1;
else if (character === "}") {
depth -= 1;
index += 1;
if (depth === 0) break;
continue;
}
index += 1;
}
for (let cursor = start; cursor < index; cursor += 1) {
if (source[cursor] !== "\n" && source[cursor] !== "\r") masked[cursor] = " ";
}
}
return masked.join("");
}
function findViewRanges(source) {
const ranges = [];
const pattern = /\bview\s*\{/g;
let match;
while ((match = pattern.exec(source)) !== null) {
const openingBrace = source.indexOf("{", match.index);
const closingBrace = findMatchingBrace(source, openingBrace);
if (closingBrace === -1) break;
ranges.push({ start: openingBrace + 1, end: closingBrace });
pattern.lastIndex = closingBrace + 1;
}
return ranges;
}
function validateHtmlTags(document, source) {
const diagnostics = [];
const voidElements = new Set([
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
]);
for (const range of findViewRanges(source)) {
const fragment = source.slice(range.start, range.end);
// JavaScript-style `//` is valid rendered text inside <pre>/<code>. Only
// HTML comments and WRN expressions are masked before tag validation.
const cleaned = maskTemplateExpressions(maskHtmlComments(fragment));
const stack = [];
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 normalizedName = /^[a-z]/.test(tagName) ? tagName.toLowerCase() : tagName;
const lowerTag = tagName.toLowerCase();
const absoluteStart = range.start + match.index;
const isClosing = completeTag.startsWith("</");
const isSelfClosing = /\/\s*>$/.test(completeTag);
const isVoid = voidElements.has(lowerTag);
if (isClosing) {
const matchingIndex = stack.findLastIndex((item) => item.normalizedName === normalizedName);
if (matchingIndex === -1) {
diagnostics.push(
createDiagnostic(
document,
absoluteStart,
absoluteStart + completeTag.length,
`Unexpected closing tag </${tagName}>.`,
vscode.DiagnosticSeverity.Error,
"wrn-unexpected-html-close",
),
);
continue;
}
const last = stack.at(-1);
if (last.normalizedName !== normalizedName) {
diagnostics.push(
createDiagnostic(
document,
absoluteStart,
absoluteStart + completeTag.length,
`Mismatched closing tag </${tagName}>. Expected </${last.tagName}>.`,
vscode.DiagnosticSeverity.Error,
"wrn-mismatched-html-tag",
),
);
}
stack.splice(matchingIndex);
continue;
}
if (!isSelfClosing && !isVoid) {
stack.push({
tagName,
normalizedName,
offset: absoluteStart,
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("{");
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)) {
const commentEnd = source.indexOf("-->", 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)) {
skipLine();
continue;
}
if (source.startsWith("/*", index)) {
const end = source.indexOf("*/", index + 2);
index = end === -1 ? bodyEnd : end + 2;
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 identifier = readIdentifier(source, index, bodyEnd);
if (!identifier) {
index += 1;
continue;
}
index = identifier.end;
members.push({
name: identifier.name,
start: identifier.start,
end: identifier.end,
});
const assignmentMembers = new Set(["layout", "runtime", "hydrate", "state"]);
if (assignmentMembers.has(identifier.name)) {
skipLine();
continue;
}
if (identifier.name === "client") {
const next = skipWhitespace(source, index, bodyEnd);
if (source[next] === "=") {
skipLine();
continue;
}
}
const blockMembers = new Set([
"types",
"props",
"computed",
"effect",
"watch",
"lifecycle",
"view",
"seo",
"security",
"load",
"action",
"api",
"ssr",
"client",
"realtime",
"style",
"functions",
]);
if (blockMembers.has(identifier.name)) {
let cursor = index;
let parenthesisDepth = 0;
let bracketDepth = 0;
let memberQuote = null;
let memberEscaped = false;
while (cursor < bodyEnd) {
const current = source[cursor];
if (memberQuote !== null) {
if (memberEscaped) {
memberEscaped = false;
} else if (current === "\\") {
memberEscaped = true;
} else if (current === memberQuote) {
memberQuote = null;
}
cursor += 1;
continue;
}
if (current === '"' || current === "'") {
memberQuote = current;
cursor += 1;
continue;
}
if (current === "(") parenthesisDepth += 1;
if (current === ")") parenthesisDepth = Math.max(0, parenthesisDepth - 1);
if (current === "[") bracketDepth += 1;
if (current === "]") bracketDepth = Math.max(0, bracketDepth - 1);
if (current === "{" && parenthesisDepth === 0 && bracketDepth === 0) {
index = cursor;
break;
}
cursor += 1;
}
if (cursor >= bodyEnd) index = bodyEnd;
}
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)) {
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 &&
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)) {
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 &&
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, rootMatch) {
const diagnostics = [];
if (rootKind !== "page") {
const bodyRange = getRootBodyRange(source, rootMatch);
const layoutMember = findRootMembers(source, bodyRange.start, bodyRange.end).find(
(member) => member.name === "layout",
);
if (layoutMember) {
diagnostics.push(
createDiagnostic(
document,
layoutMember.start,
layoutMember.end,
'`layout = "..."` is only valid inside a page.',
vscode.DiagnosticSeverity.Error,
"wrn-invalid-layout-member",
),
);
}
}
return diagnostics;
}
function validateV060Features(document, source) {
const diagnostics = [];
const duplicateRuntimeFunctions = new Map();
for (const match of source.matchAll(
/\b(client|server|shared)\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g,
)) {
const key = `${match[1]}:${match[2]}`;
if (duplicateRuntimeFunctions.has(key)) {
diagnostics.push(
createDiagnostic(
document,
match.index,
match.index + match[0].length,
`Duplicate ${match[1]} function '${match[2]}'.`,
vscode.DiagnosticSeverity.Error,
"WRN-FUNCTION-DUPLICATE",
),
);
} else duplicateRuntimeFunctions.set(key, match.index);
}
for (const match of source.matchAll(/\$emit\s*\(/g)) {
diagnostics.push(
createDiagnostic(
document,
match.index,
match.index + match[0].length,
"Use typed output.name(payload) instead of deprecated $emit().",
vscode.DiagnosticSeverity.Warning,
"WRN-OUTPUT-LEGACY-EMIT",
),
);
}
for (const match of source.matchAll(/\bevent\.detail\b/g)) {
diagnostics.push(
createDiagnostic(
document,
match.index,
match.index + match[0].length,
"Component output handlers receive payload directly.",
vscode.DiagnosticSeverity.Warning,
"WRN-OUTPUT-LEGACY-DETAIL",
),
);
}
for (const match of source.matchAll(
/\bserver\s+(?:async\s+)?function\b[\s\S]*?\b(window|document|localStorage|navigator)\b/g,
)) {
const offset = match.index + match[0].lastIndexOf(match[1]);
diagnostics.push(
createDiagnostic(
document,
offset,
offset + match[1].length,
`Browser API '${match[1]}' is unavailable in a server function.`,
vscode.DiagnosticSeverity.Error,
"WRN-SERVER-BROWSER-API",
),
);
}
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, declaration.match));
diagnostics.push(...validateV060Features(document, source));
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);
timers.delete(key);
}
const configuration = vscode.workspace.getConfiguration("wrnexus", document.uri);
if (!configuration.get("diagnostics.enable", true)) {
collection.delete(document.uri);
return;
}
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.onDidChangeConfiguration((event) => {
if (!event.affectsConfiguration("wrnexus.diagnostics.enable")) {
return;
}
for (const document of vscode.workspace.textDocuments) {
update(document);
}
}),
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 = {
findTopLevelDeclaration,
maskLeadingTrivia,
registerDiagnostics,
validateBalancedCharacters,
validateDocument,
validateHtmlTags,
validateLifecycleBlocks,
validateLayoutUsage,
validateRootMembers,
validateWatchBlocks,
};