From f3e5909a20a9d628237dc96ef9176a048bb5865e Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Sat, 19 Sep 2026 08:52:39 +0800 Subject: [PATCH] feat(nacos): add YAML diagnostics for config editor --- .../components/nacos/NacosAdminConsole.vue | 85 ++++++- .../NacosAdminConsoleYamlDiagnostics.spec.ts | 91 +++++++ apps/desktop/src/i18n/locales/az.ts | 8 + apps/desktop/src/i18n/locales/en.ts | 8 + apps/desktop/src/i18n/locales/es.ts | 8 + apps/desktop/src/i18n/locales/it.ts | 8 + apps/desktop/src/i18n/locales/ja.ts | 8 + apps/desktop/src/i18n/locales/ko.ts | 8 + apps/desktop/src/i18n/locales/pt-BR.ts | 8 + apps/desktop/src/i18n/locales/tr.ts | 8 + apps/desktop/src/i18n/locales/zh-CN.ts | 8 + apps/desktop/src/i18n/locales/zh-TW.ts | 8 + .../nacos/nacosConfigValidation.spec.ts | 22 ++ .../nacos/nacosConfigYamlLint.spec.ts | 129 ++++++++++ .../src/lib/nacos/nacosConfigValidation.ts | 56 +++-- .../src/lib/nacos/nacosConfigYamlLint.ts | 39 +++ .../src/lib/nacos/nacosYamlDiagnostics.ts | 188 +++++++++++++++ apps/desktop/vite.config.ts | 2 +- package.json | 1 + .../app-tests/nacosYamlDiagnostics.test.ts | 223 ++++++++++++++++++ pnpm-lock.yaml | 3 + 21 files changed, 886 insertions(+), 33 deletions(-) create mode 100644 apps/desktop/src/components/nacos/__tests__/NacosAdminConsoleYamlDiagnostics.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/nacos/nacosConfigYamlLint.spec.ts create mode 100644 apps/desktop/src/lib/nacos/nacosConfigYamlLint.ts create mode 100644 apps/desktop/src/lib/nacos/nacosYamlDiagnostics.ts create mode 100644 packages/app-tests/nacosYamlDiagnostics.test.ts diff --git a/apps/desktop/src/components/nacos/NacosAdminConsole.vue b/apps/desktop/src/components/nacos/NacosAdminConsole.vue index 977e67405d..45ee40cbe7 100644 --- a/apps/desktop/src/components/nacos/NacosAdminConsole.vue +++ b/apps/desktop/src/components/nacos/NacosAdminConsole.vue @@ -3,6 +3,7 @@ import { useUpdateBlocker } from "@/lib/app/updatePreparation"; import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, shallowRef, useId, watch } from "vue"; import { Compartment, StateEffect, StateField } from "@codemirror/state"; import { ensureSyntaxTree } from "@codemirror/language"; +import { setDiagnostics } from "@codemirror/lint"; 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 { Button } from "@/components/ui/button"; @@ -60,8 +61,10 @@ 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 { validateNacosConfigContent, nacosConfigDiagnosticSeverity, nacosConfigValidationBlocksPublish, type NacosConfigDiagnostic } from "@/lib/nacos/nacosConfigValidation"; 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 { NacosBatchPreview, @@ -550,6 +553,8 @@ function currentCustomThemeColors() { } 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 ranges = diagnostics .filter(() => state.doc.length > 0) @@ -632,6 +637,7 @@ async function mountConfigEditor() { }, }), configEditorLanguage.of(language), + ...(format === "yaml" ? [nacosConfigYamlLintExtension(configDiagnosticTranslate)] : []), configValidationHighlight.of(configValidationHighlightExtension()), configEditorTheme.of(theme), configEditorFontTheme.of(editorFontTheme(EditorView, editorSettings.fontSize, editorSettings.fontFamily, { fixedHeight: true, scrollable: true })), @@ -670,6 +676,9 @@ async function mountConfigEditor() { return; } 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 }); 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) { + 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) { if (!configValidationHighlightActive) return; queueMicrotask(() => { @@ -862,11 +889,18 @@ function validateCurrentConfig(showSuccess = true): boolean { configValidationDiagnostics.value = diagnostics; configValidationHighlightActive = false; 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; 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; } @@ -875,9 +909,11 @@ function focusConfigValidationDiagnostic() { const view = configEditorView.value; if (!diagnostic || !view) return; 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({ - effects: setConfigValidationHighlight.of(configValidationDiagnostics.value), + effects: setConfigValidationHighlight.of(configUsesYamlLint.value ? [] : configValidationDiagnostics.value), selection: { anchor: diagnostic.from }, scrollIntoView: true, }); @@ -3366,13 +3402,16 @@ useUpdateBlocker(() => - {{ t("nacos.validationErrorTitle") }} - {{ t("nacos.validationErrorDescription") }} + {{ configValidationHasError ? t("nacos.validationErrorTitle") : t("nacos.validationWarningTitle") }} + {{ configValidationHasError ? t("nacos.validationErrorDescription") : t("nacos.validationWarningDescription") }} -
-
+
+
-

{{ diagnostic.message }}

+

+ {{ configValidationSeverityLabel(diagnostic) }}{{ configValidationMessage(diagnostic) }} +

{{ t("nacos.validationLine", { line: diagnostic.line }) }} {{ t("nacos.validationColumn", { column: diagnostic.column }) }} @@ -3735,6 +3774,32 @@ useUpdateBlocker(() => 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 { container-type: inline-size; } diff --git a/apps/desktop/src/components/nacos/__tests__/NacosAdminConsoleYamlDiagnostics.spec.ts b/apps/desktop/src/components/nacos/__tests__/NacosAdminConsoleYamlDiagnostics.spec.ts new file mode 100644 index 0000000000..503462fd6e --- /dev/null +++ b/apps/desktop/src/components/nacos/__tests__/NacosAdminConsoleYamlDiagnostics.spec.ts @@ -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; + 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();"); + }); +}); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 79aa2a0d26..4a230409e2 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -9796,6 +9796,14 @@ export default withEnglishFallback({ validationLine: "Sətir: {line}", validationColumn: "Sütun: {column}", 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", allGroups: "Bütün qruplar", noConfigs: "Konfiqurasiya yoxdur", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 317c7a1649..69b494730b 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -10049,6 +10049,14 @@ export default { validationLine: "Line: {line}", validationColumn: "Column: {column}", 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", allGroups: "All groups", noConfigs: "No configs", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 0a46219749..345cf8273b 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -8285,6 +8285,14 @@ export default withEnglishFallback({ validationLine: "Línea: {line}", validationColumn: "Columna: {column}", 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", allGroups: "Todos los grupos", noConfigs: "Sin configuraciones", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 65ee382d87..2613b6b896 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -8286,6 +8286,14 @@ export default withEnglishFallback({ validationLine: "Riga: {line}", validationColumn: "Colonna: {column}", 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", allGroups: "Tutti i gruppi", noConfigs: "Nessuna configurazione", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 760e4fe0a6..9eadf660e0 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -8325,6 +8325,14 @@ export default withEnglishFallback({ validationLine: "行: {line}", validationColumn: "列: {column}", validationLocate: "エラーに移動", + yamlDiagnosticDuplicateKey: "マッピングキーが重複しています: {key}", + yamlDiagnosticTabIndent: "YAML のインデントにタブ文字は使用できません。スペースを使用してください", + yamlDiagnosticSyntaxError: "YAML 構文エラー: {reason}", + yamlDiagnosticParserWarning: "YAML 解析の警告: {reason}", + validationWarningTitle: "構成に警告があります", + validationWarningDescription: "これらの警告は公開をブロックしませんが、公開前に確認してください。", + validationSeverityError: "エラー", + validationSeverityWarning: "警告", dataIdRequired: "Data ID は必須です", allGroups: "すべてのグループ", noConfigs: "設定がありません", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 604bc9846b..a5cd615a77 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -9136,6 +9136,14 @@ export default withEnglishFallback({ validationLine: "줄: {line}", validationColumn: "열: {column}", validationLocate: "오류 위치", + yamlDiagnosticDuplicateKey: "매핑 키가 중복되었습니다: {key}", + yamlDiagnosticTabIndent: "YAML 들여쓰기에는 탭 문자를 사용할 수 없습니다. 공백을 사용하세요", + yamlDiagnosticSyntaxError: "YAML 구문 오류: {reason}", + yamlDiagnosticParserWarning: "YAML 파싱 경고: {reason}", + validationWarningTitle: "구성에 경고가 있습니다", + validationWarningDescription: "이 경고는 게시를 차단하지 않지만 게시하기 전에 확인하세요.", + validationSeverityError: "오류", + validationSeverityWarning: "경고", dataIdRequired: "데이터 ID는 필수입니다", allGroups: "모든 그룹", noConfigs: "구성이 없습니다", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index b71b59d779..344b292525 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -8287,6 +8287,14 @@ export default withEnglishFallback({ validationLine: "Linha: {line}", validationColumn: "Coluna: {column}", 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", allGroups: "Todos os grupos", noConfigs: "Nenhuma configuração", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index caacc2cf0e..a58e5feb50 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -9662,6 +9662,14 @@ export default withEnglishFallback({ validationLine: "Satır: {line}", validationColumn: "Sütun: {column}", 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", allGroups: "Tüm gruplar", noConfigs: "Yapılandırma yok", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 181034f20c..26aa9cca51 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -10019,6 +10019,14 @@ export default withEnglishFallback({ validationLine: "行号:{line}", validationColumn: "列号:{column}", validationLocate: "定位错误", + yamlDiagnosticDuplicateKey: "重复的映射键:{key}", + yamlDiagnosticTabIndent: "YAML 缩进不能使用 Tab 字符,请改用空格", + yamlDiagnosticSyntaxError: "YAML 语法错误:{reason}", + yamlDiagnosticParserWarning: "YAML 解析警告:{reason}", + validationWarningTitle: "配置内容存在警告", + validationWarningDescription: "以下警告不会阻止发布,建议确认后再发布配置。", + validationSeverityError: "错误", + validationSeverityWarning: "警告", dataIdRequired: "Data ID 不能为空", allGroups: "全部分组", noConfigs: "暂无配置", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 5c6c1be443..7cb7fee365 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -7603,6 +7603,14 @@ export default withEnglishFallback({ validationLine: "行號:{line}", validationColumn: "列號:{column}", validationLocate: "定位錯誤", + yamlDiagnosticDuplicateKey: "重複的映射鍵:{key}", + yamlDiagnosticTabIndent: "YAML 縮排不能使用 Tab 字元,請改用空格", + yamlDiagnosticSyntaxError: "YAML 語法錯誤:{reason}", + yamlDiagnosticParserWarning: "YAML 解析警告:{reason}", + validationWarningTitle: "配置內容存在警告", + validationWarningDescription: "以下警告不會阻止發布,建議確認後再發布配置。", + validationSeverityError: "錯誤", + validationSeverityWarning: "警告", dataIdRequired: "Data ID 不能為空", allGroups: "全部分組", noConfigs: "暫無配置", diff --git a/apps/desktop/src/lib/__tests__/nacos/nacosConfigValidation.spec.ts b/apps/desktop/src/lib/__tests__/nacos/nacosConfigValidation.spec.ts index 5c643f6c00..c2d6f59b2f 100644 --- a/apps/desktop/src/lib/__tests__/nacos/nacosConfigValidation.spec.ts +++ b/apps/desktop/src/lib/__tests__/nacos/nacosConfigValidation.spec.ts @@ -58,6 +58,28 @@ describe("Nacos config validation", () => { 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", () => { expect(validateNacosConfig("", "xml")?.message).toContain("XML"); expect(validateNacosConfig("
", "html")?.message).toContain("unclosed tag"); diff --git a/apps/desktop/src/lib/__tests__/nacos/nacosConfigYamlLint.spec.ts b/apps/desktop/src/lib/__tests__/nacos/nacosConfigYamlLint.spec.ts new file mode 100644 index 0000000000..0d5e22f501 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/nacos/nacosConfigYamlLint.spec.ts @@ -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 = 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), []); +}); diff --git a/apps/desktop/src/lib/nacos/nacosConfigValidation.ts b/apps/desktop/src/lib/nacos/nacosConfigValidation.ts index 7f09517002..766a53d00f 100644 --- a/apps/desktop/src/lib/nacos/nacosConfigValidation.ts +++ b/apps/desktop/src/lib/nacos/nacosConfigValidation.ts @@ -2,7 +2,7 @@ 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"; +import { analyzeNacosYaml, type NacosYamlDiagnosticCode, type NacosYamlDiagnosticSeverity } from "@/lib/nacos/nacosYamlDiagnostics"; export type NacosConfigValidationFormat = "text" | "json" | "xml" | "yaml" | "html" | "properties" | "toml"; @@ -12,6 +12,25 @@ export interface NacosConfigDiagnostic { column: number; from: 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; +} + +/** 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) { @@ -197,24 +216,17 @@ function nextNonWhitespaceOffset(text: string, offset: number): number { 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 validateYaml(text: string): NacosConfigDiagnostic[] { + return analyzeNacosYaml(text).map((diagnostic) => ({ + message: diagnostic.message, + line: diagnostic.line, + column: diagnostic.column, + from: diagnostic.from, + to: diagnostic.to, + severity: diagnostic.severity, + code: diagnostic.code, + params: diagnostic.params, + })); } function validateXml(text: string): NacosConfigDiagnostic | null { @@ -254,10 +266,10 @@ function validateToml(text: string): NacosConfigDiagnostic | null { 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); + if (normalized === "yaml") return validateYaml(text); + let diagnostic: NacosConfigDiagnostic | null = null; + 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); diff --git a/apps/desktop/src/lib/nacos/nacosConfigYamlLint.ts b/apps/desktop/src/lib/nacos/nacosConfigYamlLint.ts new file mode 100644 index 0000000000..f2842e4d58 --- /dev/null +++ b/apps/desktop/src/lib/nacos/nacosConfigYamlLint.ts @@ -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 }); +} diff --git a/apps/desktop/src/lib/nacos/nacosYamlDiagnostics.ts b/apps/desktop/src/lib/nacos/nacosYamlDiagnostics.ts new file mode 100644 index 0000000000..d2b45de1b7 --- /dev/null +++ b/apps/desktop/src/lib/nacos/nacosYamlDiagnostics.ts @@ -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; +} + +export type NacosYamlDiagnosticTranslator = (key: string, params: Record) => string; + +const MESSAGE_KEYS: Record = { + 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, 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; +} diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f972a63533..9e5118e6ca 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -9,7 +9,7 @@ const host = process.env.TAURI_DEV_HOST; const isTauri = !!host || !!process.env.TAURI_ENV_ARCH; const configuredBasePath = process.env.VITE_DBX_BASE_PATH || process.env.DBX_PUBLIC_BASE_PATH; const manualChunks: Record = { - 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"], ui: ["reka-ui"], marked: ["marked"], diff --git a/package.json b/package.json index 2aa3782f22..d3615f9436 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-yaml": "^6.1.3", "@codemirror/language": "^6.12.3", + "@codemirror/lint": "^6.9.5", "@codemirror/legacy-modes": "^6.5.3", "@codemirror/search": "^6.7.0", "@codemirror/state": "^6.6.0", diff --git a/packages/app-tests/nacosYamlDiagnostics.test.ts b/packages/app-tests/nacosYamlDiagnostics.test.ts new file mode 100644 index 0000000000..83247f41a5 --- /dev/null +++ b/packages/app-tests/nacosYamlDiagnostics.test.ts @@ -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 }> = []; + 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) => { + const template = key.split(".").reduce((node, part) => (node as Record)?.[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 })); + } + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecc0871b46..5c8e9994bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@codemirror/legacy-modes': specifier: ^6.5.3 version: 6.5.3 + '@codemirror/lint': + specifier: ^6.9.5 + version: 6.9.5 '@codemirror/search': specifier: ^6.7.0 version: 6.7.0