Files
dbx/apps/desktop/src/lib/nacos/nacosConfigValidation.ts
T

270 lines
12 KiB
TypeScript

import { htmlLanguage } from "@codemirror/lang-html";
import { jsonLanguage } from "@codemirror/lang-json";
import { XMLValidator } from "fast-xml-parser";
import { parse as parseToml, TomlError } from "smol-toml";
import { parseDocument } from "yaml";
export type NacosConfigValidationFormat = "text" | "json" | "xml" | "yaml" | "html" | "properties" | "toml";
export interface NacosConfigDiagnostic {
message: string;
line: number;
column: number;
from: number;
to: number;
}
function lineColumnAt(text: string, offset: number) {
const bounded = Math.max(0, Math.min(offset, text.length));
const before = text.slice(0, bounded);
const lineStart = before.lastIndexOf("\n") + 1;
return { line: before.split("\n").length, column: bounded - lineStart + 1 };
}
function diagnosticAt(text: string, offset: number, message: string, length = 1): NacosConfigDiagnostic {
const position = lineColumnAt(text, offset);
return { message, ...position, from: offset, to: Math.min(text.length, Math.max(offset + length, offset + 1)) };
}
function offsetAtLineColumn(text: string, line: number, column: number): number {
let offset = 0;
for (let currentLine = 1; currentLine < line && offset < text.length; currentLine += 1) {
const newline = text.indexOf("\n", offset);
offset = newline < 0 ? text.length : newline + 1;
}
return Math.min(text.length, offset + Math.max(0, column - 1));
}
type SyntaxErrorRange = { from: number; to: number };
type HtmlOpenTag = { name: string; offset: number };
const HTML_VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
const HTML_OPTIONAL_END_TAGS = new Set(["html", "head", "body", "colgroup", "li", "dt", "dd", "p", "rt", "rp", "optgroup", "option", "thead", "tbody", "tfoot", "tr", "td", "th"]);
const HTML_RAW_TEXT_TAGS = new Set(["script", "style", "textarea", "title"]);
const HTML_BLOCK_TAGS_THAT_CLOSE_P = new Set(["address", "article", "aside", "blockquote", "div", "dl", "fieldset", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hgroup", "hr", "main", "menu", "nav", "ol", "p", "pre", "section", "table", "ul"]);
function syntaxErrors(text: string, language: { parser: { parse: (source: string) => { iterate: (spec: { enter: (node: { type: { isError: boolean }; from: number; to: number }) => void }) => void } } }): SyntaxErrorRange[] {
const errors: SyntaxErrorRange[] = [];
language.parser.parse(text).iterate({
enter(node) {
if (node.type.isError) errors.push({ from: node.from, to: node.to });
},
});
return errors.filter((error, index) => errors.findIndex((candidate) => candidate.from === error.from && candidate.to === error.to) === index);
}
function firstSyntaxError(text: string, language: { parser: { parse: (source: string) => { iterate: (spec: { enter: (node: { type: { isError: boolean }; from: number; to: number }) => void }) => void } } }, label: string) {
const error = syntaxErrors(text, language)[0];
return error ? diagnosticAt(text, error.from, `Invalid ${label} syntax`, Math.max(1, error.to - error.from)) : null;
}
function htmlTagEnd(text: string, start: number): number {
let quote = "";
for (let index = start; index < text.length; index += 1) {
const character = text[index];
if (quote) {
if (character === quote) quote = "";
} else if (character === '"' || character === "'") {
quote = character;
} else if (character === ">") {
return index;
}
}
return -1;
}
function closeImplicitHtmlTags(stack: HtmlOpenTag[], openingTag: string) {
const current = stack[stack.length - 1]?.name;
if (!current) return;
if (openingTag === "li" && current === "li") stack.pop();
else if (["dt", "dd"].includes(openingTag) && ["dt", "dd"].includes(current)) stack.pop();
else if (openingTag === "option" && current === "option") stack.pop();
else if (openingTag === "optgroup" && ["option", "optgroup"].includes(current)) stack.pop();
else if (["thead", "tbody", "tfoot"].includes(openingTag) && ["thead", "tbody", "tfoot"].includes(current)) stack.pop();
else if (openingTag === "tr" && current === "tr") stack.pop();
else if (["td", "th"].includes(openingTag) && ["td", "th"].includes(current)) stack.pop();
else if (current === "p" && HTML_BLOCK_TAGS_THAT_CLOSE_P.has(openingTag)) stack.pop();
}
function validateHtml(text: string): NacosConfigDiagnostic | null {
const syntaxError = firstSyntaxError(text, htmlLanguage, "HTML");
if (syntaxError) return syntaxError;
const stack: HtmlOpenTag[] = [];
for (let offset = 0; offset < text.length; ) {
if (text[offset] !== "<") {
offset += 1;
continue;
}
if (text.startsWith("<!--", offset)) {
const end = text.indexOf("-->", offset + 4);
if (end < 0) return diagnosticAt(text, offset, "Invalid HTML syntax: unclosed comment");
offset = end + 3;
continue;
}
if (text.startsWith("<![CDATA[", offset)) {
const end = text.indexOf("]]>", offset + 9);
if (end < 0) return diagnosticAt(text, offset, "Invalid HTML syntax: unclosed CDATA section");
offset = end + 3;
continue;
}
if (text[offset + 1] === "!" || text[offset + 1] === "?") {
const end = htmlTagEnd(text, offset + 2);
if (end < 0) return diagnosticAt(text, offset, "Invalid HTML syntax: unclosed declaration");
offset = end + 1;
continue;
}
const closing = text[offset + 1] === "/";
const nameStart = offset + (closing ? 2 : 1);
const name = /^[A-Za-z][A-Za-z0-9:_-]*/.exec(text.slice(nameStart))?.[0]?.toLowerCase();
if (!name) {
offset += 1;
continue;
}
const end = htmlTagEnd(text, nameStart + name.length);
if (end < 0) return diagnosticAt(text, offset, "Invalid HTML syntax: unclosed tag");
const selfClosing = /\/\s*$/.test(text.slice(nameStart + name.length, end));
offset = end + 1;
if (closing) {
while (stack.length && stack[stack.length - 1]?.name !== name && HTML_OPTIONAL_END_TAGS.has(stack[stack.length - 1]?.name || "")) stack.pop();
const openTag = stack[stack.length - 1];
if (!openTag) return diagnosticAt(text, nameStart, `Invalid HTML syntax: closing tag '${name}' has not been opened`, name.length);
if (openTag.name !== name) return diagnosticAt(text, nameStart, `Invalid HTML syntax: expected closing tag '${openTag.name}'`, name.length);
stack.pop();
continue;
}
closeImplicitHtmlTags(stack, name);
if (!selfClosing && !HTML_VOID_TAGS.has(name)) stack.push({ name, offset });
if (HTML_RAW_TEXT_TAGS.has(name)) {
const closingTag = new RegExp(`</${name}\\s*>`, "i");
const match = closingTag.exec(text.slice(offset));
if (!match || match.index === undefined) return diagnosticAt(text, offset - 1, `Invalid HTML syntax: unclosed tag '${name}'`, name.length);
offset += match.index;
}
}
const unclosed = [...stack].reverse().find((tag) => !HTML_OPTIONAL_END_TAGS.has(tag.name));
return unclosed ? diagnosticAt(text, unclosed.offset, `Invalid HTML syntax: unclosed tag '${unclosed.name}'`, unclosed.name.length) : null;
}
function jsonErrorOffset(text: string, error: SyntaxErrorRange): number {
if (error.to > error.from) return error.from;
const previous = previousNonWhitespaceOffset(text, error.from);
const next = nextNonWhitespaceOffset(text, error.from);
if (previous >= 0 && text[previous] === "," && next < text.length && (text[next] === "}" || text[next] === "]")) return previous;
if (previous >= 0 && (next >= text.length || "{[}]".includes(text[next]))) return previous;
return next < text.length ? next : Math.max(0, previous);
}
function trailingCommaOffset(text: string, offset: number): number | null {
const previous = previousNonWhitespaceOffset(text, offset);
const next = nextNonWhitespaceOffset(text, offset);
return previous >= 0 && text[previous] === "," && next < text.length && (text[next] === "}" || text[next] === "]") ? previous : null;
}
function validateJson(text: string): NacosConfigDiagnostic[] {
try {
JSON.parse(text);
return [];
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const errors = syntaxErrors(text, jsonLanguage);
if (errors.length) {
return errors.map((syntaxError, index) => {
const offset = jsonErrorOffset(text, syntaxError);
const trailingComma = trailingCommaOffset(text, syntaxError.from) ?? trailingCommaOffset(text, offset);
if (trailingComma !== null) return diagnosticAt(text, trailingComma, "Trailing comma is not allowed in JSON");
return diagnosticAt(text, offset, index === 0 ? message : "Invalid JSON syntax", Math.max(1, syntaxError.to - syntaxError.from));
});
}
const match = /position\s+(\d+)/i.exec(message);
return [diagnosticAt(text, match ? Number(match[1]) : 0, message)];
}
}
function previousNonWhitespaceOffset(text: string, offset: number): number {
let index = Math.min(offset, text.length) - 1;
while (index >= 0 && /\s/.test(text[index])) index--;
return index;
}
function nextNonWhitespaceOffset(text: string, offset: number): number {
let index = Math.max(0, offset);
while (index < text.length && /\s/.test(text[index])) index++;
return index;
}
function validateYaml(text: string): NacosConfigDiagnostic | null {
const lines = text.split(/\r?\n/);
let offset = 0;
for (const line of lines) {
const indentation = /^[ \t]*/.exec(line)?.[0] ?? "";
const tabIndex = indentation.indexOf("\t");
if (tabIndex >= 0) {
return diagnosticAt(text, offset + tabIndex, "YAML indentation must use spaces, not tabs");
}
offset += line.length + 1;
}
const document = parseDocument(text);
const error = document.errors[0];
if (error) {
const offsetFromError = Array.isArray(error.pos) ? error.pos[0] : 0;
return diagnosticAt(text, offsetFromError, error.message);
}
return null;
}
function validateXml(text: string): NacosConfigDiagnostic | null {
const result = XMLValidator.validate(text);
if (result === true) return null;
const { err } = result;
return diagnosticAt(text, offsetAtLineColumn(text, err.line, err.col), `Invalid XML syntax: ${err.msg}`);
}
function validateProperties(text: string): NacosConfigDiagnostic | null {
for (let offset = 0; offset < text.length; offset += 1) {
if (text[offset] !== "\\") continue;
if (text[offset + 1] !== "u") {
offset += 1;
continue;
}
const unicode = text.slice(offset + 2, offset + 6);
if (!/^[0-9a-f]{4}$/i.test(unicode)) {
return diagnosticAt(text, offset, "Invalid Properties Unicode escape", Math.min(6, text.length - offset));
}
offset += 5;
}
return null;
}
function validateToml(text: string): NacosConfigDiagnostic | null {
try {
parseToml(text);
return null;
} catch (error) {
const message = error instanceof Error ? error.message.split("\n\n", 1)[0] : String(error);
const position = error instanceof TomlError ? offsetAtLineColumn(text, error.line, error.column) : 0;
return diagnosticAt(text, position, `Invalid TOML syntax: ${message}`);
}
}
export function validateNacosConfigContent(text: string, format: string): NacosConfigDiagnostic[] {
const rawFormat = format.trim().toLowerCase();
const normalized = (rawFormat === "yml" ? "yaml" : rawFormat === "props" ? "properties" : rawFormat) as NacosConfigValidationFormat;
let diagnostic: NacosConfigDiagnostic | null = null;
if (normalized === "json") return validateJson(text);
else if (normalized === "yaml") diagnostic = validateYaml(text);
else if (normalized === "xml") diagnostic = validateXml(text);
else if (normalized === "html") diagnostic = validateHtml(text);
else if (normalized === "properties") diagnostic = validateProperties(text);
else if (normalized === "toml") diagnostic = validateToml(text);
return diagnostic ? [diagnostic] : [];
}
export function validateNacosConfig(text: string, format: string): NacosConfigDiagnostic | null {
return validateNacosConfigContent(text, format)[0] ?? null;
}