feat(nacos): add config syntax validation with error location

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-03 19:13:33 +08:00
1 parent 7157b04d87
commit 0448d5bd2c
14 files changed
+604 -4

No files matched your search

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, shallowRef, useId, watch } from "vue";
import { Compartment, type Extension } from "@codemirror/state";
import { Compartment, StateEffect, StateField, type Extension } from "@codemirror/state";
import { StreamLanguage, ensureSyntaxTree } from "@codemirror/language";
import type { EditorView } from "@codemirror/view";
import { Decoration, EditorView } from "@codemirror/view";
import { Archive, ArrowLeftRight, CheckCircle2, ChevronDown, ChevronLeft, ChevronRight, Clipboard, Columns3, Download, FileClock, FileInput, FileText, Loader2, Network, Plus, RefreshCw, Save, Search, Send, Server, Trash2, X } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -56,6 +56,7 @@ import { useTheme } from "@/composables/useTheme";
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { validateNacosConfigContent, type NacosConfigDiagnostic } from "@/lib/nacos/nacosConfigValidation";
import type { NacosConfigEditorViewport } from "@/types/database";
import type {
NacosBatchPreview,
@@ -131,6 +132,8 @@ const savingConfig = ref(false);
const deletingConfig = ref(false);
const configAdvancedOpen = ref(false);
const configSaveNotice = ref("");
const configValidationOpen = ref(false);
const configValidationDiagnostics = ref<NacosConfigDiagnostic[]>([]);
const pendingConfigSave = ref(false);
const pendingDeleteConfig = ref<NacosConfigDeleteSnapshot | null>(null);
interface NacosBatchDeleteSnapshot {
@@ -205,9 +208,12 @@ const configEditorTheme = new Compartment();
const configEditorFontTheme = new Compartment();
const configEditorWordWrap = new Compartment();
const configEditorLanguage = new Compartment();
const configValidationHighlight = new Compartment();
const setConfigValidationHighlight = StateEffect.define<NacosConfigDiagnostic[]>();
const configListRequestGuard = createNacosLatestRequestGuard();
const configDetailRequestGuard = createNacosLatestRequestGuard();
let configEditorGeneration = 0;
let configValidationHighlightActive = false;
let configEditorSessionId = 0;
let latestConfigSaveRequestId = 0;
@@ -507,6 +513,37 @@ async function configLanguageExtension(format: string): Promise<Extension[]> {
}
}
function configValidationHighlightExtension() {
const decorationsFor = (state: import("@codemirror/state").EditorState, diagnostics: NacosConfigDiagnostic[]) => {
const ranges = diagnostics
.filter(() => state.doc.length > 0)
.map((diagnostic) => {
const from = Math.min(Math.max(0, diagnostic.from), state.doc.length - 1);
const to = Math.min(state.doc.length, Math.max(diagnostic.to, from + 1));
return Decoration.mark({
class: "cm-nacos-config-validation-error",
attributes: { title: diagnostic.message },
}).range(from, to);
});
return ranges.length ? Decoration.set(ranges, true) : Decoration.none;
};
const field = StateField.define({
create() {
return Decoration.none;
},
update(decorations, transaction) {
for (const effect of transaction.effects) {
if (effect.is(setConfigValidationHighlight)) return decorationsFor(transaction.state, effect.value);
}
return transaction.docChanged ? decorations.map(transaction.changes) : decorations;
},
provide: (field) => EditorView.decorations.from(field),
});
return field;
}
async function mountConfigEditor() {
await nextTick();
if (!configEditorHost.value || configEditorView.value || !selectedConfig.value) return;
@@ -559,6 +596,7 @@ async function mountConfigEditor() {
},
}),
configEditorLanguage.of(language),
configValidationHighlight.of(configValidationHighlightExtension()),
configEditorTheme.of(theme),
configEditorFontTheme.of(editorFontTheme(EditorView, editorSettings.fontSize, editorSettings.fontFamily, { fixedHeight: true, scrollable: true })),
configEditorWordWrap.of(editorSettings.wordWrap ? EditorView.lineWrapping : []),
@@ -568,6 +606,8 @@ async function mountConfigEditor() {
if (!update.docChanged || generation !== configEditorGeneration || editorSessionId !== configEditorSessionId) return;
configContent.value = update.state.doc.toString();
configSaveNotice.value = "";
clearConfigValidation(false);
refreshConfigValidationHighlights(update.state.doc.toString(), generation, editorSessionId);
}),
EditorView.theme({
"&": {
@@ -754,11 +794,60 @@ function observeConfigListViewport(element: HTMLElement | null) {
function inferConfigFormat(dataId: string): string {
const ext = dataId.trim().toLowerCase().split(".").pop() || "";
if (ext === "yml") return "yaml";
if (["yaml", "json", "xml", "html", "properties", "text"].includes(ext)) return ext;
if (["yaml", "json", "xml", "html", "properties", "toml", "text"].includes(ext)) return ext;
if (ext === "txt") return "text";
return "";
}
function clearConfigValidation(clearHighlight = true) {
configValidationOpen.value = false;
configValidationDiagnostics.value = [];
if (clearHighlight) {
configValidationHighlightActive = false;
configEditorView.value?.dispatch({ effects: setConfigValidationHighlight.of([]) });
}
}
function refreshConfigValidationHighlights(content: string, generation: number, editorSessionId: number) {
if (!configValidationHighlightActive) return;
queueMicrotask(() => {
const view = configEditorView.value;
if (!view || !configValidationHighlightActive || generation !== configEditorGeneration || editorSessionId !== configEditorSessionId || view.state.doc.toString() !== content) return;
const diagnostics = validateNacosConfigContent(content, configType.value);
configValidationDiagnostics.value = diagnostics;
view.dispatch({ effects: setConfigValidationHighlight.of(diagnostics) });
if (!diagnostics.length) configValidationHighlightActive = false;
});
}
function validateCurrentConfig(showSuccess = true): boolean {
if (!selectedConfig.value) return true;
const diagnostics = validateNacosConfigContent(configContent.value, configType.value);
configValidationDiagnostics.value = diagnostics;
configValidationHighlightActive = false;
configEditorView.value?.dispatch({ effects: setConfigValidationHighlight.of([]) });
if (diagnostics.length) {
configValidationOpen.value = true;
return false;
}
if (showSuccess) toast(t("nacos.validationPassed"), 2000);
return true;
}
function focusConfigValidationDiagnostic() {
const diagnostic = configValidationDiagnostics.value[0];
const view = configEditorView.value;
if (!diagnostic || !view) return;
configValidationOpen.value = false;
configValidationHighlightActive = true;
view.dispatch({
effects: setConfigValidationHighlight.of(configValidationDiagnostics.value),
selection: { anchor: diagnostic.from },
scrollIntoView: true,
});
view.focus();
}
function configFormatValue(item: Pick<NacosConfigItem, "dataId" | "configType">): string {
const value = normalizeConfigFormat(item.configType);
return value || inferConfigFormat(item.dataId);
@@ -949,6 +1038,7 @@ function closePendingConfigMutationConfirmations() {
async function selectConfig(item: NacosConfigItem) {
closePendingConfigMutationConfirmations();
clearConfigValidation();
const detailRequestId = configDetailRequestGuard.begin();
configEditorSessionId += 1;
const listItemHadFormat = !!configFormatValue(item);
@@ -1006,6 +1096,7 @@ async function selectConfig(item: NacosConfigItem) {
function newConfig() {
closePendingConfigMutationConfirmations();
clearConfigValidation();
configDetailRequestGuard.invalidate();
configEditorSessionId += 1;
destroyConfigEditor();
@@ -1032,6 +1123,7 @@ function newConfig() {
function saveConfigAsCopy() {
if (!selectedConfig.value) return;
closePendingConfigMutationConfirmations();
clearConfigValidation();
configDetailRequestGuard.invalidate();
configEditorSessionId += 1;
const copy = createNacosSaveAsCopy({ ...selectedConfig.value, content: configContent.value, configType: configType.value });
@@ -1641,11 +1733,13 @@ async function setConfigFormat(format: string) {
if (selectedConfig.value) selectedConfig.value.configType = format;
if (selectedConfigOriginalKey.value) rememberConfigFormat({ ...selectedConfigOriginalKey.value, configType: format });
configSaveNotice.value = "";
clearConfigValidation();
await refreshConfigEditor();
}
function requestSaveConfig() {
if (!selectedConfig.value || !canRequestConfigSave.value) return;
if (!validateCurrentConfig(false)) return;
if (!isCreatingConfig.value && configContent.value !== originalConfigContent.value) {
pendingConfigSave.value = true;
return;
@@ -2880,6 +2974,10 @@ onBeforeUnmount(() => {
</div>
<div class="nacos-editor-actions-primary flex shrink-0 items-center gap-1.5 bg-muted/15">
<div class="mx-0.5 h-5 w-px shrink-0 bg-border" aria-hidden="true" />
<Button size="sm" variant="outline" class="h-8 gap-1.5 px-3" :disabled="!selectedConfig || savingConfig" @click="validateCurrentConfig()">
<CheckCircle2 class="h-3.5 w-3.5" />
{{ t("nacos.validate") }}
</Button>
<Button size="sm" class="h-8 gap-1.5 px-3" :disabled="!canRequestConfigSave || (!isCreatingConfig && !isConfigDirty)" @click="requestSaveConfig">
<Loader2 v-if="savingConfig" class="h-3.5 w-3.5 animate-spin" />
<Send v-else class="h-3.5 w-3.5" />
@@ -3165,6 +3263,30 @@ onBeforeUnmount(() => {
</Pane>
</Splitpanes>
<Dialog :open="configValidationOpen" @update:open="configValidationOpen = $event">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{{ t("nacos.validationErrorTitle") }}</DialogTitle>
<DialogDescription>{{ t("nacos.validationErrorDescription") }}</DialogDescription>
</DialogHeader>
<div v-if="configValidationDiagnostics.length" class="max-h-[min(50vh,24rem)] overflow-y-auto rounded-md border border-destructive/30 bg-destructive/5 text-sm">
<div v-for="(diagnostic, index) in configValidationDiagnostics" :key="`${diagnostic.from}-${diagnostic.to}-${index}`" class="border-b border-destructive/15 p-4 last:border-b-0">
<div class="min-w-0 space-y-2">
<p class="break-words font-medium text-destructive">{{ diagnostic.message }}</p>
<div class="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>{{ t("nacos.validationLine", { line: diagnostic.line }) }}</span>
<span>{{ t("nacos.validationColumn", { column: diagnostic.column }) }}</span>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button v-if="configValidationDiagnostics.length && configEditorView" variant="outline" @click="focusConfigValidationDiagnostic">{{ t("nacos.validationLocate") }}</Button>
<Button @click="configValidationOpen = false">{{ t("common.confirm") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<NacosConfigDiffDialog v-model:open="pendingConfigSave" :before="originalConfigContent" :after="configContent" :loading="savingConfig" @confirm="saveConfig" />
<NacosContentSearchDialog
@@ -3474,6 +3596,15 @@ onBeforeUnmount(() => {
background: var(--dbx-editor-selection-background, rgba(59, 130, 246, 0.35)) !important;
}
.nacos-config-editor :deep(.cm-nacos-config-validation-error) {
border-radius: 2px;
background: rgba(220, 38, 38, 0.16);
color: var(--destructive);
font-weight: 700;
text-decoration: underline wavy var(--destructive);
text-underline-offset: 3px;
}
.nacos-config-workbench {
container-type: inline-size;
}
+7
View File
@@ -9023,6 +9023,13 @@ export default {
confirmRollbackMessage: "This will roll the current config back to the selected history version.",
rollbackSuccess: "Config rolled back",
publish: "Publish",
validate: "Validate",
validationPassed: "Configuration syntax is valid",
validationErrorTitle: "Configuration syntax error",
validationErrorDescription: "Fix the syntax errors before publishing this configuration.",
validationLine: "Line: {line}",
validationColumn: "Column: {column}",
validationLocate: "Locate error",
dataIdRequired: "Data ID is required",
allGroups: "All groups",
noConfigs: "No configs",
+7
View File
@@ -7397,6 +7397,13 @@ export default withEnglishFallback({
confirmRollbackMessage: "This will roll the current config back to the selected history version.",
rollbackSuccess: "Config rolled back",
publish: "Publicar",
validate: "Validar",
validationPassed: "La sintaxis de la configuración es válida",
validationErrorTitle: "Error de sintaxis de configuración",
validationErrorDescription: "Corrige los errores de sintaxis antes de publicar.",
validationLine: "Línea: {line}",
validationColumn: "Columna: {column}",
validationLocate: "Ir al error",
dataIdRequired: "Data ID es obligatorio",
allGroups: "Todos los grupos",
noConfigs: "Sin configuraciones",
+7
View File
@@ -7398,6 +7398,13 @@ export default withEnglishFallback({
confirmRollbackMessage: "This will roll the current config back to the selected history version.",
rollbackSuccess: "Config rolled back",
publish: "Pubblica",
validate: "Convalida",
validationPassed: "La sintassi della configurazione è valida",
validationErrorTitle: "Errore di sintassi della configurazione",
validationErrorDescription: "Correggi gli errori di sintassi prima di pubblicare.",
validationLine: "Riga: {line}",
validationColumn: "Colonna: {column}",
validationLocate: "Vai all'errore",
dataIdRequired: "Data ID è obbligatorio",
allGroups: "Tutti i gruppi",
noConfigs: "Nessuna configurazione",
+7
View File
@@ -7437,6 +7437,13 @@ export default withEnglishFallback({
confirmRollbackMessage: "This will roll the current config back to the selected history version.",
rollbackSuccess: "Config rolled back",
publish: "公開",
validate: "検証",
validationPassed: "設定の構文は有効です",
validationErrorTitle: "設定の構文エラー",
validationErrorDescription: "公開する前に構文エラーを修正してください。",
validationLine: "行: {line}",
validationColumn: "列: {column}",
validationLocate: "エラーに移動",
dataIdRequired: "Data ID は必須です",
allGroups: "すべてのグループ",
noConfigs: "設定がありません",
+7
View File
@@ -8198,6 +8198,13 @@ export default withEnglishFallback({
confirmRollbackMessage: "현재 구성을 선택한 기록 버전으로 롤백합니다.",
rollbackSuccess: "구성을 롤백했습니다",
publish: "게시",
validate: "검증",
validationPassed: "구성 구문이 올바릅니다",
validationErrorTitle: "구성 구문 오류",
validationErrorDescription: "게시하기 전에 구문 오류를 수정하세요.",
validationLine: "줄: {line}",
validationColumn: "열: {column}",
validationLocate: "오류 위치",
dataIdRequired: "데이터 ID는 필수입니다",
allGroups: "모든 그룹",
noConfigs: "구성이 없습니다",
+7
View File
@@ -7399,6 +7399,13 @@ export default withEnglishFallback({
confirmRollbackMessage: "This will roll the current config back to the selected history version.",
rollbackSuccess: "Config rolled back",
publish: "Publicar",
validate: "Validar",
validationPassed: "A sintaxe da configuração é válida",
validationErrorTitle: "Erro de sintaxe da configuração",
validationErrorDescription: "Corrija os erros de sintaxe antes de publicar.",
validationLine: "Linha: {line}",
validationColumn: "Coluna: {column}",
validationLocate: "Localizar erro",
dataIdRequired: "Data ID é obrigatório",
allGroups: "Todos os grupos",
noConfigs: "Nenhuma configuração",
+7
View File
@@ -9002,6 +9002,13 @@ export default withEnglishFallback({
confirmRollbackMessage: "该操作会将当前配置回滚到所选历史版本。",
rollbackSuccess: "配置已回滚",
publish: "发布",
validate: "校验",
validationPassed: "配置内容校验通过",
validationErrorTitle: "配置内容语法有误",
validationErrorDescription: "请修正语法错误后再发布配置。",
validationLine: "行号:{line}",
validationColumn: "列号:{column}",
validationLocate: "定位错误",
dataIdRequired: "Data ID 不能为空",
allGroups: "全部分组",
noConfigs: "暂无配置",
+7
View File
@@ -6705,6 +6705,13 @@ export default withEnglishFallback({
confirmRollbackMessage: "該操作會將目前配置回滾到所選歷史版本。",
rollbackSuccess: "配置已回滾",
publish: "發布",
validate: "校驗",
validationPassed: "配置內容校驗通過",
validationErrorTitle: "配置內容語法錯誤",
validationErrorDescription: "請修正語法錯誤後再發布配置。",
validationLine: "行號:{line}",
validationColumn: "列號:{column}",
validationLocate: "定位錯誤",
dataIdRequired: "Data ID 不能為空",
allGroups: "全部分組",
noConfigs: "暫無配置",
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { validateNacosConfig, validateNacosConfigContent } from "@/lib/nacos/nacosConfigValidation";
describe("Nacos config validation", () => {
it("accepts valid structured formats", () => {
expect(validateNacosConfig('{"enabled":true}', "json")).toBeNull();
expect(validateNacosConfig("service:\n port: 8080", "yaml")).toBeNull();
expect(validateNacosConfig("<root><value>1</value></root>", "xml")).toBeNull();
expect(validateNacosConfig("<html><body>ok</body></html>", "html")).toBeNull();
expect(validateNacosConfig('<!doctype html><html><head><meta charset="utf-8"><title>DBX</title></head><body><ul><li>one<li>two</ul><br></body></html>', "html")).toBeNull();
expect(validateNacosConfig("server.port=8080", "properties")).toBeNull();
expect(validateNacosConfig("feature.enabled", "properties")).toBeNull();
expect(validateNacosConfig("key=\\\\u12xz", "properties")).toBeNull();
expect(validateNacosConfig("[server]\nport = 8080", "toml")).toBeNull();
expect(validateNacosConfig("ports = [\n 8000,\n 8001,\n]", "toml")).toBeNull();
});
it("reports JSON and YAML locations", () => {
const json = validateNacosConfig('{\n "Version": "1",\n "name": "zhangsan",\n}', "json");
expect(json?.message).toContain("Trailing comma");
expect(json?.line).toBe(3);
expect(json?.column).toBe(21);
expect(json?.from).toBe(40);
expect(json?.to).toBe(41);
const yaml = validateNacosConfig("service:\n\tport: 8080", "yaml");
expect(yaml?.message).toContain("spaces");
expect(yaml?.line).toBe(2);
expect(yaml?.column).toBe(1);
});
it("reports every recoverable JSON syntax error", () => {
const content = `{
"Version": "1",
"Statement": [
{
"Action": [
"pai:*"1
],
"Resource": "acs:paidsw:*:*:*",
"Effect": "Allow",
"Condition": {
"StringEquals": {
"pai:Accessibility": "PRIVATE",
"pai:EntityAccessType": "CREATOR"
}
}
},2
{
"Action": "next"
}
]
}`;
const diagnostics = validateNacosConfigContent(content, "json");
expect(diagnostics).toHaveLength(2);
expect(diagnostics[0]).toMatchObject({ line: 6, column: 16, from: content.indexOf("1\n") });
expect(diagnostics[1]).toMatchObject({ line: 16, column: 7, from: content.indexOf("},2") + 2 });
});
it("rejects malformed XML, HTML, Properties, and TOML", () => {
expect(validateNacosConfig("<root><child></root>", "xml")?.message).toContain("XML");
expect(validateNacosConfig("<div>", "html")?.message).toContain("unclosed tag");
expect(validateNacosConfig("<div><span></div>", "html")?.message).toContain("HTML");
expect(validateNacosConfig("<script>if (a < b) {}</script>", "html")).toBeNull();
expect(validateNacosConfig("key=\\u12xz", "properties")?.message).toContain("Unicode");
expect(validateNacosConfig("server = [", "toml")?.message).toContain("TOML");
expect(validateNacosConfig('title = "unterminated', "toml")?.message).toContain("TOML");
expect(validateNacosConfig('title = "invalid\\q"', "toml")?.message).toContain("TOML");
expect(validateNacosConfig("ports = [1 2]", "toml")?.message).toContain("TOML");
expect(validateNacosConfig("enabled = truth", "toml")?.message).toContain("TOML");
expect(validateNacosConfig("[server] trailing", "toml")?.message).toContain("TOML");
expect(validateNacosConfig('name = "dbx"\nname = "duplicate"', "toml")?.message).toContain("TOML");
});
});
@@ -0,0 +1,269 @@
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;
}