feat(nacos): add YAML diagnostics for config editor
This commit is contained in:
1 parent
d10216e302
commit
f3e5909a20
21 files changed
+886
-33
No files matched your search
@@ -3,6 +3,7 @@ import { useUpdateBlocker } from "@/lib/app/updatePreparation";
|
|||||||
import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, shallowRef, useId, watch } from "vue";
|
import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, shallowRef, useId, watch } from "vue";
|
||||||
import { Compartment, StateEffect, StateField } from "@codemirror/state";
|
import { Compartment, StateEffect, StateField } from "@codemirror/state";
|
||||||
import { ensureSyntaxTree } from "@codemirror/language";
|
import { ensureSyntaxTree } from "@codemirror/language";
|
||||||
|
import { setDiagnostics } from "@codemirror/lint";
|
||||||
import { Decoration, EditorView } from "@codemirror/view";
|
import { Decoration, EditorView } from "@codemirror/view";
|
||||||
import { Archive, ArrowLeftRight, CheckCircle2, ChevronDown, ChevronLeft, ChevronRight, Clipboard, Columns3, Download, ExternalLink, FileClock, FileInput, FileText, Loader2, Maximize2, Minimize2, Network, Plus, RefreshCw, Save, Search, Send, Server, Trash2, X } from "@lucide/vue";
|
import { Archive, ArrowLeftRight, CheckCircle2, ChevronDown, ChevronLeft, ChevronRight, Clipboard, Columns3, Download, ExternalLink, FileClock, FileInput, FileText, Loader2, Maximize2, Minimize2, Network, Plus, RefreshCw, Save, Search, Send, Server, Trash2, X } from "@lucide/vue";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -60,8 +61,10 @@ import { useTheme } from "@/composables/useTheme";
|
|||||||
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
|
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
|
||||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||||
import { validateNacosConfigContent, type NacosConfigDiagnostic } from "@/lib/nacos/nacosConfigValidation";
|
import { validateNacosConfigContent, nacosConfigDiagnosticSeverity, nacosConfigValidationBlocksPublish, type NacosConfigDiagnostic } from "@/lib/nacos/nacosConfigValidation";
|
||||||
import { loadNacosConfigLanguage, resolveNacosConfigFormat } from "@/lib/nacos/nacosConfigLanguage";
|
import { loadNacosConfigLanguage, resolveNacosConfigFormat } from "@/lib/nacos/nacosConfigLanguage";
|
||||||
|
import { nacosConfigYamlLintDiagnostics, nacosConfigYamlLintExtension } from "@/lib/nacos/nacosConfigYamlLint";
|
||||||
|
import { translateNacosYamlDiagnostic } from "@/lib/nacos/nacosYamlDiagnostics";
|
||||||
import type { NacosConfigEditorViewport } from "@/types/database";
|
import type { NacosConfigEditorViewport } from "@/types/database";
|
||||||
import type {
|
import type {
|
||||||
NacosBatchPreview,
|
NacosBatchPreview,
|
||||||
@@ -550,6 +553,8 @@ function currentCustomThemeColors() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function configValidationHighlightExtension() {
|
function configValidationHighlightExtension() {
|
||||||
|
// Formats without a native linter keep this decoration; YAML uses the linter
|
||||||
|
// installed by `nacosConfigYamlLintExtension` instead (#9405).
|
||||||
const decorationsFor = (state: import("@codemirror/state").EditorState, diagnostics: NacosConfigDiagnostic[]) => {
|
const decorationsFor = (state: import("@codemirror/state").EditorState, diagnostics: NacosConfigDiagnostic[]) => {
|
||||||
const ranges = diagnostics
|
const ranges = diagnostics
|
||||||
.filter(() => state.doc.length > 0)
|
.filter(() => state.doc.length > 0)
|
||||||
@@ -632,6 +637,7 @@ async function mountConfigEditor() {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
configEditorLanguage.of(language),
|
configEditorLanguage.of(language),
|
||||||
|
...(format === "yaml" ? [nacosConfigYamlLintExtension(configDiagnosticTranslate)] : []),
|
||||||
configValidationHighlight.of(configValidationHighlightExtension()),
|
configValidationHighlight.of(configValidationHighlightExtension()),
|
||||||
configEditorTheme.of(theme),
|
configEditorTheme.of(theme),
|
||||||
configEditorFontTheme.of(editorFontTheme(EditorView, editorSettings.fontSize, editorSettings.fontFamily, { fixedHeight: true, scrollable: true })),
|
configEditorFontTheme.of(editorFontTheme(EditorView, editorSettings.fontSize, editorSettings.fontFamily, { fixedHeight: true, scrollable: true })),
|
||||||
@@ -670,6 +676,9 @@ async function mountConfigEditor() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
configEditorView.value = view;
|
configEditorView.value = view;
|
||||||
|
// `linter()` only schedules a run after a document change, so a config opened
|
||||||
|
// with an existing error would look clean until the first keystroke.
|
||||||
|
if (format === "yaml") view.dispatch(setDiagnostics(view.state, nacosConfigYamlLintDiagnostics(view.state.doc.toString(), configDiagnosticTranslate)));
|
||||||
view.scrollDOM.addEventListener("scroll", scheduleConfigEditorViewportCapture, { passive: true });
|
view.scrollDOM.addEventListener("scroll", scheduleConfigEditorViewportCapture, { passive: true });
|
||||||
restoreConfigEditorViewport();
|
restoreConfigEditorViewport();
|
||||||
}
|
}
|
||||||
@@ -844,6 +853,24 @@ function clearConfigValidation(clearHighlight = true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when the open config is YAML, which shows diagnostics through CodeMirror's linter rather than the legacy decoration. */
|
||||||
|
const configUsesYamlLint = computed(() => resolveNacosConfigFormat(configType.value, configDataId.value) === "yaml");
|
||||||
|
|
||||||
|
const configValidationHasError = computed(() => configValidationDiagnostics.value.some((diagnostic) => nacosConfigDiagnosticSeverity(diagnostic) === "error"));
|
||||||
|
|
||||||
|
function configDiagnosticTranslate(key: string, params: Record<string, string>) {
|
||||||
|
return t(key, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** YAML diagnostics carry a code so the message follows the active locale; other formats keep their parser text. */
|
||||||
|
function configValidationMessage(diagnostic: NacosConfigDiagnostic): string {
|
||||||
|
return diagnostic.code ? translateNacosYamlDiagnostic({ code: diagnostic.code, message: diagnostic.message, params: diagnostic.params ?? {} }, configDiagnosticTranslate) : diagnostic.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configValidationSeverityLabel(diagnostic: NacosConfigDiagnostic): string {
|
||||||
|
return nacosConfigDiagnosticSeverity(diagnostic) === "error" ? t("nacos.validationSeverityError") : t("nacos.validationSeverityWarning");
|
||||||
|
}
|
||||||
|
|
||||||
function refreshConfigValidationHighlights(content: string, generation: number, editorSessionId: number) {
|
function refreshConfigValidationHighlights(content: string, generation: number, editorSessionId: number) {
|
||||||
if (!configValidationHighlightActive) return;
|
if (!configValidationHighlightActive) return;
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -862,11 +889,18 @@ function validateCurrentConfig(showSuccess = true): boolean {
|
|||||||
configValidationDiagnostics.value = diagnostics;
|
configValidationDiagnostics.value = diagnostics;
|
||||||
configValidationHighlightActive = false;
|
configValidationHighlightActive = false;
|
||||||
configEditorView.value?.dispatch({ effects: setConfigValidationHighlight.of([]) });
|
configEditorView.value?.dispatch({ effects: setConfigValidationHighlight.of([]) });
|
||||||
if (diagnostics.length) {
|
// Only errors block publishing: a parser warning must never trap a config the
|
||||||
|
// user is entitled to publish (#9405).
|
||||||
|
if (nacosConfigValidationBlocksPublish(diagnostics)) {
|
||||||
configValidationOpen.value = true;
|
configValidationOpen.value = true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (showSuccess) toast(t("nacos.validationPassed"), 2000);
|
if (!showSuccess) return true;
|
||||||
|
if (diagnostics.length) {
|
||||||
|
configValidationOpen.value = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
toast(t("nacos.validationPassed"), 2000);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -875,9 +909,11 @@ function focusConfigValidationDiagnostic() {
|
|||||||
const view = configEditorView.value;
|
const view = configEditorView.value;
|
||||||
if (!diagnostic || !view) return;
|
if (!diagnostic || !view) return;
|
||||||
configValidationOpen.value = false;
|
configValidationOpen.value = false;
|
||||||
configValidationHighlightActive = true;
|
// The YAML linter already marks every diagnostic, so only move the cursor;
|
||||||
|
// reusing the legacy decoration on top would double-render the same range.
|
||||||
|
configValidationHighlightActive = !configUsesYamlLint.value;
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
effects: setConfigValidationHighlight.of(configValidationDiagnostics.value),
|
effects: setConfigValidationHighlight.of(configUsesYamlLint.value ? [] : configValidationDiagnostics.value),
|
||||||
selection: { anchor: diagnostic.from },
|
selection: { anchor: diagnostic.from },
|
||||||
scrollIntoView: true,
|
scrollIntoView: true,
|
||||||
});
|
});
|
||||||
@@ -3366,13 +3402,16 @@ useUpdateBlocker(() =>
|
|||||||
<Dialog :open="configValidationOpen" @update:open="configValidationOpen = $event">
|
<Dialog :open="configValidationOpen" @update:open="configValidationOpen = $event">
|
||||||
<DialogContent class="sm:max-w-lg">
|
<DialogContent class="sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{{ t("nacos.validationErrorTitle") }}</DialogTitle>
|
<DialogTitle>{{ configValidationHasError ? t("nacos.validationErrorTitle") : t("nacos.validationWarningTitle") }}</DialogTitle>
|
||||||
<DialogDescription>{{ t("nacos.validationErrorDescription") }}</DialogDescription>
|
<DialogDescription>{{ configValidationHasError ? t("nacos.validationErrorDescription") : t("nacos.validationWarningDescription") }}</DialogDescription>
|
||||||
</DialogHeader>
|
</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-if="configValidationDiagnostics.length" class="max-h-[min(50vh,24rem)] overflow-y-auto rounded-md border text-sm" :class="configValidationHasError ? 'border-destructive/30 bg-destructive/5' : 'border-warning/30 bg-warning/5'">
|
||||||
<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 v-for="(diagnostic, index) in configValidationDiagnostics" :key="`${diagnostic.from}-${diagnostic.to}-${index}`" class="border-b p-4 last:border-b-0" :class="configValidationHasError ? 'border-destructive/15' : 'border-warning/15'">
|
||||||
<div class="min-w-0 space-y-2">
|
<div class="min-w-0 space-y-2">
|
||||||
<p class="break-words font-medium text-destructive">{{ diagnostic.message }}</p>
|
<p class="break-words font-medium" :class="nacosConfigDiagnosticSeverity(diagnostic) === 'error' ? 'text-destructive' : 'text-warning'">
|
||||||
|
<span class="me-1.5 text-xs uppercase tracking-wide">{{ configValidationSeverityLabel(diagnostic) }}</span
|
||||||
|
>{{ configValidationMessage(diagnostic) }}
|
||||||
|
</p>
|
||||||
<div class="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
<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.validationLine", { line: diagnostic.line }) }}</span>
|
||||||
<span>{{ t("nacos.validationColumn", { column: diagnostic.column }) }}</span>
|
<span>{{ t("nacos.validationColumn", { column: diagnostic.column }) }}</span>
|
||||||
@@ -3735,6 +3774,32 @@ useUpdateBlocker(() =>
|
|||||||
text-underline-offset: 3px;
|
text-underline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The YAML linter marks ranges with `@codemirror/lint`'s hardcoded palette;
|
||||||
|
override it so the underline follows the active light/dark theme the way the
|
||||||
|
legacy decoration above already does. */
|
||||||
|
.nacos-config-editor :deep(.cm-lintRange-error),
|
||||||
|
.nacos-config-editor :deep(.cm-lintRange-warning) {
|
||||||
|
background-image: none;
|
||||||
|
text-decoration: underline wavy;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nacos-config-editor :deep(.cm-lintRange-error) {
|
||||||
|
text-decoration-color: var(--destructive);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nacos-config-editor :deep(.cm-lintRange-warning) {
|
||||||
|
text-decoration-color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nacos-config-editor :deep(.cm-diagnostic-error) {
|
||||||
|
border-left-color: var(--destructive);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nacos-config-editor :deep(.cm-diagnostic-warning) {
|
||||||
|
border-left-color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
.nacos-config-workbench {
|
.nacos-config-workbench {
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
|
|||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import en from "@/i18n/locales/en";
|
||||||
|
|
||||||
|
const source = readFileSync(new URL("../NacosAdminConsole.vue", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lint itself is covered by real CodeMirror integration specs
|
||||||
|
* (`lib/__tests__/nacos/nacosConfigYamlLint.spec.ts` and
|
||||||
|
* `packages/app-tests/nacosYamlDiagnostics.test.ts`). These assertions pin the
|
||||||
|
* wiring that only exists inside this component, which is too large to mount.
|
||||||
|
*/
|
||||||
|
describe("NacosAdminConsole YAML diagnostics wiring", () => {
|
||||||
|
it("installs and seeds the linter only for YAML configs", () => {
|
||||||
|
expect(source).toContain('...(format === "yaml" ? [nacosConfigYamlLintExtension(configDiagnosticTranslate)] : []),');
|
||||||
|
expect(source).toContain('if (format === "yaml") view.dispatch(setDiagnostics(view.state, nacosConfigYamlLintDiagnostics(view.state.doc.toString(), configDiagnosticTranslate)));');
|
||||||
|
// A YAML-only extension: each helper has exactly one call site, inside the gated branch.
|
||||||
|
expect(source.match(/nacosConfigYamlLintExtension\(/g)).toHaveLength(1);
|
||||||
|
expect(source.match(/nacosConfigYamlLintDiagnostics\(/g)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the lint logic out of the component", () => {
|
||||||
|
// The component only formats and displays diagnostics; it never parses YAML itself.
|
||||||
|
expect(source).not.toContain("analyzeNacosYaml(");
|
||||||
|
expect(source).not.toContain("parseAllDocuments(");
|
||||||
|
expect(source).not.toContain("parseDocument(");
|
||||||
|
expect(source).toContain('import { setDiagnostics } from "@codemirror/lint";');
|
||||||
|
expect(source).toContain('import { nacosConfigYamlLintDiagnostics, nacosConfigYamlLintExtension } from "@/lib/nacos/nacosConfigYamlLint";');
|
||||||
|
expect(source).toContain('import { translateNacosYamlDiagnostic } from "@/lib/nacos/nacosYamlDiagnostics";');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks publishing on error severity only", () => {
|
||||||
|
expect(source).toContain("if (nacosConfigValidationBlocksPublish(diagnostics)) {");
|
||||||
|
expect(source).not.toContain('diagnostics.some((diagnostic) => nacosConfigDiagnosticSeverity(diagnostic) === "error")');
|
||||||
|
// `showSuccess` now also means "the user asked for validation", so a warnings-only
|
||||||
|
// run reports them instead of a success toast, and publishing is not blocked.
|
||||||
|
expect(source).toContain("if (!showSuccess) return true;");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not double-mark YAML diagnostics with the legacy decoration", () => {
|
||||||
|
expect(source).toContain('const configUsesYamlLint = computed(() => resolveNacosConfigFormat(configType.value, configDataId.value) === "yaml");');
|
||||||
|
expect(source).toContain("configValidationHighlightActive = !configUsesYamlLint.value;");
|
||||||
|
expect(source).toContain("effects: setConfigValidationHighlight.of(configUsesYamlLint.value ? [] : configValidationDiagnostics.value),");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports severity and localized messages in the validation dialog", () => {
|
||||||
|
expect(source).toContain('{{ configValidationHasError ? t("nacos.validationErrorTitle") : t("nacos.validationWarningTitle") }}');
|
||||||
|
expect(source).toContain('{{ configValidationHasError ? t("nacos.validationErrorDescription") : t("nacos.validationWarningDescription") }}');
|
||||||
|
// Asserted separately because the formatter may wrap the spans.
|
||||||
|
expect(source).toContain("{{ configValidationSeverityLabel(diagnostic) }}");
|
||||||
|
expect(source).toContain("{{ configValidationMessage(diagnostic) }}");
|
||||||
|
// The localized text is resolved through the shared helper, never a raw parser string.
|
||||||
|
expect(source).toContain("return diagnostic.code ? translateNacosYamlDiagnostic({ code: diagnostic.code, message: diagnostic.message, params: diagnostic.params ?? {} }, configDiagnosticTranslate) : diagnostic.message;");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("localizes every key it references, including the new severity labels", () => {
|
||||||
|
const referenced = [
|
||||||
|
"validationErrorTitle",
|
||||||
|
"validationErrorDescription",
|
||||||
|
"validationWarningTitle",
|
||||||
|
"validationWarningDescription",
|
||||||
|
"validationSeverityError",
|
||||||
|
"validationSeverityWarning",
|
||||||
|
"yamlDiagnosticDuplicateKey",
|
||||||
|
"yamlDiagnosticTabIndent",
|
||||||
|
"yamlDiagnosticSyntaxError",
|
||||||
|
"yamlDiagnosticParserWarning",
|
||||||
|
];
|
||||||
|
const nacos = en.nacos as Record<string, unknown>;
|
||||||
|
for (const key of referenced) {
|
||||||
|
expect(typeof nacos[key], key).toBe("string");
|
||||||
|
expect((nacos[key] as string).length, key).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds no watcher, listener, or observer for live diagnostics", () => {
|
||||||
|
// CodeMirror owns the debounce; a component-level watcher would re-parse on
|
||||||
|
// every keystroke and leak across config switches.
|
||||||
|
expect(source.match(/watch\(/g)).toHaveLength(6);
|
||||||
|
expect(source.match(/addEventListener\(/g)).toHaveLength(1);
|
||||||
|
expect(source.match(/removeEventListener\(/g)).toHaveLength(1);
|
||||||
|
expect(source.match(/onMounted\(/g)).toHaveLength(1);
|
||||||
|
expect(source.match(/new ResizeObserver/g)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still tears the editor view down when the config changes", () => {
|
||||||
|
expect(source).toContain("configEditorView.value?.destroy();");
|
||||||
|
expect(source).toContain("configEditorView.value = null;");
|
||||||
|
expect(source).toContain("await refreshConfigEditor();");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9796,6 +9796,14 @@ export default withEnglishFallback({
|
|||||||
validationLine: "Sətir: {line}",
|
validationLine: "Sətir: {line}",
|
||||||
validationColumn: "Sütun: {column}",
|
validationColumn: "Sütun: {column}",
|
||||||
validationLocate: "Xətanın yerini tap",
|
validationLocate: "Xətanın yerini tap",
|
||||||
|
yamlDiagnosticDuplicateKey: "Təkrarlanan xəritə açarı: {key}",
|
||||||
|
yamlDiagnosticTabIndent: "YAML boşluqlaması tab simvolundan istifadə etməməlidir; boşluq istifadə edin",
|
||||||
|
yamlDiagnosticSyntaxError: "YAML sintaksis xətası: {reason}",
|
||||||
|
yamlDiagnosticParserWarning: "YAML analiz xəbərdarlığı: {reason}",
|
||||||
|
validationWarningTitle: "Konfiqurasiyada xəbərdarlıqlar var",
|
||||||
|
validationWarningDescription: "Bu xəbərdarlıqlar dərc etməni bloklamır, lakin dərc etməzdən əvvəl yoxlayın.",
|
||||||
|
validationSeverityError: "Xəta",
|
||||||
|
validationSeverityWarning: "Xəbərdarlıq",
|
||||||
dataIdRequired: "Məlumat ID-si tələb olunur",
|
dataIdRequired: "Məlumat ID-si tələb olunur",
|
||||||
allGroups: "Bütün qruplar",
|
allGroups: "Bütün qruplar",
|
||||||
noConfigs: "Konfiqurasiya yoxdur",
|
noConfigs: "Konfiqurasiya yoxdur",
|
||||||
|
|||||||
@@ -10049,6 +10049,14 @@ export default {
|
|||||||
validationLine: "Line: {line}",
|
validationLine: "Line: {line}",
|
||||||
validationColumn: "Column: {column}",
|
validationColumn: "Column: {column}",
|
||||||
validationLocate: "Locate error",
|
validationLocate: "Locate error",
|
||||||
|
yamlDiagnosticDuplicateKey: "Duplicate mapping key: {key}",
|
||||||
|
yamlDiagnosticTabIndent: "YAML indentation must not use tab characters; use spaces instead",
|
||||||
|
yamlDiagnosticSyntaxError: "YAML syntax error: {reason}",
|
||||||
|
yamlDiagnosticParserWarning: "YAML parser warning: {reason}",
|
||||||
|
validationWarningTitle: "Configuration has warnings",
|
||||||
|
validationWarningDescription: "These warnings do not block publishing, but review them before publishing this configuration.",
|
||||||
|
validationSeverityError: "Error",
|
||||||
|
validationSeverityWarning: "Warning",
|
||||||
dataIdRequired: "Data ID is required",
|
dataIdRequired: "Data ID is required",
|
||||||
allGroups: "All groups",
|
allGroups: "All groups",
|
||||||
noConfigs: "No configs",
|
noConfigs: "No configs",
|
||||||
|
|||||||
@@ -8285,6 +8285,14 @@ export default withEnglishFallback({
|
|||||||
validationLine: "Línea: {line}",
|
validationLine: "Línea: {line}",
|
||||||
validationColumn: "Columna: {column}",
|
validationColumn: "Columna: {column}",
|
||||||
validationLocate: "Ir al error",
|
validationLocate: "Ir al error",
|
||||||
|
yamlDiagnosticDuplicateKey: "Clave de asignación duplicada: {key}",
|
||||||
|
yamlDiagnosticTabIndent: "La indentación YAML no debe usar tabulaciones; use espacios",
|
||||||
|
yamlDiagnosticSyntaxError: "Error de sintaxis YAML: {reason}",
|
||||||
|
yamlDiagnosticParserWarning: "Advertencia del analizador YAML: {reason}",
|
||||||
|
validationWarningTitle: "La configuración tiene advertencias",
|
||||||
|
validationWarningDescription: "Estas advertencias no bloquean la publicación, pero revíselas antes de publicar la configuración.",
|
||||||
|
validationSeverityError: "Error",
|
||||||
|
validationSeverityWarning: "Advertencia",
|
||||||
dataIdRequired: "Data ID es obligatorio",
|
dataIdRequired: "Data ID es obligatorio",
|
||||||
allGroups: "Todos los grupos",
|
allGroups: "Todos los grupos",
|
||||||
noConfigs: "Sin configuraciones",
|
noConfigs: "Sin configuraciones",
|
||||||
|
|||||||
@@ -8286,6 +8286,14 @@ export default withEnglishFallback({
|
|||||||
validationLine: "Riga: {line}",
|
validationLine: "Riga: {line}",
|
||||||
validationColumn: "Colonna: {column}",
|
validationColumn: "Colonna: {column}",
|
||||||
validationLocate: "Vai all'errore",
|
validationLocate: "Vai all'errore",
|
||||||
|
yamlDiagnosticDuplicateKey: "Chiave di mappatura duplicata: {key}",
|
||||||
|
yamlDiagnosticTabIndent: "L'indentazione YAML non deve usare tabulazioni; usa gli spazi",
|
||||||
|
yamlDiagnosticSyntaxError: "Errore di sintassi YAML: {reason}",
|
||||||
|
yamlDiagnosticParserWarning: "Avviso del parser YAML: {reason}",
|
||||||
|
validationWarningTitle: "La configurazione presenta avvisi",
|
||||||
|
validationWarningDescription: "Questi avvisi non bloccano la pubblicazione, ma verificali prima di pubblicare la configurazione.",
|
||||||
|
validationSeverityError: "Errore",
|
||||||
|
validationSeverityWarning: "Avviso",
|
||||||
dataIdRequired: "Data ID è obbligatorio",
|
dataIdRequired: "Data ID è obbligatorio",
|
||||||
allGroups: "Tutti i gruppi",
|
allGroups: "Tutti i gruppi",
|
||||||
noConfigs: "Nessuna configurazione",
|
noConfigs: "Nessuna configurazione",
|
||||||
|
|||||||
@@ -8325,6 +8325,14 @@ export default withEnglishFallback({
|
|||||||
validationLine: "行: {line}",
|
validationLine: "行: {line}",
|
||||||
validationColumn: "列: {column}",
|
validationColumn: "列: {column}",
|
||||||
validationLocate: "エラーに移動",
|
validationLocate: "エラーに移動",
|
||||||
|
yamlDiagnosticDuplicateKey: "マッピングキーが重複しています: {key}",
|
||||||
|
yamlDiagnosticTabIndent: "YAML のインデントにタブ文字は使用できません。スペースを使用してください",
|
||||||
|
yamlDiagnosticSyntaxError: "YAML 構文エラー: {reason}",
|
||||||
|
yamlDiagnosticParserWarning: "YAML 解析の警告: {reason}",
|
||||||
|
validationWarningTitle: "構成に警告があります",
|
||||||
|
validationWarningDescription: "これらの警告は公開をブロックしませんが、公開前に確認してください。",
|
||||||
|
validationSeverityError: "エラー",
|
||||||
|
validationSeverityWarning: "警告",
|
||||||
dataIdRequired: "Data ID は必須です",
|
dataIdRequired: "Data ID は必須です",
|
||||||
allGroups: "すべてのグループ",
|
allGroups: "すべてのグループ",
|
||||||
noConfigs: "設定がありません",
|
noConfigs: "設定がありません",
|
||||||
|
|||||||
@@ -9136,6 +9136,14 @@ export default withEnglishFallback({
|
|||||||
validationLine: "줄: {line}",
|
validationLine: "줄: {line}",
|
||||||
validationColumn: "열: {column}",
|
validationColumn: "열: {column}",
|
||||||
validationLocate: "오류 위치",
|
validationLocate: "오류 위치",
|
||||||
|
yamlDiagnosticDuplicateKey: "매핑 키가 중복되었습니다: {key}",
|
||||||
|
yamlDiagnosticTabIndent: "YAML 들여쓰기에는 탭 문자를 사용할 수 없습니다. 공백을 사용하세요",
|
||||||
|
yamlDiagnosticSyntaxError: "YAML 구문 오류: {reason}",
|
||||||
|
yamlDiagnosticParserWarning: "YAML 파싱 경고: {reason}",
|
||||||
|
validationWarningTitle: "구성에 경고가 있습니다",
|
||||||
|
validationWarningDescription: "이 경고는 게시를 차단하지 않지만 게시하기 전에 확인하세요.",
|
||||||
|
validationSeverityError: "오류",
|
||||||
|
validationSeverityWarning: "경고",
|
||||||
dataIdRequired: "데이터 ID는 필수입니다",
|
dataIdRequired: "데이터 ID는 필수입니다",
|
||||||
allGroups: "모든 그룹",
|
allGroups: "모든 그룹",
|
||||||
noConfigs: "구성이 없습니다",
|
noConfigs: "구성이 없습니다",
|
||||||
|
|||||||
@@ -8287,6 +8287,14 @@ export default withEnglishFallback({
|
|||||||
validationLine: "Linha: {line}",
|
validationLine: "Linha: {line}",
|
||||||
validationColumn: "Coluna: {column}",
|
validationColumn: "Coluna: {column}",
|
||||||
validationLocate: "Localizar erro",
|
validationLocate: "Localizar erro",
|
||||||
|
yamlDiagnosticDuplicateKey: "Chave de mapeamento duplicada: {key}",
|
||||||
|
yamlDiagnosticTabIndent: "A indentação YAML não deve usar tabulações; use espaços",
|
||||||
|
yamlDiagnosticSyntaxError: "Erro de sintaxe YAML: {reason}",
|
||||||
|
yamlDiagnosticParserWarning: "Aviso do analisador YAML: {reason}",
|
||||||
|
validationWarningTitle: "A configuração tem avisos",
|
||||||
|
validationWarningDescription: "Estes avisos não bloqueiam a publicação, mas revise-os antes de publicar a configuração.",
|
||||||
|
validationSeverityError: "Erro",
|
||||||
|
validationSeverityWarning: "Aviso",
|
||||||
dataIdRequired: "Data ID é obrigatório",
|
dataIdRequired: "Data ID é obrigatório",
|
||||||
allGroups: "Todos os grupos",
|
allGroups: "Todos os grupos",
|
||||||
noConfigs: "Nenhuma configuração",
|
noConfigs: "Nenhuma configuração",
|
||||||
|
|||||||
@@ -9662,6 +9662,14 @@ export default withEnglishFallback({
|
|||||||
validationLine: "Satır: {line}",
|
validationLine: "Satır: {line}",
|
||||||
validationColumn: "Sütun: {column}",
|
validationColumn: "Sütun: {column}",
|
||||||
validationLocate: "Hatayı bul",
|
validationLocate: "Hatayı bul",
|
||||||
|
yamlDiagnosticDuplicateKey: "Yinelenen eşleme anahtarı: {key}",
|
||||||
|
yamlDiagnosticTabIndent: "YAML girintisi sekme karakteri kullanamaz; boşluk kullanın",
|
||||||
|
yamlDiagnosticSyntaxError: "YAML sözdizimi hatası: {reason}",
|
||||||
|
yamlDiagnosticParserWarning: "YAML ayrıştırıcı uyarısı: {reason}",
|
||||||
|
validationWarningTitle: "Yapılandırmada uyarılar var",
|
||||||
|
validationWarningDescription: "Bu uyarılar yayımlamayı engellemez, ancak yayımlamadan önce gözden geçirin.",
|
||||||
|
validationSeverityError: "Hata",
|
||||||
|
validationSeverityWarning: "Uyarı",
|
||||||
dataIdRequired: "Data ID zorunludur",
|
dataIdRequired: "Data ID zorunludur",
|
||||||
allGroups: "Tüm gruplar",
|
allGroups: "Tüm gruplar",
|
||||||
noConfigs: "Yapılandırma yok",
|
noConfigs: "Yapılandırma yok",
|
||||||
|
|||||||
@@ -10019,6 +10019,14 @@ export default withEnglishFallback({
|
|||||||
validationLine: "行号:{line}",
|
validationLine: "行号:{line}",
|
||||||
validationColumn: "列号:{column}",
|
validationColumn: "列号:{column}",
|
||||||
validationLocate: "定位错误",
|
validationLocate: "定位错误",
|
||||||
|
yamlDiagnosticDuplicateKey: "重复的映射键:{key}",
|
||||||
|
yamlDiagnosticTabIndent: "YAML 缩进不能使用 Tab 字符,请改用空格",
|
||||||
|
yamlDiagnosticSyntaxError: "YAML 语法错误:{reason}",
|
||||||
|
yamlDiagnosticParserWarning: "YAML 解析警告:{reason}",
|
||||||
|
validationWarningTitle: "配置内容存在警告",
|
||||||
|
validationWarningDescription: "以下警告不会阻止发布,建议确认后再发布配置。",
|
||||||
|
validationSeverityError: "错误",
|
||||||
|
validationSeverityWarning: "警告",
|
||||||
dataIdRequired: "Data ID 不能为空",
|
dataIdRequired: "Data ID 不能为空",
|
||||||
allGroups: "全部分组",
|
allGroups: "全部分组",
|
||||||
noConfigs: "暂无配置",
|
noConfigs: "暂无配置",
|
||||||
|
|||||||
@@ -7603,6 +7603,14 @@ export default withEnglishFallback({
|
|||||||
validationLine: "行號:{line}",
|
validationLine: "行號:{line}",
|
||||||
validationColumn: "列號:{column}",
|
validationColumn: "列號:{column}",
|
||||||
validationLocate: "定位錯誤",
|
validationLocate: "定位錯誤",
|
||||||
|
yamlDiagnosticDuplicateKey: "重複的映射鍵:{key}",
|
||||||
|
yamlDiagnosticTabIndent: "YAML 縮排不能使用 Tab 字元,請改用空格",
|
||||||
|
yamlDiagnosticSyntaxError: "YAML 語法錯誤:{reason}",
|
||||||
|
yamlDiagnosticParserWarning: "YAML 解析警告:{reason}",
|
||||||
|
validationWarningTitle: "配置內容存在警告",
|
||||||
|
validationWarningDescription: "以下警告不會阻止發布,建議確認後再發布配置。",
|
||||||
|
validationSeverityError: "錯誤",
|
||||||
|
validationSeverityWarning: "警告",
|
||||||
dataIdRequired: "Data ID 不能為空",
|
dataIdRequired: "Data ID 不能為空",
|
||||||
allGroups: "全部分組",
|
allGroups: "全部分組",
|
||||||
noConfigs: "暫無配置",
|
noConfigs: "暫無配置",
|
||||||
|
|||||||
@@ -58,6 +58,28 @@ describe("Nacos config validation", () => {
|
|||||||
expect(diagnostics[1]).toMatchObject({ line: 16, column: 7, from: content.indexOf("},2") + 2 });
|
expect(diagnostics[1]).toMatchObject({ line: 16, column: 7, from: content.indexOf("},2") + 2 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("surfaces every YAML diagnostic with its severity, code, and params", () => {
|
||||||
|
const duplicateKeys = validateNacosConfigContent("server:\n port: 8080\n port: 9090\nserver:\n host: localhost\n", "yaml");
|
||||||
|
|
||||||
|
expect(duplicateKeys).toHaveLength(2);
|
||||||
|
expect(duplicateKeys.map((diagnostic) => diagnostic.code)).toEqual(["duplicateKey", "duplicateKey"]);
|
||||||
|
expect(duplicateKeys.map((diagnostic) => diagnostic.severity)).toEqual(["error", "error"]);
|
||||||
|
expect(duplicateKeys.map((diagnostic) => diagnostic.params?.key)).toEqual(["port", "server"]);
|
||||||
|
expect(duplicateKeys.map((diagnostic) => diagnostic.message)).toEqual(["Duplicate mapping key: port", "Duplicate mapping key: server"]);
|
||||||
|
// `validateNacosConfig` keeps its "first diagnostic or null" contract for existing callers.
|
||||||
|
expect(validateNacosConfig("server:\n port: 8080\n port: 9090\n", "yaml")).toStrictEqual(duplicateKeys[0]);
|
||||||
|
expect(validateNacosConfig("server:\n port: 8080\n port: 9090\n", "yaml")?.params?.key).toBe("port");
|
||||||
|
|
||||||
|
// A parser warning is surfaced but must not be an error, so it cannot block publishing.
|
||||||
|
const [warning] = validateNacosConfigContent("a: !unknownTag bar\n", "yaml");
|
||||||
|
expect(warning?.severity).toBe("warning");
|
||||||
|
expect(warning?.code).toBe("parserWarning");
|
||||||
|
expect(warning?.params?.reason).toBe("Unresolved tag: !unknownTag");
|
||||||
|
|
||||||
|
expect(validateNacosConfigContent("a: 1\n---\nb: 2\n", "yaml")).toEqual([]);
|
||||||
|
expect(validateNacosConfigContent("a: 1\n", "yml")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects malformed XML, HTML, Properties, and TOML", () => {
|
it("rejects malformed XML, HTML, Properties, and TOML", () => {
|
||||||
expect(validateNacosConfig("<root><child></root>", "xml")?.message).toContain("XML");
|
expect(validateNacosConfig("<root><child></root>", "xml")?.message).toContain("XML");
|
||||||
expect(validateNacosConfig("<div>", "html")?.message).toContain("unclosed tag");
|
expect(validateNacosConfig("<div>", "html")?.message).toContain("unclosed tag");
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { afterEach, test } from "vitest";
|
||||||
|
import { EditorState } from "@codemirror/state";
|
||||||
|
import { EditorView } from "@codemirror/view";
|
||||||
|
import { forEachDiagnostic, forceLinting, setDiagnostics } from "@codemirror/lint";
|
||||||
|
import { yamlLanguage } from "@codemirror/lang-yaml";
|
||||||
|
import { NACOS_YAML_LINT_DELAY_MS, nacosConfigYamlLintDiagnostics, nacosConfigYamlLintExtension } from "@/lib/nacos/nacosConfigYamlLint";
|
||||||
|
|
||||||
|
const passthroughTranslate = (key: string) => key;
|
||||||
|
|
||||||
|
let mounted: EditorView[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const view of mounted) view.destroy();
|
||||||
|
mounted = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Mirrors `NacosAdminConsole.mountConfigEditor`, including the initial marker seeding. */
|
||||||
|
function mount(doc: string, translate: (key: string, params: Record<string, string>) => string = passthroughTranslate) {
|
||||||
|
const parent = document.createElement("div");
|
||||||
|
document.body.append(parent);
|
||||||
|
const view = new EditorView({
|
||||||
|
parent,
|
||||||
|
state: EditorState.create({ doc, extensions: [yamlLanguage, nacosConfigYamlLintExtension(translate)] }),
|
||||||
|
});
|
||||||
|
view.dispatch(setDiagnostics(view.state, nacosConfigYamlLintDiagnostics(view.state.doc.toString(), translate)));
|
||||||
|
mounted.push(view);
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
function read(view: EditorView) {
|
||||||
|
const found: Array<{ severity: string; message: string; text: string }> = [];
|
||||||
|
forEachDiagnostic(view.state, (diagnostic, from, to) => found.push({ severity: diagnostic.severity, message: diagnostic.message, text: view.state.doc.sliceString(from, to) }));
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The linter only runs off its idle timer, so force it and let the single-result batch settle. */
|
||||||
|
async function relint(view: EditorView, doc: string) {
|
||||||
|
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: doc } });
|
||||||
|
forceLinting(view);
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a config opened with an existing error is marked before the first keystroke", () => {
|
||||||
|
// `linter()` alone would report nothing here, which is why the editor seeds the first set.
|
||||||
|
assert.deepEqual(read(mount("server:\n port: 8080\n port: 9090\n")), [{ severity: "error", message: "Duplicate mapping key: port", text: "port" }]);
|
||||||
|
assert.deepEqual(read(mount("server:\n port: 8080\n host: localhost\n")), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the linter debounces instead of parsing on every keystroke, then reports live", async () => {
|
||||||
|
const view = mount("a: 1\n");
|
||||||
|
|
||||||
|
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: "a: 1\na: 2\n" } });
|
||||||
|
// Nothing until the idle delay elapses: this is what keeps typing off the parser.
|
||||||
|
assert.deepEqual(read(view), []);
|
||||||
|
assert.equal(NACOS_YAML_LINT_DELAY_MS > 0, true);
|
||||||
|
|
||||||
|
forceLinting(view);
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.deepEqual(read(view), [{ severity: "error", message: "Duplicate mapping key: a", text: "a" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a diagnostic disappears once the error is fixed and reappears when reintroduced", async () => {
|
||||||
|
const view = mount("a: 1\na: 2\n");
|
||||||
|
assert.equal(read(view).length, 1);
|
||||||
|
|
||||||
|
await relint(view, "a: 1\nb: 2\n");
|
||||||
|
assert.deepEqual(read(view), []);
|
||||||
|
|
||||||
|
await relint(view, "a: 1\nb: 2\nb: 3\n");
|
||||||
|
assert.deepEqual(read(view), [{ severity: "error", message: "Duplicate mapping key: b", text: "b" }]);
|
||||||
|
|
||||||
|
await relint(view, "a: 1\n\tb: 2\n");
|
||||||
|
assert.deepEqual(read(view), [{ severity: "error", message: "YAML indentation must not use tab characters; use spaces instead", text: "\t" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("warning-severity diagnostics reach the editor without being rendered as errors", async () => {
|
||||||
|
const view = mount("a: 1\n");
|
||||||
|
await relint(view, "a: !unknownTag bar\n");
|
||||||
|
assert.deepEqual(read(view), [{ severity: "warning", message: "YAML parser warning: Unresolved tag: !unknownTag", text: "!unknownTag" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("messages are localized through the translator the host injects", async () => {
|
||||||
|
const view = mount("a: 1\na: 2\n", (key, params) => `${key}|${params.key ?? params.reason ?? ""}`);
|
||||||
|
assert.deepEqual(read(view), [{ severity: "error", message: "nacos.yamlDiagnosticDuplicateKey|a", text: "a" }]);
|
||||||
|
|
||||||
|
await relint(view, "a: 1\n\tb: 2\n");
|
||||||
|
assert.deepEqual(read(view), [{ severity: "error", message: "nacos.yamlDiagnosticTabIndent|", text: "\t" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switching configs leaves no diagnostics from the previous one behind", () => {
|
||||||
|
const broken = mount("server:\n port: 8080\n port: 9090\n");
|
||||||
|
assert.equal(read(broken).length, 1);
|
||||||
|
|
||||||
|
// The editor destroys the old view and builds a new state per config, so the new
|
||||||
|
// document must not inherit the previous config's markers.
|
||||||
|
const clean = mount("spring:\n application:\n name: dbx\n");
|
||||||
|
assert.deepEqual(read(clean), []);
|
||||||
|
|
||||||
|
const warning = mount("a: !unknownTag bar\n");
|
||||||
|
assert.deepEqual(
|
||||||
|
read(warning).map((diagnostic) => diagnostic.severity),
|
||||||
|
["warning"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
read(broken).map((diagnostic) => diagnostic.severity),
|
||||||
|
["error"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a destroyed view stops holding diagnostics", () => {
|
||||||
|
const view = mount("a: 1\na: 2\n");
|
||||||
|
assert.equal(read(view).length, 1);
|
||||||
|
view.destroy();
|
||||||
|
mounted = mounted.filter((candidate) => candidate !== view);
|
||||||
|
// Re-reading after destroy is inert rather than throwing, so teardown cannot leave stale state.
|
||||||
|
assert.equal(read(view).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-YAML content is never analyzed by the YAML linter", async () => {
|
||||||
|
// The editor only installs this extension for YAML, so the analyzer is asserted
|
||||||
|
// directly for the formats that must stay untouched.
|
||||||
|
const properties = "server.port=8080\nserver.port=9090\n";
|
||||||
|
const view = mount(properties);
|
||||||
|
assert.deepEqual(read(view), []);
|
||||||
|
await relint(view, properties);
|
||||||
|
assert.deepEqual(read(view), []);
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import { htmlLanguage } from "@codemirror/lang-html";
|
|||||||
import { jsonLanguage } from "@codemirror/lang-json";
|
import { jsonLanguage } from "@codemirror/lang-json";
|
||||||
import { XMLValidator } from "fast-xml-parser";
|
import { XMLValidator } from "fast-xml-parser";
|
||||||
import { parse as parseToml, TomlError } from "smol-toml";
|
import { parse as parseToml, TomlError } from "smol-toml";
|
||||||
import { parseDocument } from "yaml";
|
import { analyzeNacosYaml, type NacosYamlDiagnosticCode, type NacosYamlDiagnosticSeverity } from "@/lib/nacos/nacosYamlDiagnostics";
|
||||||
|
|
||||||
export type NacosConfigValidationFormat = "text" | "json" | "xml" | "yaml" | "html" | "properties" | "toml";
|
export type NacosConfigValidationFormat = "text" | "json" | "xml" | "yaml" | "html" | "properties" | "toml";
|
||||||
|
|
||||||
@@ -12,6 +12,25 @@ export interface NacosConfigDiagnostic {
|
|||||||
column: number;
|
column: number;
|
||||||
from: number;
|
from: number;
|
||||||
to: number;
|
to: number;
|
||||||
|
/** Diagnostics without a severity are errors, which is how every producer behaved before YAML gained warnings (#9405). */
|
||||||
|
severity?: NacosYamlDiagnosticSeverity;
|
||||||
|
/** Structured code for hosts that localize the message; only the YAML analyzer produces one. */
|
||||||
|
code?: NacosYamlDiagnosticCode;
|
||||||
|
/** Interpolation values for the localized message. */
|
||||||
|
params?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Severity of a diagnostic, defaulting to the error every non-YAML producer reports. */
|
||||||
|
export function nacosConfigDiagnosticSeverity(diagnostic: NacosConfigDiagnostic): NacosYamlDiagnosticSeverity {
|
||||||
|
return diagnostic.severity ?? "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a configuration must not be published. Only errors block: a parser
|
||||||
|
* warning is a note about content the user is still entitled to publish (#9405).
|
||||||
|
*/
|
||||||
|
export function nacosConfigValidationBlocksPublish(diagnostics: readonly NacosConfigDiagnostic[]): boolean {
|
||||||
|
return diagnostics.some((diagnostic) => nacosConfigDiagnosticSeverity(diagnostic) === "error");
|
||||||
}
|
}
|
||||||
|
|
||||||
function lineColumnAt(text: string, offset: number) {
|
function lineColumnAt(text: string, offset: number) {
|
||||||
@@ -197,24 +216,17 @@ function nextNonWhitespaceOffset(text: string, offset: number): number {
|
|||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateYaml(text: string): NacosConfigDiagnostic | null {
|
function validateYaml(text: string): NacosConfigDiagnostic[] {
|
||||||
const lines = text.split(/\r?\n/);
|
return analyzeNacosYaml(text).map((diagnostic) => ({
|
||||||
let offset = 0;
|
message: diagnostic.message,
|
||||||
for (const line of lines) {
|
line: diagnostic.line,
|
||||||
const indentation = /^[ \t]*/.exec(line)?.[0] ?? "";
|
column: diagnostic.column,
|
||||||
const tabIndex = indentation.indexOf("\t");
|
from: diagnostic.from,
|
||||||
if (tabIndex >= 0) {
|
to: diagnostic.to,
|
||||||
return diagnosticAt(text, offset + tabIndex, "YAML indentation must use spaces, not tabs");
|
severity: diagnostic.severity,
|
||||||
}
|
code: diagnostic.code,
|
||||||
offset += line.length + 1;
|
params: diagnostic.params,
|
||||||
}
|
}));
|
||||||
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 {
|
function validateXml(text: string): NacosConfigDiagnostic | null {
|
||||||
@@ -254,10 +266,10 @@ function validateToml(text: string): NacosConfigDiagnostic | null {
|
|||||||
export function validateNacosConfigContent(text: string, format: string): NacosConfigDiagnostic[] {
|
export function validateNacosConfigContent(text: string, format: string): NacosConfigDiagnostic[] {
|
||||||
const rawFormat = format.trim().toLowerCase();
|
const rawFormat = format.trim().toLowerCase();
|
||||||
const normalized = (rawFormat === "yml" ? "yaml" : rawFormat === "props" ? "properties" : rawFormat) as NacosConfigValidationFormat;
|
const normalized = (rawFormat === "yml" ? "yaml" : rawFormat === "props" ? "properties" : rawFormat) as NacosConfigValidationFormat;
|
||||||
let diagnostic: NacosConfigDiagnostic | null = null;
|
|
||||||
if (normalized === "json") return validateJson(text);
|
if (normalized === "json") return validateJson(text);
|
||||||
else if (normalized === "yaml") diagnostic = validateYaml(text);
|
if (normalized === "yaml") return validateYaml(text);
|
||||||
else if (normalized === "xml") diagnostic = validateXml(text);
|
let diagnostic: NacosConfigDiagnostic | null = null;
|
||||||
|
if (normalized === "xml") diagnostic = validateXml(text);
|
||||||
else if (normalized === "html") diagnostic = validateHtml(text);
|
else if (normalized === "html") diagnostic = validateHtml(text);
|
||||||
else if (normalized === "properties") diagnostic = validateProperties(text);
|
else if (normalized === "properties") diagnostic = validateProperties(text);
|
||||||
else if (normalized === "toml") diagnostic = validateToml(text);
|
else if (normalized === "toml") diagnostic = validateToml(text);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* CodeMirror lint wiring for Nacos YAML configs (#9405).
|
||||||
|
*
|
||||||
|
* Kept apart from `nacosYamlDiagnostics.ts` so the analysis stays a pure,
|
||||||
|
* dependency-free function that any host can unit test, while this module only
|
||||||
|
* adapts it to `@codemirror/lint`'s `Diagnostic` shape.
|
||||||
|
*
|
||||||
|
* The `delay` option is the debounce: CodeMirror parses once the editor has been
|
||||||
|
* idle for `NACOS_YAML_LINT_DELAY_MS` after the last change instead of on every
|
||||||
|
* keystroke, and only a fresh `EditorState` (a newly opened config) resets it.
|
||||||
|
*/
|
||||||
|
import { linter, type Diagnostic, type LintSource } from "@codemirror/lint";
|
||||||
|
import type { Extension } from "@codemirror/state";
|
||||||
|
import { analyzeNacosYaml, translateNacosYamlDiagnostic, type NacosYamlDiagnosticTranslator } from "@/lib/nacos/nacosYamlDiagnostics";
|
||||||
|
|
||||||
|
export const NACOS_YAML_LINT_DELAY_MS = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `Diagnostic`s for `content`, translated for the active locale.
|
||||||
|
*
|
||||||
|
* Exposed separately because `linter()` only schedules a run after a document
|
||||||
|
* change, so a config opened with a pre-existing error would look clean until
|
||||||
|
* the first keystroke. Hosts seed the initial markers with `setDiagnostics`
|
||||||
|
* using this same function, keeping one source of truth for the analysis.
|
||||||
|
*/
|
||||||
|
export function nacosConfigYamlLintDiagnostics(content: string, translate: NacosYamlDiagnosticTranslator): Diagnostic[] {
|
||||||
|
return analyzeNacosYaml(content).map((diagnostic) => ({
|
||||||
|
from: diagnostic.from,
|
||||||
|
to: diagnostic.to,
|
||||||
|
severity: diagnostic.severity,
|
||||||
|
message: translateNacosYamlDiagnostic(diagnostic, translate),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Native YAML diagnostics: wavy underline per problem plus the hover tooltip. */
|
||||||
|
export function nacosConfigYamlLintExtension(translate: NacosYamlDiagnosticTranslator): Extension {
|
||||||
|
const source: LintSource = (view): readonly Diagnostic[] => nacosConfigYamlLintDiagnostics(view.state.doc.toString(), translate);
|
||||||
|
return linter(source, { delay: NACOS_YAML_LINT_DELAY_MS });
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* YAML diagnostics for the Nacos configuration editor (#9405).
|
||||||
|
*
|
||||||
|
* The editor's YAML mode (`@codemirror/lang-yaml`, Lezer based) is an
|
||||||
|
* error-tolerant grammar: it parses tab indentation and duplicate mapping keys
|
||||||
|
* as valid structure, so it can never report the two problems this feature
|
||||||
|
* exists for. DBX already depends on the `yaml` package for Nacos validation,
|
||||||
|
* and that parser tracks mapping scope itself and reports both with exact
|
||||||
|
* offsets, so the diagnostics are derived from it and are only *rendered* by
|
||||||
|
* CodeMirror's native lint extension.
|
||||||
|
*
|
||||||
|
* `parseAllDocuments` is required instead of `parseDocument`: Nacos YAML configs
|
||||||
|
* commonly separate Spring Boot profile documents with `---`, which
|
||||||
|
* `parseDocument` rejects with a `MULTIPLE_DOCS` error even though the document
|
||||||
|
* is valid.
|
||||||
|
*/
|
||||||
|
import { parseAllDocuments, type YAMLError } from "yaml";
|
||||||
|
|
||||||
|
export type NacosYamlDiagnosticCode = "duplicateKey" | "tabIndent" | "syntaxError" | "parserWarning";
|
||||||
|
export type NacosYamlDiagnosticSeverity = "error" | "warning";
|
||||||
|
|
||||||
|
export interface NacosYamlDiagnostic {
|
||||||
|
code: NacosYamlDiagnosticCode;
|
||||||
|
severity: NacosYamlDiagnosticSeverity;
|
||||||
|
/** Start offset of the offending text in the document. */
|
||||||
|
from: number;
|
||||||
|
/** End offset, always greater than {@link from} so the marker has a width. */
|
||||||
|
to: number;
|
||||||
|
/** 1-based line, for hosts that list diagnostics instead of marking them. */
|
||||||
|
line: number;
|
||||||
|
/** 1-based column, for hosts that list diagnostics instead of marking them. */
|
||||||
|
column: number;
|
||||||
|
/** English fallback used when no translation is available. */
|
||||||
|
message: string;
|
||||||
|
/** Interpolation values for the translated message. */
|
||||||
|
params: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NacosYamlDiagnosticTranslator = (key: string, params: Record<string, string>) => string;
|
||||||
|
|
||||||
|
const MESSAGE_KEYS: Record<NacosYamlDiagnosticCode, string> = {
|
||||||
|
duplicateKey: "nacos.yamlDiagnosticDuplicateKey",
|
||||||
|
tabIndent: "nacos.yamlDiagnosticTabIndent",
|
||||||
|
syntaxError: "nacos.yamlDiagnosticSyntaxError",
|
||||||
|
parserWarning: "nacos.yamlDiagnosticParserWarning",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** i18n key for a diagnostic code, kept in one place so hosts cannot drift. */
|
||||||
|
export function nacosYamlDiagnosticMessageKey(code: NacosYamlDiagnosticCode): string {
|
||||||
|
return MESSAGE_KEYS[code];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Localize a diagnostic, falling back to its English message when the active
|
||||||
|
* locale has no entry (vue-i18n returns the key itself in that case).
|
||||||
|
*/
|
||||||
|
export function translateNacosYamlDiagnostic(diagnostic: Pick<NacosYamlDiagnostic, "code" | "message" | "params">, translate: NacosYamlDiagnosticTranslator): string {
|
||||||
|
const key = nacosYamlDiagnosticMessageKey(diagnostic.code);
|
||||||
|
const translated = translate(key, diagnostic.params);
|
||||||
|
return translated.length > 0 && translated !== key ? translated : diagnostic.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every problem `yaml` finds in `text`, in document order.
|
||||||
|
*
|
||||||
|
* Duplicate keys are `error`: the project's own parser rejects them
|
||||||
|
* (`DUPLICATE_KEY`), Spring Boot's `OriginTrackedYamlLoader` fails startup on
|
||||||
|
* them, and DBX already blocked publishing on them before this feature. Parser
|
||||||
|
* *warnings* stay `warning` and never block publishing.
|
||||||
|
*/
|
||||||
|
export function analyzeNacosYaml(text: string): NacosYamlDiagnostic[] {
|
||||||
|
if (!text.trim()) return [];
|
||||||
|
|
||||||
|
const diagnostics: NacosYamlDiagnostic[] = [];
|
||||||
|
for (const document of parseAllDocuments(text)) {
|
||||||
|
for (const error of document.errors) diagnostics.push(nacosYamlParserDiagnostic(text, error, "error"));
|
||||||
|
for (const warning of document.warnings) diagnostics.push(nacosYamlParserDiagnostic(text, warning, "warning"));
|
||||||
|
}
|
||||||
|
return dedupeNacosYamlDiagnostics(diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nacosYamlParserDiagnostic(text: string, error: YAMLError, severity: NacosYamlDiagnosticSeverity): NacosYamlDiagnostic {
|
||||||
|
const code = diagnosticCodeFor(error, severity);
|
||||||
|
const range = code === "duplicateKey" ? yamlKeyRange(text, error.pos[0], error.pos[1]) : clampRange(text, error.pos[0], error.pos[1]);
|
||||||
|
const { line, column } = yamlLineColumn(text, error);
|
||||||
|
const key = code === "duplicateKey" ? yamlKeyName(text.slice(range.from, range.to)) : "";
|
||||||
|
const reason = yamlMessageReason(error.message);
|
||||||
|
|
||||||
|
if (code === "duplicateKey") {
|
||||||
|
return { code, severity, ...range, line, column, message: yamlFallbackMessage(code, reason, key), params: { key } };
|
||||||
|
}
|
||||||
|
if (code === "tabIndent") {
|
||||||
|
return { code, severity, ...range, line, column, message: yamlFallbackMessage(code, reason, ""), params: {} };
|
||||||
|
}
|
||||||
|
return { code, severity, ...range, line, column, message: yamlFallbackMessage(code, reason, ""), params: { reason } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticCodeFor(error: YAMLError, severity: NacosYamlDiagnosticSeverity): NacosYamlDiagnosticCode {
|
||||||
|
if (error.code === "DUPLICATE_KEY") return "duplicateKey";
|
||||||
|
if (error.code === "TAB_AS_INDENT") return "tabIndent";
|
||||||
|
return severity === "warning" ? "parserWarning" : "syntaxError";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** English text for hosts that render `message` directly, mirroring the i18n keys. */
|
||||||
|
function yamlFallbackMessage(code: NacosYamlDiagnosticCode, reason: string, key: string): string {
|
||||||
|
if (code === "duplicateKey") return `Duplicate mapping key: ${key}`;
|
||||||
|
if (code === "tabIndent") return "YAML indentation must not use tab characters; use spaces instead";
|
||||||
|
if (code === "parserWarning") return `YAML parser warning: ${reason}`;
|
||||||
|
return `YAML syntax error: ${reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `yaml` appends ` at line N, column M:` plus a source excerpt and a caret to
|
||||||
|
* every message; both are redundant next to a positioned marker.
|
||||||
|
*/
|
||||||
|
function yamlMessageReason(message: string): string {
|
||||||
|
const firstLine = message.split("\n", 1)[0]?.trim() ?? "";
|
||||||
|
return firstLine.replace(/\s+at line \d+, column \d+:?\s*$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `DUPLICATE_KEY` points at the first character of the repeated key only, so the
|
||||||
|
* range is widened to the whole key. Keys are never widened past `:` or the end
|
||||||
|
* of the line, which keeps a wide marker from swallowing the value.
|
||||||
|
*/
|
||||||
|
function yamlKeyRange(text: string, start: number, parserEnd: number): { from: number; to: number } {
|
||||||
|
const quote = text[start];
|
||||||
|
if (quote === '"' || quote === "'") {
|
||||||
|
const closing = closingQuoteIndex(text, start, quote);
|
||||||
|
if (closing > start) return clampRange(text, start, closing + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let end = start;
|
||||||
|
while (end < text.length && text[end] !== ":" && text[end] !== "\n" && text[end] !== "\r") end += 1;
|
||||||
|
while (end > start && /\s/.test(text[end - 1] ?? "")) end -= 1;
|
||||||
|
return clampRange(text, start, Math.max(end, parserEnd));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closingQuoteIndex(text: string, start: number, quote: string): number {
|
||||||
|
for (let index = start + 1; index < text.length; index += 1) {
|
||||||
|
if (quote === '"' && text[index] === "\\") {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (text[index] === quote) return index;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function yamlKeyName(raw: string): string {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
const quoted = trimmed.length >= 2 && ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'")));
|
||||||
|
return quoted ? trimmed.slice(1, -1) : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampRange(text: string, from: number, to: number): { from: number; to: number } {
|
||||||
|
const start = Math.min(Math.max(from, 0), Math.max(0, text.length - 1));
|
||||||
|
const end = Math.min(Math.max(to, start + 1), text.length);
|
||||||
|
return { from: start, to: end };
|
||||||
|
}
|
||||||
|
|
||||||
|
function yamlLineColumn(text: string, error: YAMLError): { line: number; column: number } {
|
||||||
|
const position = error.linePos?.[0];
|
||||||
|
if (position && position.line >= 1 && position.col >= 1) return { line: position.line, column: position.col };
|
||||||
|
return offsetLineColumn(text, error.pos[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function offsetLineColumn(text: string, offset: number): { line: number; column: 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One broken construct is often reported more than once — a tab also raises
|
||||||
|
* `BLOCK_AS_IMPLICIT_KEY`, and `MULTILINE_IMPLICIT_KEY` spans the narrower
|
||||||
|
* `BLOCK_AS_IMPLICIT_KEY` range — so identical and contained ranges collapse
|
||||||
|
* into the widest diagnostic instead of stacking near-duplicate markers.
|
||||||
|
*/
|
||||||
|
function dedupeNacosYamlDiagnostics(diagnostics: readonly NacosYamlDiagnostic[]): NacosYamlDiagnostic[] {
|
||||||
|
const kept: NacosYamlDiagnostic[] = [];
|
||||||
|
for (const diagnostic of [...diagnostics].sort((left, right) => left.from - right.from || right.to - left.to)) {
|
||||||
|
const seen = kept.some((candidate) => diagnostic.from >= candidate.from && diagnostic.to <= candidate.to);
|
||||||
|
if (!seen) kept.push(diagnostic);
|
||||||
|
}
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ const host = process.env.TAURI_DEV_HOST;
|
|||||||
const isTauri = !!host || !!process.env.TAURI_ENV_ARCH;
|
const isTauri = !!host || !!process.env.TAURI_ENV_ARCH;
|
||||||
const configuredBasePath = process.env.VITE_DBX_BASE_PATH || process.env.DBX_PUBLIC_BASE_PATH;
|
const configuredBasePath = process.env.VITE_DBX_BASE_PATH || process.env.DBX_PUBLIC_BASE_PATH;
|
||||||
const manualChunks: Record<string, string[]> = {
|
const manualChunks: Record<string, string[]> = {
|
||||||
codemirror: ["codemirror", "@codemirror/lang-sql", "@codemirror/view", "@codemirror/state", "@codemirror/autocomplete", "@codemirror/commands", "@codemirror/theme-one-dark"],
|
codemirror: ["codemirror", "@codemirror/lang-sql", "@codemirror/view", "@codemirror/state", "@codemirror/autocomplete", "@codemirror/commands", "@codemirror/lint", "@codemirror/theme-one-dark"],
|
||||||
"vue-echarts": ["vue-echarts"],
|
"vue-echarts": ["vue-echarts"],
|
||||||
ui: ["reka-ui"],
|
ui: ["reka-ui"],
|
||||||
marked: ["marked"],
|
marked: ["marked"],
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
"@codemirror/lang-xml": "^6.1.0",
|
"@codemirror/lang-xml": "^6.1.0",
|
||||||
"@codemirror/lang-yaml": "^6.1.3",
|
"@codemirror/lang-yaml": "^6.1.3",
|
||||||
"@codemirror/language": "^6.12.3",
|
"@codemirror/language": "^6.12.3",
|
||||||
|
"@codemirror/lint": "^6.9.5",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
"@codemirror/search": "^6.7.0",
|
"@codemirror/search": "^6.7.0",
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
|
|||||||
+223
@@ -0,0 +1,223 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "vitest";
|
||||||
|
import { analyzeNacosYaml, nacosYamlDiagnosticMessageKey, translateNacosYamlDiagnostic, type NacosYamlDiagnostic } from "../../apps/desktop/src/lib/nacos/nacosYamlDiagnostics.ts";
|
||||||
|
import { nacosConfigDiagnosticSeverity, nacosConfigValidationBlocksPublish, validateNacosConfigContent } from "../../apps/desktop/src/lib/nacos/nacosConfigValidation.ts";
|
||||||
|
import en from "../../apps/desktop/src/i18n/locales/en.ts";
|
||||||
|
|
||||||
|
/** The exact text a marker underlines, so placement is asserted and not just line numbers. */
|
||||||
|
function underlined(source: string, diagnostic: NacosYamlDiagnostic): string {
|
||||||
|
return source.slice(diagnostic.from, diagnostic.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(source: string, diagnostics: readonly NacosYamlDiagnostic[]) {
|
||||||
|
return diagnostics.map((diagnostic) => ({ code: diagnostic.code, severity: diagnostic.severity, text: underlined(source, diagnostic), key: diagnostic.params.key }));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("valid YAML produces no diagnostics", () => {
|
||||||
|
for (const source of ["server:\n port: 8080\n host: localhost\n", "a: 1\n", "", " \n", "# only a comment\n", "a:\n", "null\n", "list:\n - 1\n - 2\n", "base: &anchor\n x: 1\nmerged:\n <<: *anchor\n"]) {
|
||||||
|
assert.deepEqual(analyzeNacosYaml(source), [], JSON.stringify(source));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the same key in different mapping scopes is not a duplicate", () => {
|
||||||
|
for (const source of ["a:\n port: 8080\nb:\n port: 9090\n", "a:\n port: 1\nb:\n c:\n port: 2\n", "- a: 1\n- a: 2\n", "a: {x: 1}\nb: {x: 2}\n"]) {
|
||||||
|
assert.deepEqual(analyzeNacosYaml(source), [], JSON.stringify(source));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a key repeated inside one mapping is an error that underlines the repeated key", () => {
|
||||||
|
const source = "server:\n port: 8080\n port: 9090\n";
|
||||||
|
const diagnostics = analyzeNacosYaml(source);
|
||||||
|
|
||||||
|
assert.equal(diagnostics.length, 1);
|
||||||
|
assert.deepEqual(summarize(source, diagnostics), [{ code: "duplicateKey", severity: "error", text: "port", key: "port" }]);
|
||||||
|
// `yaml` points at one character only, so the widened range must still start there and cover the key.
|
||||||
|
assert.deepEqual({ from: diagnostics[0]!.from, to: diagnostics[0]!.to, line: diagnostics[0]!.line, column: diagnostics[0]!.column }, { from: source.indexOf(" port: 9090") + 2, to: source.indexOf(" port: 9090") + 6, line: 3, column: 3 });
|
||||||
|
assert.equal(diagnostics[0]!.message, "Duplicate mapping key: port");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a key repeated at the document root is an error", () => {
|
||||||
|
const source = "server:\n port: 8080\nserver:\n host: localhost\n";
|
||||||
|
const diagnostics = analyzeNacosYaml(source);
|
||||||
|
|
||||||
|
assert.deepEqual(summarize(source, diagnostics), [{ code: "duplicateKey", severity: "error", text: "server", key: "server" }]);
|
||||||
|
assert.deepEqual({ line: diagnostics[0]!.line, column: diagnostics[0]!.column }, { line: 3, column: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("duplicate keys are found in flow mappings, quoted keys, and sequences of mappings", () => {
|
||||||
|
const flow = "a: {b: {c: 1, c: 2}}\n";
|
||||||
|
assert.deepEqual(summarize(flow, analyzeNacosYaml(flow)), [{ code: "duplicateKey", severity: "error", text: "c", key: "c" }]);
|
||||||
|
|
||||||
|
const quoted = "server:\n \"port\": 8080\n 'port': 9090\n";
|
||||||
|
// The duplicate (second) occurrence is the one marked, with its quotes stripped from the key name.
|
||||||
|
assert.deepEqual(summarize(quoted, analyzeNacosYaml(quoted)), [{ code: "duplicateKey", severity: "error", text: "'port'", key: "port" }]);
|
||||||
|
|
||||||
|
const sequence = "- a: 1\n a: 2\n";
|
||||||
|
assert.deepEqual(summarize(sequence, analyzeNacosYaml(sequence)), [{ code: "duplicateKey", severity: "error", text: "a", key: "a" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every duplicate key is reported, not only the first", () => {
|
||||||
|
const source = "a: 1\na: 2\nb: 3\nb: 4\n";
|
||||||
|
assert.deepEqual(summarize(source, analyzeNacosYaml(source)), [
|
||||||
|
{ code: "duplicateKey", severity: "error", text: "a", key: "a" },
|
||||||
|
{ code: "duplicateKey", severity: "error", text: "b", key: "b" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tab indentation is an error that underlines the offending tab", () => {
|
||||||
|
const source = "service:\n\tport: 8080\n";
|
||||||
|
const diagnostics = analyzeNacosYaml(source);
|
||||||
|
|
||||||
|
assert.equal(diagnostics.length, 1);
|
||||||
|
assert.equal(diagnostics[0]!.code, "tabIndent");
|
||||||
|
assert.equal(diagnostics[0]!.severity, "error");
|
||||||
|
assert.equal(underlined(source, diagnostics[0]!), "\t");
|
||||||
|
assert.deepEqual({ from: diagnostics[0]!.from, to: diagnostics[0]!.to, line: diagnostics[0]!.line, column: diagnostics[0]!.column }, { from: 9, to: 10, line: 2, column: 1 });
|
||||||
|
assert.equal(diagnostics[0]!.message, "YAML indentation must not use tab characters; use spaces instead");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tabs move anywhere except structural indentation are not reported", () => {
|
||||||
|
// A naive leading-whitespace scan flags every one of these; only real
|
||||||
|
// indentation tabs may be reported (#9405).
|
||||||
|
for (const source of ["a: |\n \tx\n", "a: |\n x\n \ty\n", "a: 'x\ty'\n", 'a: "x\ty"\n', "a: x\ty\n", "a:\t1\n", "a: [1,\t2]\n", "a: 1\t\n", "# c\na: 1\n\t# trailing comment line\n"]) {
|
||||||
|
assert.deepEqual(analyzeNacosYaml(source), [], JSON.stringify(source));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("indentation that does not line up is a syntax error", () => {
|
||||||
|
const source = "a:\n b: 1\n c: 2\n";
|
||||||
|
const diagnostics = analyzeNacosYaml(source);
|
||||||
|
|
||||||
|
assert.equal(diagnostics[0]!.code, "syntaxError");
|
||||||
|
assert.equal(diagnostics[0]!.severity, "error");
|
||||||
|
assert.match(diagnostics[0]!.message, /^YAML syntax error: All mapping items must start at the same column$/);
|
||||||
|
assert.equal(diagnostics[0]!.params.reason, "All mapping items must start at the same column");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multi-document YAML used by Spring Boot profiles stays valid", () => {
|
||||||
|
// `parseDocument` would report MULTIPLE_DOCS here and block publishing a valid config.
|
||||||
|
for (const source of ["a: 1\n---\nb: 2\n", "spring:\n profiles: dev\n---\nspring:\n profiles: prod\n", "---\na: 1\n", "%YAML 1.2\n---\na: 1\n"]) {
|
||||||
|
assert.deepEqual(analyzeNacosYaml(source), [], JSON.stringify(source));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("duplicate keys are still found in a later document", () => {
|
||||||
|
const source = "x: 1\n---\nb: 1\nb: 2\n";
|
||||||
|
assert.deepEqual(summarize(source, analyzeNacosYaml(source)), [{ code: "duplicateKey", severity: "error", text: "b", key: "b" }]);
|
||||||
|
assert.equal(analyzeNacosYaml(source)[0]!.line, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parser warnings are downgraded so they cannot block publishing", () => {
|
||||||
|
const source = "a: !unknownTag bar\n";
|
||||||
|
const diagnostics = analyzeNacosYaml(source);
|
||||||
|
|
||||||
|
assert.equal(diagnostics.length, 1);
|
||||||
|
assert.equal(diagnostics[0]!.code, "parserWarning");
|
||||||
|
assert.equal(diagnostics[0]!.severity, "warning");
|
||||||
|
assert.equal(diagnostics[0]!.params.reason, "Unresolved tag: !unknownTag");
|
||||||
|
assert.equal(nacosConfigValidationBlocksPublish(validateNacosConfigContent(source, "yaml")), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one broken construct collapses into the widest marker instead of stacking", () => {
|
||||||
|
const source = "a:\n\tb: 1\n c: 2\n";
|
||||||
|
// `yaml` raises TAB_AS_INDENT plus a narrower BLOCK_AS_IMPLICIT_KEY nested in a
|
||||||
|
// MULTILINE_IMPLICIT_KEY; the nested one must not add a second marker.
|
||||||
|
assert.deepEqual(summarize(source, analyzeNacosYaml(source)), [
|
||||||
|
{ code: "tabIndent", severity: "error", text: "\t", key: undefined },
|
||||||
|
{ code: "syntaxError", severity: "error", text: "1\n c", key: undefined },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every diagnostic carries a non-empty range inside the document", () => {
|
||||||
|
const broken = ["a:\n\tb: 1\n", "a: 1\na: 2\n", "a:\n b: 1\n c: 2\n", "a: [1, 2\n", 'a: "unterminated\n', "a: !tag x\n"];
|
||||||
|
for (const source of broken) {
|
||||||
|
for (const diagnostic of analyzeNacosYaml(source)) {
|
||||||
|
assert.ok(diagnostic.from >= 0 && diagnostic.from < source.length, JSON.stringify({ source, diagnostic }));
|
||||||
|
assert.ok(diagnostic.to > diagnostic.from && diagnostic.to <= source.length, JSON.stringify({ source, diagnostic }));
|
||||||
|
assert.ok(diagnostic.line >= 1 && diagnostic.column >= 1, JSON.stringify({ source, diagnostic }));
|
||||||
|
assert.ok(diagnostic.message.length > 0, JSON.stringify({ source, diagnostic }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diagnostics outside YAML formats never reach the linter", () => {
|
||||||
|
const duplicateKey = "a: 1\na: 2\n";
|
||||||
|
for (const format of ["text", "json", "properties", "xml", "html", "toml"]) {
|
||||||
|
assert.equal(
|
||||||
|
validateNacosConfigContent(duplicateKey, format).some((diagnostic) => diagnostic.code === "duplicateKey"),
|
||||||
|
false,
|
||||||
|
format,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
validateNacosConfigContent(duplicateKey, "yaml").some((diagnostic) => diagnostic.code === "duplicateKey"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
validateNacosConfigContent(duplicateKey, "yml").some((diagnostic) => diagnostic.code === "duplicateKey"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("only error severity blocks publishing", () => {
|
||||||
|
assert.equal(nacosConfigValidationBlocksPublish([]), false);
|
||||||
|
assert.equal(nacosConfigValidationBlocksPublish([{ message: "warn", line: 1, column: 1, from: 0, to: 1, severity: "warning" }]), false);
|
||||||
|
assert.equal(nacosConfigValidationBlocksPublish([{ message: "err", line: 1, column: 1, from: 0, to: 1 }]), true);
|
||||||
|
assert.equal(
|
||||||
|
nacosConfigValidationBlocksPublish([
|
||||||
|
{ message: "warn", line: 1, column: 1, from: 0, to: 1, severity: "warning" },
|
||||||
|
{ message: "err", line: 1, column: 1, from: 0, to: 1 },
|
||||||
|
]),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legacy diagnostics without a severity stay errors", () => {
|
||||||
|
assert.equal(nacosConfigDiagnosticSeverity({ message: "x", line: 1, column: 1, from: 0, to: 1 }), "error");
|
||||||
|
assert.equal(nacosConfigDiagnosticSeverity({ message: "x", line: 1, column: 1, from: 0, to: 1, severity: "warning" }), "warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diagnostic codes map to translation keys and localize with parameters", () => {
|
||||||
|
assert.equal(nacosYamlDiagnosticMessageKey("duplicateKey"), "nacos.yamlDiagnosticDuplicateKey");
|
||||||
|
assert.equal(nacosYamlDiagnosticMessageKey("tabIndent"), "nacos.yamlDiagnosticTabIndent");
|
||||||
|
assert.equal(nacosYamlDiagnosticMessageKey("syntaxError"), "nacos.yamlDiagnosticSyntaxError");
|
||||||
|
assert.equal(nacosYamlDiagnosticMessageKey("parserWarning"), "nacos.yamlDiagnosticParserWarning");
|
||||||
|
|
||||||
|
const duplicate = analyzeNacosYaml("a: 1\na: 2\n")[0]!;
|
||||||
|
const seen: Array<{ key: string; params: Record<string, string> }> = [];
|
||||||
|
assert.equal(
|
||||||
|
translateNacosYamlDiagnostic(duplicate, (key, params) => {
|
||||||
|
seen.push({ key, params });
|
||||||
|
return `已翻译:${params.key}`;
|
||||||
|
}),
|
||||||
|
"已翻译:a",
|
||||||
|
);
|
||||||
|
assert.deepEqual(seen, [{ key: "nacos.yamlDiagnosticDuplicateKey", params: { key: "a" } }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an untranslated code falls back to the English message", () => {
|
||||||
|
const duplicate = analyzeNacosYaml("a: 1\na: 2\n")[0]!;
|
||||||
|
// vue-i18n returns the key itself when the locale has no entry.
|
||||||
|
assert.equal(
|
||||||
|
translateNacosYamlDiagnostic(duplicate, (key) => key),
|
||||||
|
"Duplicate mapping key: a",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
translateNacosYamlDiagnostic(duplicate, () => ""),
|
||||||
|
"Duplicate mapping key: a",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the English fallbacks stay identical to the `en` locale entries", () => {
|
||||||
|
const translate = (key: string, params: Record<string, string>) => {
|
||||||
|
const template = key.split(".").reduce<unknown>((node, part) => (node as Record<string, unknown>)?.[part], en);
|
||||||
|
assert.equal(typeof template, "string", key);
|
||||||
|
return String(template).replace(/\{(\w+)\}/g, (_, name: string) => params[name] ?? "");
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const source of ["a: 1\na: 2\n", "a:\n\tb: 1\n", "a:\n b: 1\n c: 2\n", "a: !unknownTag bar\n"]) {
|
||||||
|
for (const diagnostic of analyzeNacosYaml(source)) {
|
||||||
|
assert.equal(translateNacosYamlDiagnostic(diagnostic, translate), diagnostic.message, JSON.stringify({ source, diagnostic }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Generated
+3
@@ -38,6 +38,9 @@ importers:
|
|||||||
'@codemirror/legacy-modes':
|
'@codemirror/legacy-modes':
|
||||||
specifier: ^6.5.3
|
specifier: ^6.5.3
|
||||||
version: 6.5.3
|
version: 6.5.3
|
||||||
|
'@codemirror/lint':
|
||||||
|
specifier: ^6.9.5
|
||||||
|
version: 6.9.5
|
||||||
'@codemirror/search':
|
'@codemirror/search':
|
||||||
specifier: ^6.7.0
|
specifier: ^6.7.0
|
||||||
version: 6.7.0
|
version: 6.7.0
|
||||||
|
|||||||
Reference in new issue
Block a user