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
@@ -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("<root><child></root>", "xml")?.message).toContain("XML");
|
||||
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 { 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<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) {
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user