release: Vs Code New Extension with layout Support
This commit is contained in:
@@ -0,0 +1,761 @@
|
||||
"use strict";
|
||||
|
||||
const vscode = require("vscode");
|
||||
|
||||
const COLLECTION_NAME = "wrnexus";
|
||||
const WRN_LANGUAGE_ID = "wrn";
|
||||
|
||||
const TOP_LEVEL_PATTERN =
|
||||
/^\s*(page|component|layout)\s+([A-Za-z_$][\w$]*)\s*\{/;
|
||||
|
||||
const VALID_TOP_LEVEL_KINDS = new Set([
|
||||
"page",
|
||||
"component",
|
||||
"layout",
|
||||
]);
|
||||
|
||||
const VALID_MEMBERS = {
|
||||
page: new Set([
|
||||
"layout",
|
||||
"state",
|
||||
"view",
|
||||
"seo",
|
||||
"style",
|
||||
"functions",
|
||||
"api",
|
||||
"ssr",
|
||||
"client",
|
||||
"realtime",
|
||||
]),
|
||||
|
||||
component: new Set([
|
||||
"props",
|
||||
"state",
|
||||
"view",
|
||||
"style",
|
||||
"functions",
|
||||
]),
|
||||
|
||||
layout: new Set([
|
||||
"props",
|
||||
"state",
|
||||
"view",
|
||||
"style",
|
||||
"functions",
|
||||
]),
|
||||
};
|
||||
|
||||
function createDiagnostic(
|
||||
document,
|
||||
startOffset,
|
||||
endOffset,
|
||||
message,
|
||||
severity = vscode.DiagnosticSeverity.Error,
|
||||
code,
|
||||
) {
|
||||
const diagnostic = new vscode.Diagnostic(
|
||||
new vscode.Range(
|
||||
document.positionAt(startOffset),
|
||||
document.positionAt(endOffset),
|
||||
),
|
||||
message,
|
||||
severity,
|
||||
);
|
||||
|
||||
diagnostic.source = "WRNexus";
|
||||
|
||||
if (code) {
|
||||
diagnostic.code = code;
|
||||
}
|
||||
|
||||
return diagnostic;
|
||||
}
|
||||
|
||||
function lineDiagnostic(
|
||||
document,
|
||||
lineNumber,
|
||||
message,
|
||||
severity = vscode.DiagnosticSeverity.Error,
|
||||
code,
|
||||
) {
|
||||
const line = document.lineAt(lineNumber);
|
||||
|
||||
const diagnostic = new vscode.Diagnostic(
|
||||
line.range,
|
||||
message,
|
||||
severity,
|
||||
);
|
||||
|
||||
diagnostic.source = "WRNexus";
|
||||
|
||||
if (code) {
|
||||
diagnostic.code = code;
|
||||
}
|
||||
|
||||
return diagnostic;
|
||||
}
|
||||
|
||||
function stripComments(source) {
|
||||
return source.replace(/<!--[\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("{");
|
||||
|
||||
let depth = 0;
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
|
||||
for (
|
||||
let index = openingBrace;
|
||||
index < source.length;
|
||||
index += 1
|
||||
) {
|
||||
const character = source[index];
|
||||
|
||||
if (quote !== null) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === quote) {
|
||||
quote = null;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"' || character === "'") {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "{") {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "}") {
|
||||
depth -= 1;
|
||||
|
||||
if (depth === 0) {
|
||||
return {
|
||||
start: openingBrace + 1,
|
||||
end: index,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start: openingBrace + 1,
|
||||
end: source.length,
|
||||
};
|
||||
}
|
||||
|
||||
function findRootMembers(source, bodyStart, bodyEnd) {
|
||||
const members = [];
|
||||
let index = bodyStart;
|
||||
let depth = 0;
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
|
||||
while (index < bodyEnd) {
|
||||
const character = source[index];
|
||||
|
||||
if (quote !== null) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "\\") {
|
||||
escaped = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === quote) {
|
||||
quote = null;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"' || character === "'") {
|
||||
quote = character;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source.startsWith("<!--", index)) {
|
||||
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 start = index;
|
||||
index += 1;
|
||||
|
||||
while (
|
||||
index < bodyEnd &&
|
||||
/[A-Za-z0-9_-]/.test(source[index])
|
||||
) {
|
||||
index += 1;
|
||||
}
|
||||
|
||||
const name = source.slice(start, index);
|
||||
|
||||
members.push({
|
||||
name,
|
||||
start,
|
||||
end: index,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return members;
|
||||
}
|
||||
|
||||
function validateRootMembers(
|
||||
document,
|
||||
source,
|
||||
rootKind,
|
||||
rootMatch,
|
||||
) {
|
||||
const diagnostics = [];
|
||||
const allowed = VALID_MEMBERS[rootKind];
|
||||
|
||||
if (!allowed) {
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
const bodyRange = getRootBodyRange(source, rootMatch);
|
||||
const members = findRootMembers(
|
||||
source,
|
||||
bodyRange.start,
|
||||
bodyRange.end,
|
||||
);
|
||||
|
||||
for (const member of members) {
|
||||
if (allowed.has(member.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
document,
|
||||
member.start,
|
||||
member.end,
|
||||
`Unknown ${rootKind} member \`${member.name}\`.`,
|
||||
vscode.DiagnosticSeverity.Error,
|
||||
"wrn-unknown-member",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function validateRequiredView(
|
||||
document,
|
||||
source,
|
||||
rootKind,
|
||||
rootMatch,
|
||||
) {
|
||||
const bodyRange = getRootBodyRange(source, rootMatch);
|
||||
const body = source.slice(bodyRange.start, bodyRange.end);
|
||||
|
||||
if (/\bview\s*\{/.test(body)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
createDiagnostic(
|
||||
document,
|
||||
rootMatch.index,
|
||||
rootMatch.index + rootMatch[0].length,
|
||||
`The ${rootKind} \`${rootMatch[2]}\` does not contain a \`view { ... }\` block.`,
|
||||
vscode.DiagnosticSeverity.Warning,
|
||||
"wrn-missing-view",
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function validateLayoutUsage(
|
||||
document,
|
||||
source,
|
||||
rootKind,
|
||||
) {
|
||||
const diagnostics = [];
|
||||
|
||||
if (
|
||||
rootKind !== "page" &&
|
||||
/^\s*layout\s*=/m.test(source)
|
||||
) {
|
||||
const match = /^\s*layout\s*=/m.exec(source);
|
||||
|
||||
if (match) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
document,
|
||||
match.index,
|
||||
match.index + match[0].length,
|
||||
"`layout = \"...\"` is only valid inside a page.",
|
||||
vscode.DiagnosticSeverity.Error,
|
||||
"wrn-invalid-layout-member",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function validateDocument(document) {
|
||||
if (document.languageId !== WRN_LANGUAGE_ID) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const source = document.getText();
|
||||
|
||||
if (!source.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const diagnostics = [];
|
||||
const declaration = findTopLevelDeclaration(
|
||||
document,
|
||||
source,
|
||||
);
|
||||
|
||||
if (declaration.diagnostic) {
|
||||
diagnostics.push(declaration.diagnostic);
|
||||
diagnostics.push(
|
||||
...validateBalancedCharacters(document, source),
|
||||
);
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
if (!VALID_TOP_LEVEL_KINDS.has(declaration.kind)) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
document,
|
||||
declaration.match.index,
|
||||
declaration.match.index +
|
||||
declaration.match[0].length,
|
||||
`Unsupported WRN declaration \`${declaration.kind}\`.`,
|
||||
vscode.DiagnosticSeverity.Error,
|
||||
"wrn-invalid-kind",
|
||||
),
|
||||
);
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
diagnostics.push(
|
||||
...validateBalancedCharacters(document, source),
|
||||
);
|
||||
|
||||
diagnostics.push(
|
||||
...validateHtmlTags(document, source),
|
||||
);
|
||||
|
||||
diagnostics.push(
|
||||
...validateRootMembers(
|
||||
document,
|
||||
source,
|
||||
declaration.kind,
|
||||
declaration.match,
|
||||
),
|
||||
);
|
||||
|
||||
diagnostics.push(
|
||||
...validateRequiredView(
|
||||
document,
|
||||
source,
|
||||
declaration.kind,
|
||||
declaration.match,
|
||||
),
|
||||
);
|
||||
|
||||
diagnostics.push(
|
||||
...validateLayoutUsage(
|
||||
document,
|
||||
source,
|
||||
declaration.kind,
|
||||
),
|
||||
);
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function registerDiagnostics(context) {
|
||||
const collection =
|
||||
vscode.languages.createDiagnosticCollection(
|
||||
COLLECTION_NAME,
|
||||
);
|
||||
|
||||
const timers = new Map();
|
||||
|
||||
const update = (document) => {
|
||||
if (document.languageId !== WRN_LANGUAGE_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousTimer = timers.get(
|
||||
document.uri.toString(),
|
||||
);
|
||||
|
||||
if (previousTimer) {
|
||||
clearTimeout(previousTimer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timers.delete(document.uri.toString());
|
||||
|
||||
collection.set(
|
||||
document.uri,
|
||||
validateDocument(document),
|
||||
);
|
||||
}, 150);
|
||||
|
||||
timers.set(document.uri.toString(), timer);
|
||||
};
|
||||
|
||||
for (const document of vscode.workspace.textDocuments) {
|
||||
update(document);
|
||||
}
|
||||
|
||||
context.subscriptions.push(
|
||||
collection,
|
||||
|
||||
vscode.workspace.onDidOpenTextDocument(update),
|
||||
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
update(event.document);
|
||||
}),
|
||||
|
||||
vscode.workspace.onDidSaveTextDocument(update),
|
||||
|
||||
vscode.workspace.onDidCloseTextDocument(
|
||||
(document) => {
|
||||
const key = document.uri.toString();
|
||||
const timer = timers.get(key);
|
||||
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timers.delete(key);
|
||||
}
|
||||
|
||||
collection.delete(document.uri);
|
||||
},
|
||||
),
|
||||
|
||||
{
|
||||
dispose() {
|
||||
for (const timer of timers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
timers.clear();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerDiagnostics,
|
||||
validateBalancedCharacters,
|
||||
validateDocument,
|
||||
validateHtmlTags,
|
||||
validateRootMembers,
|
||||
};
|
||||
Reference in New Issue
Block a user