feat(nacos): add config syntax validation with error location
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "設定がありません",
|
||||
|
||||
@@ -8198,6 +8198,13 @@ export default withEnglishFallback({
|
||||
confirmRollbackMessage: "현재 구성을 선택한 기록 버전으로 롤백합니다.",
|
||||
rollbackSuccess: "구성을 롤백했습니다",
|
||||
publish: "게시",
|
||||
validate: "검증",
|
||||
validationPassed: "구성 구문이 올바릅니다",
|
||||
validationErrorTitle: "구성 구문 오류",
|
||||
validationErrorDescription: "게시하기 전에 구문 오류를 수정하세요.",
|
||||
validationLine: "줄: {line}",
|
||||
validationColumn: "열: {column}",
|
||||
validationLocate: "오류 위치",
|
||||
dataIdRequired: "데이터 ID는 필수입니다",
|
||||
allGroups: "모든 그룹",
|
||||
noConfigs: "구성이 없습니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -9002,6 +9002,13 @@ export default withEnglishFallback({
|
||||
confirmRollbackMessage: "该操作会将当前配置回滚到所选历史版本。",
|
||||
rollbackSuccess: "配置已回滚",
|
||||
publish: "发布",
|
||||
validate: "校验",
|
||||
validationPassed: "配置内容校验通过",
|
||||
validationErrorTitle: "配置内容语法有误",
|
||||
validationErrorDescription: "请修正语法错误后再发布配置。",
|
||||
validationLine: "行号:{line}",
|
||||
validationColumn: "列号:{column}",
|
||||
validationLocate: "定位错误",
|
||||
dataIdRequired: "Data ID 不能为空",
|
||||
allGroups: "全部分组",
|
||||
noConfigs: "暂无配置",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user