1125 lines
24 KiB
JavaScript
1125 lines
24 KiB
JavaScript
"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(/<!--[\s\S]*?-->/g, (comment) => comment.replace(/[^\n]/g, " "));
|
|
}
|
|
|
|
function findTopLevelDeclaration(document, source) {
|
|
const match = TOP_LEVEL_PATTERN.exec(source);
|
|
|
|
if (!match) {
|
|
const firstMeaningfulLine = source.split(/\r?\n/).findIndex((line) => line.trim().length > 0);
|
|
|
|
return {
|
|
diagnostic: lineDiagnostic(
|
|
document,
|
|
Math.max(0, firstMeaningfulLine),
|
|
"A .wrn file must start with `page`, `component`, or `layout`.",
|
|
vscode.DiagnosticSeverity.Error,
|
|
"wrn-invalid-root",
|
|
),
|
|
};
|
|
}
|
|
|
|
return {
|
|
kind: match[1],
|
|
name: match[2],
|
|
match,
|
|
};
|
|
}
|
|
|
|
function validateBalancedCharacters(document, source) {
|
|
const diagnostics = [];
|
|
const stack = [];
|
|
|
|
let quote = null;
|
|
let escaped = false;
|
|
|
|
const pairs = {
|
|
"}": "{",
|
|
"]": "[",
|
|
")": "(",
|
|
};
|
|
|
|
for (let index = 0; index < source.length; index += 1) {
|
|
const character = source[index];
|
|
|
|
if (quote !== null) {
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
|
|
if (character === "\\") {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
|
|
if (character === quote) {
|
|
quote = null;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (character === '"' || character === "'") {
|
|
quote = character;
|
|
continue;
|
|
}
|
|
|
|
if (source.startsWith("<!--", index)) {
|
|
const commentEnd = source.indexOf("-->", index + 4);
|
|
|
|
if (commentEnd === -1) {
|
|
diagnostics.push(
|
|
createDiagnostic(
|
|
document,
|
|
index,
|
|
Math.min(source.length, index + 4),
|
|
"Unclosed HTML comment.",
|
|
vscode.DiagnosticSeverity.Error,
|
|
"wrn-unclosed-comment",
|
|
),
|
|
);
|
|
|
|
break;
|
|
}
|
|
|
|
index = commentEnd + 2;
|
|
continue;
|
|
}
|
|
|
|
if (character === "{" || character === "[" || character === "(") {
|
|
stack.push({
|
|
character,
|
|
offset: index,
|
|
});
|
|
|
|
continue;
|
|
}
|
|
|
|
if (character === "}" || character === "]" || character === ")") {
|
|
const expectedOpening = pairs[character];
|
|
const opening = stack.pop();
|
|
|
|
if (!opening || opening.character !== expectedOpening) {
|
|
diagnostics.push(
|
|
createDiagnostic(
|
|
document,
|
|
index,
|
|
index + 1,
|
|
`Unexpected \`${character}\`.`,
|
|
vscode.DiagnosticSeverity.Error,
|
|
"wrn-unexpected-closing",
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const opening of stack) {
|
|
const expectedClosing = opening.character === "{" ? "}" : opening.character === "[" ? "]" : ")";
|
|
|
|
diagnostics.push(
|
|
createDiagnostic(
|
|
document,
|
|
opening.offset,
|
|
opening.offset + 1,
|
|
`Missing closing \`${expectedClosing}\`.`,
|
|
vscode.DiagnosticSeverity.Error,
|
|
"wrn-missing-closing",
|
|
),
|
|
);
|
|
}
|
|
|
|
if (quote !== null) {
|
|
diagnostics.push(
|
|
createDiagnostic(
|
|
document,
|
|
Math.max(0, source.length - 1),
|
|
source.length,
|
|
`Unclosed ${quote === '"' ? "double" : "single"} quote.`,
|
|
vscode.DiagnosticSeverity.Error,
|
|
"wrn-unclosed-string",
|
|
),
|
|
);
|
|
}
|
|
|
|
return diagnostics;
|
|
}
|
|
|
|
function validateHtmlTags(document, source) {
|
|
const diagnostics = [];
|
|
const stack = [];
|
|
|
|
const voidElements = new Set([
|
|
"area",
|
|
"base",
|
|
"br",
|
|
"col",
|
|
"embed",
|
|
"hr",
|
|
"img",
|
|
"input",
|
|
"link",
|
|
"meta",
|
|
"param",
|
|
"source",
|
|
"track",
|
|
"wbr",
|
|
]);
|
|
|
|
const cleaned = stripComments(source);
|
|
const tagPattern = /<\/?([A-Za-z][A-Za-z0-9_$:.-]*)(?:\s[\s\S]*?)?\/?>/g;
|
|
|
|
let match;
|
|
|
|
while ((match = tagPattern.exec(cleaned)) !== null) {
|
|
const completeTag = match[0];
|
|
const tagName = match[1];
|
|
const lowerTag = tagName.toLowerCase();
|
|
|
|
const isClosing = completeTag.startsWith("</");
|
|
const isSelfClosing = completeTag.endsWith("/>");
|
|
const isVoid = voidElements.has(lowerTag);
|
|
|
|
if (isClosing) {
|
|
const last = stack.pop();
|
|
|
|
if (!last) {
|
|
diagnostics.push(
|
|
createDiagnostic(
|
|
document,
|
|
match.index,
|
|
match.index + completeTag.length,
|
|
`Unexpected closing tag </${tagName}>.`,
|
|
vscode.DiagnosticSeverity.Error,
|
|
"wrn-unexpected-html-close",
|
|
),
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
if (last.tagName !== tagName) {
|
|
diagnostics.push(
|
|
createDiagnostic(
|
|
document,
|
|
match.index,
|
|
match.index + completeTag.length,
|
|
`Mismatched closing tag </${tagName}>. Expected </${last.tagName}>.`,
|
|
vscode.DiagnosticSeverity.Error,
|
|
"wrn-mismatched-html-tag",
|
|
),
|
|
);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (!isSelfClosing && !isVoid) {
|
|
stack.push({
|
|
tagName,
|
|
offset: match.index,
|
|
length: completeTag.length,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const tag of stack) {
|
|
diagnostics.push(
|
|
createDiagnostic(
|
|
document,
|
|
tag.offset,
|
|
tag.offset + tag.length,
|
|
`Missing closing tag </${tag.tagName}>.`,
|
|
vscode.DiagnosticSeverity.Error,
|
|
"wrn-missing-html-close",
|
|
),
|
|
);
|
|
}
|
|
|
|
return diagnostics;
|
|
}
|
|
|
|
function getRootBodyRange(source, rootMatch) {
|
|
const openingBrace = rootMatch.index + rootMatch[0].lastIndexOf("{");
|
|
|
|
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)) {
|
|
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,
|
|
});
|
|
|
|
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)) {
|
|
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) {
|
|
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,
|
|
};
|