1453 lines
33 KiB
JavaScript
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,
|
|
};
|