From 5c49ef181ac60b84fb1a44c54ff49435eacc31a8 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Tue, 11 Aug 2026 08:34:33 +0200 Subject: [PATCH] fix(highlight): parse yaml diff fragments per indentation segment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hunk that starts deep inside a yaml document roots the parse at its first line's indent, so the first dedent below it (a sibling key from an outer level) turns the rest of the side into one ERROR node and every line after it renders plain. Split the side text into maximal runs that never dedent below their opening indent, parse each as its own document and shift the rows back. A full file is one segment — one parse, as before. The gotmpl path reuses it for its masked yaml pass. Signed-off-by: Daniil Antoshin --- src/background.ts | 40 ++++++++++++++++++++++++++++---- test/e2e/grammars.spec.ts | 19 +++++++++++++++ test/fixtures/yaml-fragment.diff | 13 +++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/yaml-fragment.diff diff --git a/src/background.ts b/src/background.ts index 3fe27c2..6f742c0 100644 --- a/src/background.ts +++ b/src/background.ts @@ -120,6 +120,10 @@ function highlight(langName: string, text: string) { return Promise.resolve({ rows: hljsRows(langName.slice(5), text) }); if (langName === "gotmpl") return highlightGotmpl(text); if (langName === "markdown") return highlightMarkdown(text); + if (langName === "yaml") + return init() + .then(() => loadLang("yaml")) + .then((yaml: Lang) => ({ rows: yamlFragmentRows(yaml, text) })); return init() .then(() => loadLang(langName)) .then(({ language, query }: Lang) => { @@ -134,6 +138,36 @@ function highlight(langName: string, text: string) { }); } +// yaml roots its document at the first line's indent, so a diff fragment that +// later dedents below it — any hunk that starts deep inside a document — turns +// the rest of the parse into one ERROR node with no captures. Parse each +// maximal run of lines that never dedents below its opening indent as its own +// document and shift the captured rows back into place; an ordinary full file +// is a single segment, i.e. exactly one parse like before. +function yamlFragmentRows(yaml: Lang, text: string): Rows { + const lines = text.split("\n"); + const starts: number[] = []; + let open = Infinity; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line.trim()) continue; + const indent = line.length - line.trimStart().length; + if (indent < open) { + starts.push(i); + open = indent; + } + } + const rows: Rows = {}; + for (let k = 0; k < starts.length; k++) { + const from = starts[k]; + const to = k + 1 < starts.length ? starts[k + 1] : lines.length; + const nested: Rows = {}; + captureInto(nested, yaml, lines.slice(from, to).join("\n")); + mergeRows(rows, nested, from, 0); + } + return rows; +} + // Helm/Go templates: highlight as yaml with the {{ … }} actions on top // (like nvim-treesitter's gotmpl setup). The yaml grammar chokes on inline // template syntax, so it parses a copy with every action blanked out — same @@ -146,11 +180,9 @@ function highlightGotmpl(text: string) { const lineStarts = lineStartsOf(text); const rows: Rows = {}; + // masking keeps every line length, so the fragment rows line up 1:1 const masked = text.replace(/\{\{[\s\S]*?\}\}/g, (m) => m.replace(/[^\n]/g, " ")); - const yamlTree = parseWith(yaml.language, masked); - for (const { name, node } of yaml.query.captures(yamlTree.rootNode).slice(0, MAX_CAPTURES)) - pushNode(rows, node, cssClass(name), lineStarts, text); - yamlTree.delete(); + mergeRows(rows, yamlFragmentRows(yaml, masked), 0, 0); const gtTree = parseWith(gt.language, text); for (const { name, node } of gt.query.captures(gtTree.rootNode).slice(0, MAX_CAPTURES)) diff --git a/test/e2e/grammars.spec.ts b/test/e2e/grammars.spec.ts index 065052f..e7d670e 100644 --- a/test/e2e/grammars.spec.ts +++ b/test/e2e/grammars.spec.ts @@ -54,3 +54,22 @@ test("added tree-sitter grammars and the markdown fallback produce tokens", asyn const md = page.locator("section.pt-file", { hasText: "NOTES.md" }); await expect(md.locator(".pt-keyword").first()).toBeVisible({ timeout: 20000 }); }); + +// a hunk from deep inside a yaml document dedents below its first line; the +// per-segment parse must still colour the keys after the dedent +test("a yaml hunk that dedents below its opening line still highlights", async ({ + context, + page, +}) => { + const body = readFileSync(path.join(__dirname, "../fixtures/yaml-fragment.diff"), "utf8"); + const url = "https://example.com/yaml-fragment.diff"; + await context.route(url, (route) => + route.fulfill({ contentType: "text/plain; charset=utf-8", body }) + ); + await page.goto(url); + const section = page.locator("section.pt-file", { hasText: "crds/machines.yaml" }); + await expect(section.locator(".pt-property", { hasText: "gpus" }).first()).toBeVisible({ + timeout: 20000, + }); + await expect(section.locator(".pt-property", { hasText: "gpuClassName" }).first()).toBeVisible(); +}); diff --git a/test/fixtures/yaml-fragment.diff b/test/fixtures/yaml-fragment.diff new file mode 100644 index 0000000..b6eba99 --- /dev/null +++ b/test/fixtures/yaml-fragment.diff @@ -0,0 +1,13 @@ +diff --git a/crds/machines.yaml b/crds/machines.yaml +--- a/crds/machines.yaml ++++ b/crds/machines.yaml +@@ -1067,3 +1067,9 @@ spec: + name: + description: | + The name of the device. ++ gpus: ++ type: array ++ items: ++ properties: ++ gpuClassName: ++ type: string