diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index df0396a9..4ef87492 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -28,6 +28,19 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`. +## JetBrains IntelliJ Platform diff algorithm + +`src/features/vcs/comparison/**` is a TypeScript port of the text comparison of +[intellij-community](https://github.com/JetBrains/intellij-community) (Apache-2.0): +`platform/util/diff` (`com.intellij.util.diff`: Diff, Reindexer, MyersLCS, +PatienceIntLCS, UniqueLCS; `com.intellij.diff.comparison`: ByLineRt, ByWordRt, +ByCharRt, ChunkOptimizer, ChangeCorrector, LineFragmentSplitter, TrimUtil, +ComparisonUtil, iterables) and `platform/diff-impl` (ComparisonManagerImpl's +`compareLinesInner` and `processBlocks`). `__tests__/ideaCases.json` is converted +from its tests (`LineComparisonUtilTest`, `WordComparisonUtilTest`, +`SplitComparisonUtilTest`). Modified from the originals. Copyright 2000-2026 +JetBrains s.r.o. and contributors. Apache License 2.0 (below). + `src/ui/openidea/**` holds OpenIDEA's own file-type icons drawn in the expui style for languages whose JetBrains icons are not open source (Go, Rust, Ruby, Dart, Lua, Zig, Svelte, …). Those using the page-with-corner-badge layout (`goMod`, `goWork`, diff --git a/docs/plugins/themes.md b/docs/plugins/themes.md index ecd72301..33ce8c2f 100644 --- a/docs/plugins/themes.md +++ b/docs/plugins/themes.md @@ -295,7 +295,7 @@ a token. -219 themeable tokens: 113 in the UI theme file, 106 in the editor colour scheme file. +222 themeable tokens: 116 in the UI theme file, 106 in the editor colour scheme file. ### Surfaces (UI theme) @@ -374,6 +374,9 @@ a token. | `--gutter-modified` | `#375fad` | `#88adf7` | | `--gutter-added` | `#549159` | `#7fc784` | | `--gutter-deleted` | `#868a91` | `#767a8a` | +| `--diff-modified` | `#385570` | `#c2d8f2` | +| `--diff-inserted` | `#294436` | `#bee6be` | +| `--diff-deleted` | `#484a4a` | `#d6d6d6` | ### Syntax (editor scheme) diff --git a/e2e/diff.spec.ts b/e2e/diff.spec.ts new file mode 100644 index 00000000..9657d5a7 --- /dev/null +++ b/e2e/diff.spec.ts @@ -0,0 +1,336 @@ +/** + * The diff viewer over a real git repository (`diffRepo.ts`): each case opens + * HEAD against the working copy and checks the IDEA-style change types of + * lines and words, the difference count against `git diff`, and that the + * 10,000-line files open, scroll and navigate in every viewer mode. + */ +import { rmSync } from "node:fs"; +import { dirname } from "node:path"; +import type { Page } from "@playwright/test"; +import { gitHunks, readSides, tempDiffRepo } from "./diffRepo"; +import { expect, test } from "./fixtures"; + +type Kind = "modified" | "inserted" | "deleted"; + +interface Opts { + mode?: "split" | "unified"; + /** Padded panes (true), or panes joined by a curved divider (false, the default). */ + alignChanges?: boolean; + syncScroll?: boolean; + collapse?: boolean; + ignore?: "none" | "trim" | "whitespace" | "whitespaceAndLines"; +} + +let repo = ""; +test.beforeAll(() => { + repo = tempDiffRepo(); +}); +test.afterAll(() => rmSync(dirname(repo), { recursive: true, force: true })); + +const toolbar = (page: Page) => page.getByRole("toolbar", { name: "Diff" }); +const counter = (page: Page) => + toolbar(page) + .locator("span") + .filter({ hasText: /differen|identical/ }); + +/** Opens HEAD ↔ working tree of `path` and waits until the difference count shows; returns the time it took. */ +async function openCase(page: Page, path: string, opts: Opts = {}): Promise { + const { left, right } = readSides(repo, path); + const options = { + mode: "split", + ignore: "none", + highlight: "words", + collapse: false, + alignChanges: true, + syncScroll: true, + ...opts, + }; + const started = Date.now(); + await page.evaluate( + async ({ path, left, right, options }) => { + localStorage.setItem("openidea.vcs.diffOptions", JSON.stringify(options)); + const actions = "/src/platform/actions.ts"; + const { executeAction } = await import(/* @vite-ignore */ actions); + await executeAction("diff.compareTexts", { + path, + title: path, + left: { text: left, label: "HEAD" }, + right: { text: right, label: "Working tree" }, + }); + }, + { path, left, right, options }, + ); + await expect(counter(page)).toBeVisible({ timeout: 20_000 }); + const elapsed = Date.now() - started; + // Word highlights land a microtask after both panes exist. + await page.evaluate(() => new Promise(requestAnimationFrame)); + return elapsed; +} + +interface Side { + lines: Kind[]; + words: [string, Kind][]; +} + +/** Change types of the rendered lines and words of each pane (`a` / `b`; unified: `b` only). */ +async function rendered(page: Page): Promise<{ a: Side; b: Side; deletedChunks: number }> { + return page + .locator(".cm-mergeView, .cm-editor.cm-merge-b") + .first() + .evaluate((el) => { + const root = el.closest("[role=region]") ?? el; + const kind = (e: Element, prefix: string) => + (e.className.match(new RegExp(`${prefix}-(modified|inserted|deleted)`))?.[1] ?? null) as Kind | null; + const side = (cls: string) => { + const ed = root.querySelector(`.cm-editor.cm-merge-${cls}`); + const lines = [...(ed?.querySelectorAll(".cm-content > .cm-line") ?? [])] + .map((l) => kind(l, "cm-diffLine")) + .filter((k): k is Kind => !!k); + const words = [...(ed?.querySelectorAll(".cm-diffWord") ?? [])].map( + (w) => [w.textContent ?? "", kind(w, "cm-diffWord")!] as [string, Kind], + ); + return { lines, words }; + }; + return { a: side("a"), b: side("b"), deletedChunks: root.querySelectorAll(".cm-deletedChunk").length }; + }); +} + +const differences = (n: number) => (n === 1 ? "1 difference" : `${n} differences`); + +async function expectCount(page: Page, path: string, n = gitHunks(repo, path)) { + await expect(counter(page)).toHaveText(new RegExp(`^(${differences(n)}|\\d+ of ${n} differences)$`)); +} + +const all = (n: number, k: Kind) => Array(n).fill(k); + +async function shot(page: Page, name: string) { + const path = test.info().outputPath(`${name}.png`); + await page.getByRole("region").last().screenshot({ path, animations: "disabled" }); + await test.info().attach(name, { path, contentType: "image/png" }); +} + +test.describe("small cases (side-by-side, Highlight words)", () => { + const cases: { + path: string; + a: Kind[]; + b: Kind[]; + wordsA?: [string, Kind][]; + wordsB?: [string, Kind][]; + }[] = [ + { + path: "src/words.ts", + a: all(2, "modified"), + b: all(2, "modified"), + wordsA: [["true", "modified"]], + wordsB: [ + ["false", "modified"], + [" x,", "inserted"], + ], + }, + { path: "src/insert.ts", a: [], b: all(5, "inserted"), wordsA: [], wordsB: [] }, + { path: "src/delete.ts", a: all(4, "deleted"), b: [], wordsA: [], wordsB: [] }, + { + path: "docs/readme-zh.md", + a: ["modified"], + b: ["modified"], + wordsA: [["测试", "modified"]], + wordsB: [["示例", "modified"]], + }, + { + path: "docs/emoji.md", + a: ["modified"], + b: ["modified"], + wordsA: [["👋", "modified"]], + wordsB: [["🌍", "modified"]], + }, + { + path: "data/no-eol.txt", + a: ["modified"], + b: ["modified"], + wordsA: [["beta", "modified"]], + wordsB: [["gamma", "modified"]], + }, + { path: "src/indent.py", a: all(3, "modified"), b: all(3, "modified") }, + { path: "src/moved.ts", a: all(5, "deleted"), b: all(5, "inserted"), wordsA: [], wordsB: [] }, + { path: "src/new-file.ts", a: [], b: all(6, "inserted"), wordsA: [], wordsB: [] }, + { path: "src/deleted-file.ts", a: all(6, "deleted"), b: [], wordsA: [], wordsB: [] }, + ]; + for (const c of cases) + test(c.path, async ({ workbench: page }) => { + await openCase(page, c.path); + await expectCount(page, c.path); + const { a, b } = await rendered(page); + expect(a.lines, "left lines").toEqual(c.a); + expect(b.lines, "right lines").toEqual(c.b); + if (c.wordsA) expect(a.words, "left words").toEqual(c.wordsA); + if (c.wordsB) expect(b.words, "right words").toEqual(c.wordsB); + }); + + test("a renamed field with a comment added reads as one modified block", async ({ workbench: page }) => { + await openCase(page, "src/options.ts"); + await expectCount(page, "src/options.ts", 1); + const { a, b } = await rendered(page); + expect(a.lines).toEqual(["modified"]); + expect(b.lines).toEqual(all(6, "modified")); + expect(a.words).toEqual([["syncScroll", "modified"]]); + expect(b.words.every(([, k]) => k === "modified")).toBe(true); + const text = b.words.map(([t]) => t).join("\n"); + expect(text).toMatch(/^\/\*\*\n/); + expect(text).toMatch(/syncScrolling$/); + await shot(page, "options"); + }); + + test("an edit in the middle of a 44,000-character minified line", async ({ workbench: page }) => { + const path = "dist/app.min.js"; + await openCase(page, path); + await expectCount(page, path); + // Long lines render only around the horizontal viewport: scroll to the edit. + const { left } = readSides(repo, path); + const at = left.indexOf("f(1000,") / left.length; + for (const side of ["a", "b"]) + await page + .locator(`.cm-editor.cm-merge-${side} .cm-scroller`) + .evaluate((s, at) => (s.scrollLeft = at * s.scrollWidth - s.clientWidth / 2), at); + await expect.poll(async () => (await rendered(page)).a.words).toEqual([["f", "modified"]]); + const { a, b } = await rendered(page); + expect(a.lines).toEqual(["modified"]); + expect(b.lines).toEqual(["modified"]); + expect(b.words).toEqual([["g", "modified"]]); + await shot(page, "minified"); + }); + + test("a final newline added", async ({ workbench: page }) => { + await openCase(page, "data/add-eol.txt"); + await expectCount(page, "data/add-eol.txt"); + const { a, b } = await rendered(page); + // IDEA compares lines without their breaks: an empty line inserted, "beta" unchanged. + expect(a.lines).toEqual([]); + expect(b.lines).toEqual(["inserted"]); + await shot(page, "add-eol"); + }); + + test("ignoring whitespace hides an indentation-only change", async ({ workbench: page }) => { + await openCase(page, "src/indent.py", { ignore: "whitespace" }); + await expect(counter(page)).toHaveText("No differences"); + expect((await rendered(page)).b.lines).toEqual([]); + }); + + test("unified viewer shows old lines deleted and new lines inserted", async ({ workbench: page }) => { + await openCase(page, "src/options.ts", { mode: "unified" }); + await expectCount(page, "src/options.ts", 1); + const { b, deletedChunks } = await rendered(page); + expect(deletedChunks).toBe(1); + expect(b.lines).toEqual(all(6, "inserted")); + await shot(page, "options-unified"); + }); +}); + +test.describe("10,000-line files", () => { + test.describe.configure({ timeout: 60_000 }); + + const modes: [string, Opts][] = [ + ["padded", {}], + ["sync scrolling", { alignChanges: false }], + ["unified", { mode: "unified" }], + ["collapsed", { collapse: true }], + ]; + + /** Scrolls the diff to the end: the padded panes scroll together, the others from the right (or unified) pane. */ + async function scrollToEnd(page: Page) { + await page + .locator(".cm-editor.cm-merge-b") + .first() + .evaluate((ed) => { + const merge = ed.closest(".cm-mergeView"); + const s = + merge && merge.scrollHeight > merge.clientHeight ? merge : ed.querySelector(".cm-scroller")!; + s.scrollTop = s.scrollHeight; + }); + await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); + } + + /** Text of the first line fully shown in each pane. */ + async function topLines(page: Page) { + return page + .locator(".cm-mergeView") + .first() + .evaluate((mv) => { + const top = (side: string) => { + const ed = mv.querySelector(`.cm-editor.cm-merge-${side}`)!; + const r = mv.getBoundingClientRect(); + const s = ed.querySelector(".cm-scroller")!.getBoundingClientRect(); + const y = Math.max(r.top, s.top); + return [...ed.querySelectorAll(".cm-content > .cm-line")].find((l) => l.getBoundingClientRect().top >= y) + ?.textContent; + }; + return [top("a"), top("b")]; + }); + } + + for (const [mode, opts] of modes) { + test(`scattered edits, inserts and deletes (${mode})`, async ({ workbench: page }) => { + const path = "big/scattered.ts"; + const ms = await openCase(page, path, opts); + test.info().annotations.push({ type: "open", description: `${ms} ms` }); + expect(ms, "time to open").toBeLessThan(8_000); + await expectCount(page, path, 42); + // The first change is a single edited identifier. + await page.locator(".cm-editor.cm-merge-b .cm-content").first().click(); + await page.keyboard.press("Meta+ArrowUp"); + await page.keyboard.press("F7"); + await expect(counter(page)).toHaveText("1 of 42 differences"); + const first = await rendered(page); + if (opts.mode !== "unified") { + expect(first.a.words).toContainEqual(["compute", "modified"]); + expect(first.b.words).toContainEqual(["computeFast", "modified"]); + } + // The last difference is reachable and still highlighted. + await page.keyboard.press("Meta+ArrowDown"); + await page.keyboard.press("Shift+F7"); + await expect(counter(page)).toHaveText("42 of 42 differences"); + const last = await rendered(page); + if (opts.mode === "unified") expect(last.b.lines).toContain("inserted"); + else expect(last.b.words).toContainEqual(["computeFast", "modified"]); + await scrollToEnd(page); + if (opts.mode !== "unified") { + const [a, b] = await topLines(page); + expect(a, "panes line up at the end").toBe(b); + // Collapsed, the unchanged tail folds away. + if (!opts.collapse) expect(b).toMatch(/value99\d\d/); + } + await shot(page, `scattered-${mode.replace(" ", "-")}`); + }); + } + + test("every tenth line edited: 1,000 differences", async ({ workbench: page }) => { + const path = "big/every-tenth.ts"; + const ms = await openCase(page, path, { alignChanges: false }); + test.info().annotations.push({ type: "open", description: `${ms} ms` }); + expect(ms, "time to open").toBeLessThan(8_000); + await expectCount(page, path, 1_000); + await scrollToEnd(page); + const { a, b } = await rendered(page); + expect(b.words.length).toBeGreaterThan(0); + expect(b.words.every(([t, k]) => t === "entry" && k === "modified")).toBe(true); + expect(a.words.every(([t, k]) => t === "item" && k === "modified")).toBe(true); + await shot(page, "every-tenth"); + }); + + for (const [mode, opts] of modes.slice(0, 3)) + test(`every line edited: one 10,000-line block (${mode})`, async ({ workbench: page }) => { + const path = "big/rewrite.ts"; + const ms = await openCase(page, path, opts); + test.info().annotations.push({ type: "open", description: `${ms} ms` }); + expect(ms, "time to open").toBeLessThan(8_000); + await expectCount(page, path, 1); + await scrollToEnd(page); + const { b } = await rendered(page); + if (opts.mode === "unified") expect(b.lines.every((k) => k === "inserted")).toBe(true); + else { + expect(b.lines.length).toBeGreaterThan(10); + expect(b.lines.every((k) => k === "modified")).toBe(true); + expect(b.words).toContainEqual(["let", "modified"]); + } + await shot(page, `rewrite-${mode.replace(" ", "-")}`); + }); +}); diff --git a/e2e/diffRepo.ts b/e2e/diffRepo.ts new file mode 100644 index 00000000..3dc5db18 --- /dev/null +++ b/e2e/diffRepo.ts @@ -0,0 +1,189 @@ +/** + * A git repository of diff viewer cases: each file is committed, then changed + * in the working tree (or added / deleted), covering word edits, pure inserts + * and deletes, whitespace, CJK, missing final newlines, moves, a minified line + * and 10,000-line files. The e2e specs open HEAD against the working copy; run + * `node e2e/diffRepo.ts ` to build it for manual QA in the app. + */ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +export interface DiffCase { + path: string; + /** Committed content; null for a file added in the working tree. */ + before: string | null; + /** Working-tree content; null for a deleted file. */ + after: string | null; +} + +const lines = (n: number, line: (i: number) => string) => Array.from({ length: n }, (_, i) => line(i) + "\n").join(""); + +/** A 10,000-line source file; `edit(i)` may replace line `i` (or return null to drop it). */ +function bigFile(edit: (i: number, line: string) => string | string[] | null = (_, l) => l) { + const out: string[] = []; + for (let i = 0; i < 10_000; i++) { + const line = i % 50 === 0 ? `// section ${i / 50}` : `export const value${i} = compute(${i}, "item-${i}");`; + const e = edit(i, line); + if (e !== null) out.push(...(Array.isArray(e) ? e : [e])); + } + return out.map((l) => l + "\n").join(""); +} + +const OPTIONS_BEFORE = `export interface DiffViewOptions { + mode: DiffMode; + ignore: IgnorePolicy; + highlight: HighlightPolicy; + collapse: boolean; + syncScroll: boolean; +} +`; + +const OPTIONS_AFTER = `export interface DiffViewOptions { + mode: DiffMode; + ignore: IgnorePolicy; + highlight: HighlightPolicy; + collapse: boolean; + /** + * IDEA's synchronized scrolling of the side-by-side panes: unpadded panes + * that carry each other along, with a curved divider. Off, the panes are + * padded so unchanged lines line up and scroll together. + */ + syncScrolling: boolean; +} +`; + +const FUNCS = ["parse", "format", "validate", "render", "save"].map( + (name) => `function ${name}(input) {\n const result = ${name}Impl(input);\n return result;\n}\n`, +); + +const MINIFIED = Array.from({ length: 2_000 }, (_, i) => `var a${i}=f(${i},"k${i}");`).join(""); + +export const CASES: readonly DiffCase[] = [ + // A word edited and a comment added: one modified block (the reported case). + { path: "src/options.ts", before: OPTIONS_BEFORE, after: OPTIONS_AFTER }, + { + path: "src/words.ts", + before: "const flag = true;\ncall(a, b);\nconst keep = 1;\n", + after: "const flag = false;\ncall(a, x, b);\nconst keep = 1;\n", + }, + { + path: "src/insert.ts", + before: lines(10, (i) => `line ${i}`), + after: lines(10, (i) => `line ${i}`).replace("line 5\n", "line 5\n" + lines(5, (i) => `added ${i}`)), + }, + { + path: "src/delete.ts", + before: lines(12, (i) => `line ${i}`), + after: lines(12, (i) => `line ${i}`).replace( + lines(4, (i) => `line ${i + 4}`), + "", + ), + }, + { + path: "src/indent.py", + before: "def f():\n if x:\n return 1\n return 0\n", + after: "def f():\n if x:\n return 1\n return 0\n", + }, + { path: "docs/readme-zh.md", before: "# 说明\n\n这是一个测试文件。\n", after: "# 说明\n\n这是一个示例文件。\n" }, + { path: "docs/emoji.md", before: "Hello 👋 world\n", after: "Hello 🌍 world\n" }, + { path: "data/no-eol.txt", before: "alpha\nkeep\nbeta", after: "alpha\nkeep\ngamma" }, + { path: "data/add-eol.txt", before: "alpha\nbeta", after: "alpha\nbeta\n" }, + { path: "src/moved.ts", before: FUNCS.join("\n"), after: [...FUNCS.slice(1), FUNCS[0]!].join("\n") }, + { path: "src/new-file.ts", before: null, after: lines(6, (i) => `export const n${i} = ${i};`) }, + { path: "src/deleted-file.ts", before: lines(6, (i) => `export const d${i} = ${i};`), after: null }, + { + path: "dist/app.min.js", + before: MINIFIED + "\n", + after: MINIFIED.replace('f(1000,"k1000")', 'g(1000,"k1000")') + "\n", + }, + // 10,000 lines: 40 scattered word edits, a 200-line insert and a 100-line delete. + { + path: "big/scattered.ts", + before: bigFile(), + after: bigFile((i, l) => + i === 5_000 + ? [l, ...Array.from({ length: 200 }, (_, k) => `export const inserted${k} = ${k};`)] + : i >= 8_000 && i < 8_100 + ? null + : i % 250 === 125 + ? l.replace("compute(", "computeFast(") + : l, + ), + }, + // 10,000 lines, every tenth one edited: 1,000 differences. + { + path: "big/every-tenth.ts", + before: bigFile(), + after: bigFile((i, l) => (i % 10 === 5 ? l.replace('"item-', '"entry-') : l)), + }, + // 10,000 lines, every line edited: one 10,000-line modified block. + { + path: "big/rewrite.ts", + before: bigFile(), + after: bigFile((_, l) => l.replace("export const", "export let").replace("// section", "// part")), + }, +]; + +const git = (cwd: string, ...args: string[]) => + execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 << 20, stdio: ["ignore", "pipe", "pipe"] }); + +/** Builds the repository in `dir` (created, or emptied first). */ +export function buildDiffRepo(dir: string): string { + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir, { recursive: true }); + const write = (path: string, text: string) => { + mkdirSync(dirname(join(dir, path)), { recursive: true }); + writeFileSync(join(dir, path), text); + }; + git(dir, "init", "-q", "-b", "main"); + git(dir, "config", "user.email", "e2e@openidea.dev"); + git(dir, "config", "user.name", "e2e"); + git(dir, "config", "core.autocrlf", "false"); + for (const c of CASES) if (c.before !== null) write(c.path, c.before); + git(dir, "add", "-A"); + git(dir, "commit", "-qm", "initial"); + for (const c of CASES) { + if (c.after === null) rmSync(join(dir, c.path)); + else write(c.path, c.after); + } + // Intent-to-add, so `git diff HEAD` lists the new files too. + const added = CASES.filter((c) => c.before === null).map((c) => c.path); + if (added.length) git(dir, "add", "-N", "--", ...added); + return dir; +} + +export function tempDiffRepo(): string { + return buildDiffRepo(join(mkdtempSync(join(tmpdir(), "openidea-diff-")), "repo")); +} + +/** HEAD and working-tree texts of `path` ("" when missing on that side). */ +export function readSides(dir: string, path: string): { left: string; right: string } { + let left = ""; + try { + left = git(dir, "show", `HEAD:${path}`); + } catch { + // added in the working tree + } + let right = ""; + try { + right = readFileSync(join(dir, path), "utf8"); + } catch { + // deleted in the working tree + } + return { left, right }; +} + +/** Hunks of `git diff -U0` for `path`, the reference count of line differences. */ +export function gitHunks(dir: string, path: string): number { + const out = git(dir, "diff", "-U0", "--no-color", "--diff-algorithm=patience", "HEAD", "--", path); + return out.split("\n").filter((l) => l.startsWith("@@")).length; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const dir = resolve(process.argv[2] ?? "diff-repo"); + buildDiffRepo(dir); + console.log(`Built ${CASES.length} diff cases in ${dir}`); + console.log(git(dir, "status", "--short")); +} diff --git a/scripts/theme/intellij.ts b/scripts/theme/intellij.ts index 1fb828b9..1169a50a 100644 --- a/scripts/theme/intellij.ts +++ b/scripts/theme/intellij.ts @@ -111,6 +111,9 @@ export const ICLS_ATTRIBUTES: Record> = { ["FOREGROUND", "--fg-editor"], ["BACKGROUND", "--bg-editor"], ], + DIFF_MODIFIED: [["BACKGROUND", "--diff-modified"]], + DIFF_INSERTED: [["BACKGROUND", "--diff-inserted"]], + DIFF_DELETED: [["BACKGROUND", "--diff-deleted"]], DEFAULT_KEYWORD: [["FOREGROUND", "--syn-keyword"]], DEFAULT_STRING: [["FOREGROUND", "--syn-string"]], DEFAULT_NUMBER: [["FOREGROUND", "--syn-number"]], diff --git a/src/features/vcs/DiffEditor.module.css b/src/features/vcs/DiffEditor.module.css index 24b7b3a6..acf34543 100644 --- a/src/features/vcs/DiffEditor.module.css +++ b/src/features/vcs/DiffEditor.module.css @@ -179,7 +179,9 @@ } .noHighlight :global(.cm-changedLine), .noHighlight :global(.cm-insertedLine), -.noHighlight :global(.cm-changedText) { +.noHighlight :global(.cm-changedText), +.noHighlight :global(.cm-diffLine), +.noHighlight :global(.cm-diffChangedGutter) { background: none !important; } /* Side-by-side without alignment (IDEA's default): each pane scrolls itself. */ @@ -216,17 +218,17 @@ position: absolute; inset: 0; } -/* Opaque mixes of the line backgrounds (cm.ts), so bands and their midlines read as one shape. */ +/* The full colours of the change types (cm.ts), so bands and their midlines read as one shape. */ .host :global(.cm-diffBand-inserted) { - --band: color-mix(in srgb, var(--success) 26%, var(--bg-app)); + --band: var(--diff-inserted); } .host :global(.cm-diffBand-deleted) { - --band: color-mix(in srgb, var(--danger) 26%, var(--bg-app)); + --band: var(--diff-deleted); } .host :global(.cm-diffBand-modified) { - --band: color-mix(in srgb, var(--gutter-added) 20%, var(--bg-app)); + --band: var(--diff-modified); } -.host :global(.cm-diffBand:not(.cm-diffBand-modified)) { +.host :global(.cm-diffBand) { fill: var(--band); } .host :global(.cm-diffBandLine) { @@ -234,12 +236,6 @@ stroke: var(--band); stroke-width: 1px; } -.host :global(.cm-diffBandStopA) { - stop-color: color-mix(in srgb, var(--gutter-deleted) 20%, var(--bg-app)); -} -.host :global(.cm-diffBandStopB) { - stop-color: color-mix(in srgb, var(--gutter-added) 20%, var(--bg-app)); -} .host :global(.cm-diffDivider button.cm-diffArrow) { position: absolute; display: flex; @@ -266,16 +262,16 @@ } /* Where the other pane has lines this one lacks. */ .host :global(.cm-diffGap-inserted) { - box-shadow: inset 0 1px 0 var(--success); + box-shadow: inset 0 1px 0 var(--gutter-added); } .host :global(.cm-diffGap-deleted) { - box-shadow: inset 0 1px 0 var(--danger); + box-shadow: inset 0 1px 0 var(--gutter-deleted); } .host :global(.cm-diffGap-inserted.cm-diffGapEnd) { - box-shadow: inset 0 -1px 0 var(--success); + box-shadow: inset 0 -1px 0 var(--gutter-added); } .host :global(.cm-diffGap-deleted.cm-diffGapEnd) { - box-shadow: inset 0 -1px 0 var(--danger); + box-shadow: inset 0 -1px 0 var(--gutter-deleted); } /* Review comments under diff lines (pull requests). */ diff --git a/src/features/vcs/DiffEditor.tsx b/src/features/vcs/DiffEditor.tsx index a6eeefa2..a1ffdcd8 100644 --- a/src/features/vcs/DiffEditor.tsx +++ b/src/features/vcs/DiffEditor.tsx @@ -5,15 +5,7 @@ import clsx from "clsx"; import { EditorView, keymap } from "@codemirror/view"; import { Prec, Text, type Extension } from "@codemirror/state"; import { history, historyKeymap } from "@codemirror/commands"; -import { - MergeView, - getChunks, - goToNextChunk, - goToPreviousChunk, - rejectChunk, - unifiedMergeView, - Chunk, -} from "@codemirror/merge"; +import { MergeView, getChunks, goToNextChunk, goToPreviousChunk, unifiedMergeView, Chunk } from "@codemirror/merge"; import { attachDocument, embeddedEditorExtensions, type DocumentAttachment } from "@/editor/api"; import { setFindOpen } from "@/editor/core/cm/find"; import { FindPanel, searchAgain, seedFind, type FindTarget } from "@/editor/core/view/FindBar"; @@ -23,14 +15,14 @@ import { bindingsFor, normalizeBinding, shortcutLabel, strokeOf } from "@/platfo import { tx } from "@/platform/i18n"; import { IconButton, Select, Spinner, T, showContextMenu } from "@/ui"; import { editorService, type EditorInput } from "@/workbench/contributions"; -import { languageFor, viewerBase } from "./cm"; -import { applyChunk, arrowControls, chunkAt, hoverRevert } from "./diffControls"; +import { changedLineGutters, diffWords, languageFor, viewerBase } from "./cm"; +import { applyChunk, arrowControls, chunkAt, hoverRevert, revertUnifiedChunk } from "./diffControls"; import { HIGHLIGHT_OPTIONS, IGNORE_OPTIONS, + ideaDiffConfig, loadDiffOptions, saveDiffOptions, - whitespaceDiff, type DiffViewOptions, type HighlightPolicy, type IgnorePolicy, @@ -259,8 +251,7 @@ export function DiffEditor({ input }: { input: EditorInput }) { }, }); const collapseUnchanged = opts.collapse ? { margin: 3, minSize: 6 } : undefined; - const override = whitespaceDiff(opts.ignore); - const diffConfig = { override }; + const diffConfig = ideaDiffConfig(opts.ignore, opts.highlight); const highlightChanges = opts.highlight === "words"; const inclusion = includable ? inclusionGutter(path, textA) : []; const side = (att: DocumentAttachment | null, editable: boolean, notes?: "left" | "right"): Extension[] => [ @@ -289,11 +280,14 @@ export function DiffEditor({ input }: { input: EditorInput }) { ? { toA: editableA, toB: editableB, apply: (i, source) => merge && applyChunk(merge, i, source) } : undefined, }); + // IDEA's colouring: lines by chunk type, carried into the gutters, and typed word highlights. + const colours = [changedLineGutters, highlightChanges ? diffWords : []]; const mv = new MergeView({ a: { doc: textA, extensions: [ side(attached.a, editableA, "left"), + colours, panes?.extension ?? [], focusTracker("a"), findWatch("a"), @@ -304,6 +298,7 @@ export function DiffEditor({ input }: { input: EditorInput }) { doc: textB, extensions: [ side(attached.b, editableB, "right"), + colours, panes?.extension ?? [], listener, focusTracker("b"), @@ -319,9 +314,9 @@ export function DiffEditor({ input }: { input: EditorInput }) { !panes && (editableA || editableB) ? arrowControls(() => merge, { toA: editableA, toB: editableB }) : undefined, - highlightChanges, - // Unpadded panes tint the gutters of changed lines instead of a change bar. - gutter: !panes, + // Words are highlighted by type (`diffWords`), and gutters tinted instead of a change bar. + highlightChanges: false, + gutter: false, collapseUnchanged, diffConfig, }); @@ -353,13 +348,14 @@ export function DiffEditor({ input }: { input: EditorInput }) { focusTracker("b"), findWatch("b"), inclusion, + highlightChanges ? diffWords : [], unifiedMergeView({ original: textA, highlightChanges, gutter: true, syntaxHighlightDeletions: true, mergeControls: editableB - ? (type, action) => { + ? (type) => { const btn = document.createElement("button"); if (type === "accept") { btn.hidden = true; @@ -367,7 +363,10 @@ export function DiffEditor({ input }: { input: EditorInput }) { } btn.textContent = "Revert"; btn.title = "Revert this change"; - btn.onmousedown = action; + btn.onmousedown = (event) => { + event.preventDefault(); + revertUnifiedChunk(view, view.posAtDOM(btn)); + }; return btn; } : false, @@ -536,7 +535,7 @@ export function DiffEditor({ input }: { input: EditorInput }) { const cur = currentChunk(); if (!views || !cur) return; if (views.merge) applyChunk(views.merge, cur.index, "a"); - else rejectChunk(views.b, cur.chunk.fromB); + else revertUnifiedChunk(views.b, cur.chunk.fromB); }; const stageChunk = async () => { diff --git a/src/features/vcs/__tests__/diffOptions.test.ts b/src/features/vcs/__tests__/diffOptions.test.ts index dc1b0cf2..f89e1897 100644 --- a/src/features/vcs/__tests__/diffOptions.test.ts +++ b/src/features/vcs/__tests__/diffOptions.test.ts @@ -1,23 +1,24 @@ import { describe, expect, it } from "vitest"; import { Chunk, MergeView } from "@codemirror/merge"; -import { Text } from "@codemirror/state"; +import { ChangeSet, Text } from "@codemirror/state"; +import type { EditorView } from "@codemirror/view"; import { applyChunk, arrowControls } from "../diffControls"; import { DEFAULT_DIFF_OPTIONS, parseDiffOptions, + ideaChunks, + ideaDiffConfig, transferChunk, - whitespaceDiff, type IgnorePolicy, } from "../diffOptions"; import { clipboardDiffData, compareFilesData, parseTextsArg } from "../compare"; import { diffInput, diffTitle, sideFile, sideKey } from "../diffInput"; -import { changedLineGutters, viewerBase } from "../cm"; +import { changedLineGutters, chunkKind, diffWords, viewerBase } from "../cm"; const doc = (s: string) => Text.of(s.split("\n")); function chunks(a: string, b: string, ignore: IgnorePolicy) { - const override = whitespaceDiff(ignore); - return Chunk.build(doc(a), doc(b), { override }); + return ideaChunks(doc(a), doc(b), ignore, "words"); } describe("diff options", () => { @@ -124,47 +125,121 @@ describe("diff options", () => { } }); - it("decorates consecutive inserted and deleted lines as whole lines", () => { + it("colours lines by their chunk's type, as IDEA does", () => { const parent = document.createElement("div"); document.body.appendChild(parent); + const extensions = [viewerBase({ readOnly: true }), diffWords]; const mv = new MergeView({ - a: { doc: "before\nremoved one\nremoved two\nafter\n", extensions: viewerBase({ readOnly: true }) }, - b: { doc: "before\nafter\n", extensions: viewerBase({ readOnly: true }) }, + a: { doc: "keep\nremoved one\nremoved two\nmid\nvalue = old\nend\n", extensions }, + b: { doc: "keep\nmid\nvalue = new\nadded\nend\n", extensions }, parent, - diffConfig: { override: whitespaceDiff("none") }, + highlightChanges: false, + diffConfig: ideaDiffConfig("none", "words"), }); - expect(mv.a.dom.querySelectorAll(".cm-diffWholeLine")).toHaveLength(2); - expect(mv.b.dom.querySelectorAll(".cm-diffWholeLine")).toHaveLength(0); + const lines = (v: EditorView, kind: string) => + [...v.dom.querySelectorAll(`.cm-diffLine-${kind}`)].map((e) => e.textContent); + expect(lines(mv.a, "deleted")).toEqual(["removed one", "removed two"]); + // A block with lines on both sides is modified as a whole, on both sides. + expect(lines(mv.a, "modified")).toEqual(["value = old"]); + expect(lines(mv.b, "modified")).toEqual(["value = new", "added"]); + expect(lines(mv.b, "inserted")).toEqual([]); + expect(chunkKind(mv.chunks[1]!)).toBe("modified"); mv.destroy(); parent.remove(); }); - it("keeps word highlighting on paired lines of a multi-line replacement", () => { + it("highlights the words of a modified block by their own type", async () => { const parent = document.createElement("div"); document.body.appendChild(parent); + const extensions = [viewerBase({ readOnly: true }), diffWords]; const mv = new MergeView({ - a: { - doc: "before\nfrom old.first import X\nfrom old.second import Y\nafter\n", - extensions: viewerBase({ readOnly: true }), - }, - b: { - doc: "before\nfrom new.first import A\nfrom new.second import B\nadded line\nafter\n", - extensions: viewerBase({ readOnly: true }), - }, + a: { doc: "x\n syncScroll: boolean;\ny\n", extensions }, + b: { doc: "x\n /** Docs. */\n syncScrolling: boolean;\ny\n", extensions }, parent, - diffConfig: { override: whitespaceDiff("none") }, + highlightChanges: false, + diffConfig: ideaDiffConfig("none", "words"), }); - expect(mv.a.dom.querySelectorAll(".cm-changedLine")).toHaveLength(2); - expect(mv.a.dom.querySelector(".cm-diffWholeLine")).toBeNull(); - expect([...mv.a.dom.querySelectorAll(".cm-changedText")].map((e) => e.textContent)).toEqual([ - "old", - "X", - "old", - "Y", + // The left pane highlights its words once the right one exists. + await Promise.resolve(); + const words = (v: EditorView, kind: string) => + [...v.dom.querySelectorAll(`.cm-diffWord.cm-diffWord-${kind}`)].map((e) => e.textContent); + // The edited identifier is highlighted whole, joined by the comment added before it. + expect(words(mv.a, "modified")).toEqual(["syncScroll"]); + expect(words(mv.b, "modified").join("")).toBe("/** Docs. */ syncScrolling"); + expect(mv.a.dom.classList.contains("cm-diffWords")).toBe(true); + mv.destroy(); + parent.remove(); + }); + + it("marks inserted and deleted words inside a modified line", async () => { + const parent = document.createElement("div"); + document.body.appendChild(parent); + const extensions = [viewerBase({ readOnly: true }), diffWords]; + const mv = new MergeView({ + a: { doc: "call(first, extra, last)\n", extensions }, + b: { doc: "call(first, last, more)\n", extensions }, + parent, + highlightChanges: false, + diffConfig: ideaDiffConfig("none", "words"), + }); + // The left pane highlights its words once the right one exists. + await Promise.resolve(); + const words = (v: EditorView, kind: string) => + [...v.dom.querySelectorAll(`.cm-diffWord.cm-diffWord-${kind}`)].map((e) => e.textContent); + expect(words(mv.a, "deleted").join("")).toContain("extra"); + expect(words(mv.b, "inserted").join("")).toContain("more"); + expect(words(mv.a, "inserted")).toEqual([]); + expect(words(mv.b, "deleted")).toEqual([]); + mv.destroy(); + parent.remove(); + }); + + it("diffs changed blocks by words (ByWord)", () => { + const pairs = (a: string, b: string) => + chunks(a, b, "none").flatMap((ch) => + ch.changes.map((c) => [ + a.slice(ch.fromA + c.fromA, ch.fromA + c.toA), + b.slice(ch.fromB + c.fromB, ch.fromB + c.toB), + ]), + ); + // An edited identifier is changed whole. + expect(pairs(" syncScroll: boolean;", " syncScrolling: boolean;")).toEqual([["syncScroll", "syncScrolling"]]); + // Punctuation is matched between matched words only: two changed words around ":" are one fragment. + expect(pairs(" syncScroll: true,", " syncScrolling: false,")).toEqual([ + ["syncScroll: true", "syncScrolling: false"], ]); - expect(mv.b.dom.querySelectorAll(".cm-changedLine")).toHaveLength(3); - expect([...mv.b.dom.querySelectorAll(".cm-diffWholeLine")].map((e) => e.textContent)).toEqual(["added line"]); - expect(mv.b.dom.querySelectorAll(".cm-changedLine:not(.cm-diffWholeLine) .cm-changedText")).toHaveLength(4); + expect(pairs("f(a, b)", "f(a, x, b)")).toEqual([["", " x,"]]); + // Each character of a script written without spaces is a word. + expect(pairs("你好世界", "你们世界")).toEqual([["好", "们"]]); + expect(pairs("a b", "a b")).toEqual([[" ", ""]]); + // A 10,000-line block edited on every line still pairs up its words. + const big = (kw: string) => + Array.from({ length: 10_000 }, (_, i) => `export ${kw} value${i} = compute(${i});\n`).join(""); + const changes = pairs(big("const"), big("let")); + expect(changes).toHaveLength(10_000); + expect(changes.every(([a, b]) => a === "const" && b === "let")).toBe(true); + }); + + it("keeps a line with only words added modified on both sides", async () => { + const parent = document.createElement("div"); + document.body.appendChild(parent); + const extensions = [viewerBase({ readOnly: true }), diffWords]; + const mv = new MergeView({ + a: { doc: "f() {\n return a;\n}\n", extensions }, + b: { doc: "f() {\n return a + b;\n}\n", extensions }, + parent, + highlightChanges: false, + diffConfig: ideaDiffConfig("none", "words"), + }); + await Promise.resolve(); + expect([...mv.a.dom.querySelectorAll(".cm-diffLine-modified")].map((e) => e.textContent)).toEqual([" return a;"]); + expect([...mv.b.dom.querySelectorAll(".cm-diffLine-modified")].map((e) => e.textContent)).toEqual([ + " return a + b;", + ]); + // IDEA keeps the space before an inserted word in the fragment. + expect([...mv.b.dom.querySelectorAll(".cm-diffWord-inserted")].map((e) => e.textContent).join("")).toBe(" + b"); + // and marks where it would be on the other side + expect(mv.a.dom.querySelectorAll(".cm-diffWordEmpty.cm-diffWord-inserted")).toHaveLength(1); mv.destroy(); parent.remove(); }); @@ -175,47 +250,76 @@ describe("diff options", () => { const extensions = [viewerBase({ readOnly: true }), changedLineGutters]; const mv = new MergeView({ a: { doc: "keep\nlet x = 1;\nend\n", extensions }, - b: { doc: "keep\nlet x = 2;\nadded\nend\n", extensions }, + b: { doc: "keep\nlet x = 2;\nadded\nend\nnew\n", extensions }, parent, gutter: false, - diffConfig: { override: whitespaceDiff("none") }, + diffConfig: ideaDiffConfig("none", "words"), }); const numbers = (v: typeof mv.a, cls: string) => [...v.dom.querySelectorAll(`.cm-lineNumbers .${cls}`)].map((e) => e.textContent); - expect(numbers(mv.a, "cm-diffChangedGutter")).toEqual(["2"]); - expect(numbers(mv.b, "cm-diffChangedGutter")).toEqual(["2", "3"]); - expect(numbers(mv.b, "cm-diffWholeGutter")).toEqual(["3"]); + expect(numbers(mv.a, "cm-diffGutter-modified")).toEqual(["2"]); + expect(numbers(mv.b, "cm-diffGutter-modified")).toEqual(["2", "3"]); + expect(numbers(mv.b, "cm-diffGutter-inserted")).toEqual(["5"]); mv.destroy(); parent.remove(); }); - it("marks a deleted last line as a whole line", () => { + it("shows a final line break added as an inserted empty line, gutter included", () => { const parent = document.createElement("div"); document.body.appendChild(parent); + const extensions = [viewerBase({ readOnly: true }), changedLineGutters]; const mv = new MergeView({ - a: { doc: "keep\ngone", extensions: viewerBase({ readOnly: true }) }, - b: { doc: "keep", extensions: viewerBase({ readOnly: true }) }, + a: { doc: "alpha\nbeta", extensions }, + b: { doc: "alpha\nbeta\n", extensions }, parent, - diffConfig: { override: whitespaceDiff("none") }, + gutter: false, + diffConfig: ideaDiffConfig("none", "words"), }); - expect([...mv.a.dom.querySelectorAll(".cm-diffWholeLine")].map((e) => e.textContent)).toEqual(["gone"]); + const lines = [...mv.b.contentDOM.querySelectorAll(".cm-line")].map((l) => l.classList.contains("cm-diffLine")); + const gutters = [...mv.b.dom.querySelectorAll(".cm-lineNumbers .cm-gutterElement:not([style*='hidden'])")] + .filter((e) => e.textContent) + .map((e) => e.classList.contains("cm-diffChangedGutter")); + // IDEA compares lines without their breaks: "beta" is unchanged, the empty last line inserted. + expect(lines).toEqual([false, false, true]); + expect(gutters).toEqual(lines); + expect(mv.b.contentDOM.querySelectorAll(".cm-diffLine-inserted")).toHaveLength(1); mv.destroy(); parent.remove(); }); - it("keeps word highlighting for a single-line replacement", () => { + it("shows a last line removed as a deleted line only", async () => { + const parent = document.createElement("div"); + document.body.appendChild(parent); + const extensions = [viewerBase({ readOnly: true }), diffWords]; + const mv = new MergeView({ + a: { doc: "keep\ngone", extensions }, + b: { doc: "keep", extensions }, + parent, + highlightChanges: false, + diffConfig: ideaDiffConfig("none", "words"), + }); + await Promise.resolve(); + // "keep" loses its line break, which IDEA does not count as a change. + expect([...mv.a.dom.querySelectorAll(".cm-diffLine")].map((e) => e.textContent)).toEqual(["gone"]); + expect(mv.a.dom.querySelectorAll(".cm-diffLine-deleted")).toHaveLength(1); + mv.destroy(); + parent.remove(); + }); + + it("keeps word highlighting for a single-line replacement", async () => { const parent = document.createElement("div"); document.body.appendChild(parent); const mv = new MergeView({ - a: { doc: "before\nvalue = old\nafter\n", extensions: viewerBase({ readOnly: true }) }, - b: { doc: "before\nvalue = new\nafter\n", extensions: viewerBase({ readOnly: true }) }, + a: { doc: "before\nvalue = old\nafter\n", extensions: [viewerBase({ readOnly: true }), diffWords] }, + b: { doc: "before\nvalue = new\nafter\n", extensions: [viewerBase({ readOnly: true }), diffWords] }, parent, - diffConfig: { override: whitespaceDiff("none") }, + highlightChanges: false, + diffConfig: ideaDiffConfig("none", "words"), }); - expect(mv.a.dom.querySelector(".cm-diffWholeLine")).toBeNull(); - expect(mv.b.dom.querySelector(".cm-diffWholeLine")).toBeNull(); - expect(mv.a.dom.querySelector(".cm-changedText")).not.toBeNull(); - expect(mv.b.dom.querySelector(".cm-changedText")).not.toBeNull(); + // The left pane highlights its words once the right one exists. + await Promise.resolve(); + expect([...mv.a.dom.querySelectorAll(".cm-diffWord-modified")].map((e) => e.textContent)).toEqual(["old"]); + expect([...mv.b.dom.querySelectorAll(".cm-diffWord-modified")].map((e) => e.textContent)).toEqual(["new"]); mv.destroy(); parent.remove(); }); @@ -305,3 +409,31 @@ describe("compare entry points", () => { expect(diffInput(compareFilesData("/p/a", "/p/b")).key).toContain("file:/p/a"); }); }); + +describe("oversized line comparison", () => { + it("falls back to transferable chunks on initial comparison and either-side edits", () => { + const a = doc("alpha\n".repeat(12000) + "beta\n".repeat(12000)); + const b = doc("beta\n".repeat(12000) + "alpha\n".repeat(12000)); + const config = ideaDiffConfig("none", "words"); + const built = Chunk.build(a, b, config); + const editB = ChangeSet.of({ from: 0, insert: "header\n" }, b.length); + const nextB = editB.apply(b); + const updatedB = Chunk.updateB(built, a, nextB, editB, config); + const editA = ChangeSet.of({ from: 0, insert: "other\n" }, a.length); + const nextA = editA.apply(a); + const updatedA = Chunk.updateA(built, nextA, b, editA, config); + for (const [chunks, source, target] of [ + [built, a, b], + [updatedB, a, nextB], + [updatedA, nextA, b], + ] as const) { + expect(chunks.length).toBeGreaterThan(0); + let restored = target; + for (const chunk of [...chunks].reverse()) { + const edit = transferChunk(chunk, "a", source, restored); + restored = restored.replace(edit.from, edit.to, doc(edit.insert)); + } + expect(restored.toString()).toBe(source.toString()); + } + }, 20000); +}); diff --git a/src/features/vcs/__tests__/diffToolbar.test.tsx b/src/features/vcs/__tests__/diffToolbar.test.tsx index bc057bcf..9bd19206 100644 --- a/src/features/vcs/__tests__/diffToolbar.test.tsx +++ b/src/features/vcs/__tests__/diffToolbar.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen, waitFor, within } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import type { EditorInput } from "@/workbench/contributions"; import { Chunk } from "@codemirror/merge"; import { Text } from "@codemirror/state"; @@ -29,6 +29,19 @@ vi.mock("@/workbench/contributions", async (importOriginal) => ({ editorService: () => ({ openFile }), })); +vi.mock("@/editor/api", async (importOriginal) => ({ + ...(await importOriginal()), + attachDocument: (path: string) => + Promise.resolve({ + path, + text: () => working, + readOnly: false, + extension: [], + save: () => Promise.resolve(), + release: () => {}, + }), +})); + import { DiffEditor, mapLine } from "../DiffEditor"; /** Git Log opens a commit's file: both sides are revisions. */ @@ -94,7 +107,7 @@ describe("Diff toolbar", () => { // Unpadded panes joined by the divider. await waitFor(() => expect(document.querySelector(".cm-diffDivider")).not.toBeNull()); expect(document.querySelector(".cm-changeGutter")).toBeNull(); - expect(document.querySelector(".cm-merge-b .cm-lineNumbers .cm-diffWholeGutter")?.textContent).toBe("2"); + expect(document.querySelector(".cm-merge-b .cm-lineNumbers .cm-diffGutter-inserted")?.textContent).toBe("2"); // The lock turns scrolling together off without rebuilding the panes. const divider = document.querySelector(".cm-diffDivider"); (await screen.findByRole("button", { name: "Synchronize Scrolling" })).click(); @@ -104,9 +117,9 @@ describe("Diff toolbar", () => { const item = await screen.findByRole("menuitemcheckbox", { name: "Align Changes" }); expect(item.getAttribute("aria-checked")).toBe("false"); item.click(); - // Padded panes with a change bar. + // Padded panes, the changed lines' gutters still tinted as in IDEA. await waitFor(() => expect(document.querySelector(".cm-diffDivider")).toBeNull()); - expect(document.querySelector(".cm-changeGutter")).not.toBeNull(); + expect(document.querySelector(".cm-merge-b .cm-lineNumbers .cm-diffGutter-inserted")?.textContent).toBe("2"); expect(JSON.parse(localStorage.getItem("openidea.vcs.diffOptions")!)).toMatchObject({ alignChanges: true, syncScroll: false, @@ -178,3 +191,48 @@ describe("Jump to Source of a revision diff", () => { expect(await jumpFrom(0, 2)).toEqual(["/p/a.ts", { selection: { line: 5, col: 1 }, focus: true }]); }); }); + +describe("Unified Revert", () => { + for (const control of ["toolbar", "chunk"] as const) { + it.each([ + ["a\nb", "a"], + ["same", "same\n"], + ["same\n", "same"], + ["", "added"], + ["deleted", ""], + ])(`${control} restores %j from %j`, async (original, changed) => { + localStorage.setItem("openidea.vcs.diffOptions", JSON.stringify({ mode: "unified", collapse: false })); + texts.HEAD = original; + working = changed; + const mounted = render( + , + ); + try { + await waitFor(() => expect(document.querySelectorAll(".cm-editor")).toHaveLength(1)); + const view = EditorView.findFromDOM(document.querySelector(".cm-editor")!)!; + if (control === "toolbar") { + const button = await screen.findByRole("button", { name: "Revert Change" }); + await waitFor(() => expect(button).not.toBeDisabled()); + fireEvent.click(button); + } else { + fireEvent.mouseDown(await screen.findByRole("button", { name: /^Revert$/ })); + } + await waitFor(() => expect(view.state.doc.toString()).toBe(original)); + } finally { + mounted.unmount(); + localStorage.clear(); + } + }); + } +}); diff --git a/src/features/vcs/cm.ts b/src/features/vcs/cm.ts index c3cb9279..d5952b06 100644 --- a/src/features/vcs/cm.ts +++ b/src/features/vcs/cm.ts @@ -22,10 +22,11 @@ import { highlightActiveLine, keymap, lineNumbers, + WidgetType, type DecorationSet, type ViewUpdate, } from "@codemirror/view"; -import { getChunks } from "@codemirror/merge"; +import { getChunks, mergeViewSiblings, type Chunk } from "@codemirror/merge"; import { defaultKeymap } from "@codemirror/commands"; import { search, searchKeymap } from "@codemirror/search"; import { tags as t } from "@lezer/highlight"; @@ -35,16 +36,58 @@ import { fontExtension } from "@/editor/core/cm/setup"; import { caretStyles, findLocals, findStyles } from "@/editor/core/cm/theme"; import { editorPrefs } from "@/platform/editorSettings"; import { useProject } from "@/platform/project"; +import { hasInnerFragments } from "./diffOptions"; -const wholeLine = Decoration.line({ class: "cm-diffWholeLine" }); +/** IDEA's change types (TextDiffType): both sides have text, only the new side, only the old side. */ +export type DiffKind = "modified" | "inserted" | "deleted"; + +const kindOf = (hasA: boolean, hasB: boolean): DiffKind => (hasA && hasB ? "modified" : hasA ? "deleted" : "inserted"); + +/** A chunk's type (DiffUtil.getLineDiffType), by the sides it has lines on. */ +export const chunkKind = (chunk: Chunk): DiffKind => kindOf(chunk.fromA !== chunk.toA, chunk.fromB !== chunk.toB); + +const KINDS: readonly DiffKind[] = ["modified", "inserted", "deleted"]; + +const byKind = (make: (kind: DiffKind) => T) => + Object.fromEntries(KINDS.map((k) => [k, make(k)])) as Record; + +/** Line backgrounds by type; `fine` ones (words compared) are lighter. */ +const lineDecos = byKind((kind) => ({ + coarse: Decoration.line({ class: `cm-diffLine cm-diffLine-${kind}` }), + fine: Decoration.line({ class: `cm-diffLine cm-diffLine-${kind} cm-diffLine-fine` }), +})); + +const wordDecos = byKind((kind) => Decoration.mark({ class: `cm-diffWord cm-diffWord-${kind}` })); + +/** A word on the other side only: a thin bar where it would be (DiffEmptyHighlighterRenderer). */ +class EmptyWordMarker extends WidgetType { + constructor(readonly kind: DiffKind) { + super(); + } + eq(other: EmptyWordMarker) { + return other.kind === this.kind; + } + toDOM() { + const span = document.createElement("span"); + span.className = `cm-diffWordEmpty cm-diffWord-${this.kind}`; + span.setAttribute("aria-hidden", "true"); + return span; + } +} + +const emptyWordDecos = byKind((kind) => Decoration.widget({ widget: new EmptyWordMarker(kind), side: 1 })); /** * Calls `f` for the changed lines of a side-by-side pane between `vpFrom` and - * `vpTo`, with whether the line has no counterpart on the other side (inserted - * or deleted as a whole). Lines paired with a line on the other side keep the - * line tint plus word-level highlights. + * `vpTo`, with the type of their chunk (DiffUtil.getLineDiffType): a chunk + * with lines on both sides is modified as a whole, however its words changed. */ -function forChangedLines(state: EditorState, vpFrom: number, vpTo: number, f: (line: Line, whole: boolean) => void) { +function forChangedLines( + state: EditorState, + vpFrom: number, + vpTo: number, + f: (line: Line, kind: DiffKind, fine: boolean) => void, +) { const info = getChunks(state); if (!info || !info.side) return; const isA = info.side === "a"; @@ -52,38 +95,70 @@ function forChangedLines(state: EditorState, vpFrom: number, vpTo: number, f: (l for (const chunk of info.chunks) { const from = isA ? chunk.fromA : chunk.fromB; const to = isA ? chunk.toA : chunk.toB; - if (from >= vpTo) break; + if (from > vpTo) break; if (from === to || to < vpFrom) continue; - const otherEmpty = isA ? chunk.fromB === chunk.toB : chunk.fromA === chunk.toA; - const ranges = chunk.changes.map((c) => (isA ? [c.fromA, c.toA] : [c.fromB, c.toB]).map((p) => p + from)); - // Covered including the line break after it, or the one before it (a deleted last line). - const covered = (line: Line) => - ranges.some(([f = 0, t = 0]) => (f <= line.from && t > line.to) || (f < line.from && t >= line.to)); - for (let line = doc.lineAt(Math.max(from, vpFrom)); line.from < Math.min(to, vpTo);) { - f(line, otherEmpty || covered(line)); + const kind = chunkKind(chunk); + const fine = hasInnerFragments(chunk); + // A line starting at `vpTo` is still in view (the empty last line). + for (let line = doc.lineAt(Math.max(from, vpFrom)); line.from < to && line.from <= vpTo;) { + f(line, kind, fine); if (line.number === doc.lines) break; line = doc.line(line.number + 1); } } } -/** Solid background for the lines changed as a whole. */ -function wholeLineDeco(view: EditorView): DecorationSet { +/** + * Line backgrounds in the colour of their chunk's type + * (DiffDrawUtil.LineHighlighterBuilder). The unified view shows every new + * line as inserted, below the old ones as deleted (createUnifiedChunkHighlighters). + */ +function lineDeco(view: EditorView): DecorationSet { + const unified = !mergeViewSiblings(view); const builder = new RangeSetBuilder(); - forChangedLines(view.state, view.viewport.from, view.viewport.to, (line, whole) => { - if (whole) builder.add(line.from, line.from, wholeLine); + forChangedLines(view.state, view.viewport.from, view.viewport.to, (line, kind, fine) => { + const decos = lineDecos[unified ? "inserted" : kind]; + builder.add(line.from, line.from, fine ? decos.fine : decos.coarse); }); return builder.finish(); } +/** + * The inner fragments of chunks whose words were compared, each in the colour + * of its own type (SimpleDiffChangeUi.createInlineHighlighter); a fragment + * empty on this side is a thin bar. + */ +function wordDeco(view: EditorView): DecorationSet { + const info = getChunks(view.state); + // The unified view highlights words itself (`highlightChanges`). + if (!info || !info.side || !mergeViewSiblings(view)) return Decoration.none; + const isA = info.side === "a"; + const { from: vpFrom, to: vpTo } = view.viewport; + const builder = new RangeSetBuilder(); + for (const chunk of info.chunks) { + const base = isA ? chunk.fromA : chunk.fromB; + if (base > vpTo) break; + if ((isA ? chunk.toA : chunk.toB) < vpFrom || !hasInnerFragments(chunk)) continue; + for (const c of chunk.changes) { + const [from, to] = isA ? [c.fromA, c.toA] : [c.fromB, c.toB]; + const kind = kindOf(c.fromA !== c.toA, c.fromB !== c.toB); + if (from < to) builder.add(base + from, base + to, wordDecos[kind]); + else builder.add(base + from, base + from, emptyWordDecos[kind]); + } + } + return builder.finish(); +} + class LineClassMarker extends GutterMarker { constructor(readonly elementClass: string) { super(); } } -const changedGutter = new LineClassMarker("cm-diffChangedGutter"); -const wholeGutter = new LineClassMarker("cm-diffChangedGutter cm-diffWholeGutter"); +const gutterMarkers = byKind((kind) => ({ + coarse: new LineClassMarker(`cm-diffChangedGutter cm-diffGutter-${kind}`), + fine: new LineClassMarker(`cm-diffChangedGutter cm-diffGutter-${kind} cm-diffGutter-fine`), +})); /** The line background of changed lines carried into the gutters (line numbers, inclusion boxes), as in IDEA. */ export const changedLineGutters = StateField.define>({ @@ -97,29 +172,42 @@ export const changedLineGutters = StateField.define>({ function changedGutterMarkers(state: EditorState): RangeSet { const builder = new RangeSetBuilder(); - forChangedLines(state, 0, state.doc.length + 1, (line, whole) => - builder.add(line.from, line.from, whole ? wholeGutter : changedGutter), + forChangedLines(state, 0, state.doc.length, (line, kind, fine) => + builder.add(line.from, line.from, fine ? gutterMarkers[kind].fine : gutterMarkers[kind].coarse), ); return builder.finish(); } -const wholeChangedLines = ViewPlugin.fromClass( - class { - decorations: DecorationSet; - constructor(view: EditorView) { - this.decorations = wholeLineDeco(view); - } - update(update: ViewUpdate) { - if ( - update.docChanged || - update.viewportChanged || - getChunks(update.startState)?.chunks !== getChunks(update.state)?.chunks - ) - this.decorations = wholeLineDeco(update.view); - } - }, - { decorations: (value) => value.decorations }, -); +/** Recomputes `build` when the document, viewport or chunks change. */ +const chunkDecorations = (build: (view: EditorView) => DecorationSet) => + ViewPlugin.fromClass( + class { + decorations: DecorationSet; + constructor(view: EditorView) { + this.decorations = build(view); + } + update(update: ViewUpdate) { + if ( + update.docChanged || + update.viewportChanged || + getChunks(update.startState)?.chunks !== getChunks(update.state)?.chunks + ) + this.decorations = build(update.view); + } + }, + { decorations: (value) => value.decorations }, + ); + +const changedLines = chunkDecorations(lineDeco); + +/** + * IDEA's "Highlight words" for a side-by-side pane: the inner fragments of + * chunks whose words were compared, over their lighter line background. + */ +export const diffWords: Extension = [ + chunkDecorations(wordDeco), + EditorView.editorAttributes.of({ class: "cm-diffWords" }), +]; export const viewerHighlight = HighlightStyle.define([ { @@ -170,44 +258,38 @@ export const viewerTheme = EditorView.theme( }, ".cm-lineNumbers .cm-gutterElement": { padding: "0 8px 0 12px", minWidth: "32px" }, ".cm-activeLineGutter": { backgroundColor: "transparent", color: "var(--fg-secondary)" }, - // @codemirror/merge - "&.cm-merge-a .cm-changedLine, .cm-deletedChunk": { - backgroundColor: "color-mix(in srgb, var(--gutter-deleted) 12%, transparent)", + // Side-by-side panes colour lines by their chunk's type (changedLines) instead of the library's tint. + "&.cm-merge-a .cm-changedLine, &.cm-merge-b .cm-changedLine": { backgroundColor: "transparent" }, + ".cm-line.cm-diffLine-modified, .cm-diffGutter-modified": { backgroundColor: "var(--diff-modified)" }, + ".cm-line.cm-diffLine-inserted, .cm-diffGutter-inserted": { backgroundColor: "var(--diff-inserted)" }, + ".cm-line.cm-diffLine-deleted, .cm-diffGutter-deleted": { backgroundColor: "var(--diff-deleted)" }, + // Chunks whose words were compared take the lighter colour (TextDiffType.getIgnoredColor), their fragments the full one. + ...Object.fromEntries( + KINDS.map((k) => [ + `.cm-line.cm-diffLine-fine.cm-diffLine-${k}, .cm-diffGutter-fine.cm-diffGutter-${k}`, + { backgroundColor: `color-mix(in srgb, var(--diff-${k}) 40%, var(--bg-app))` }, + ]), + ), + // Full line height, as IDEA paints them: an inline background only covers the font's height, + // leaving stripes between the lines of a multi-line fragment. + ".cm-diffWord": { display: "inline-block" }, + ".cm-diffWord-modified": { backgroundColor: "var(--diff-modified)" }, + ".cm-diffWord-inserted": { backgroundColor: "var(--diff-inserted)" }, + ".cm-diffWord-deleted": { backgroundColor: "var(--diff-deleted)" }, + ".cm-diffWordEmpty": { + display: "inline-block", + width: "2px", + height: "1lh", + margin: "0 -1px", + verticalAlign: "top", }, - "&.cm-merge-b .cm-changedLine, .cm-inlineChangedLine": { - backgroundColor: "color-mix(in srgb, var(--gutter-added) 12%, transparent)", - }, - "&.cm-merge-a .cm-changedLine.cm-diffWholeLine": { - backgroundColor: "color-mix(in srgb, var(--danger) 26%, transparent)", - }, - "&.cm-merge-b .cm-changedLine.cm-diffWholeLine": { - backgroundColor: "color-mix(in srgb, var(--success) 26%, transparent)", - }, - "&.cm-merge-a .cm-diffWholeLine .cm-changedText, &.cm-merge-b .cm-diffWholeLine .cm-changedText": { - background: "transparent", - }, - "&.cm-merge-a .cm-changedText, .cm-deletedChunk .cm-deletedText": { - background: "color-mix(in srgb, var(--danger) 26%, transparent)", - }, - "&.cm-merge-b .cm-changedText": { - background: "color-mix(in srgb, var(--success) 26%, transparent)", - }, - "&.cm-merge-b .cm-changedText, .cm-insertedLine .cm-changedText": { - background: "color-mix(in srgb, var(--success) 26%, transparent)", - }, - // Gutters of changed lines take the line background (changedLineGutters). - "&.cm-merge-a .cm-diffChangedGutter": { - backgroundColor: "color-mix(in srgb, var(--gutter-deleted) 12%, transparent)", - }, - "&.cm-merge-b .cm-diffChangedGutter": { - backgroundColor: "color-mix(in srgb, var(--gutter-added) 12%, transparent)", - }, - "&.cm-merge-a .cm-diffWholeGutter": { - backgroundColor: "color-mix(in srgb, var(--danger) 26%, transparent)", - }, - "&.cm-merge-b .cm-diffWholeGutter": { - backgroundColor: "color-mix(in srgb, var(--success) 26%, transparent)", + // The unified view: old lines deleted, new lines inserted. + ".cm-deletedChunk": { backgroundColor: "var(--diff-deleted)" }, + "&.cm-diffWords .cm-deletedChunk": { + backgroundColor: "color-mix(in srgb, var(--diff-deleted) 40%, var(--bg-app))", }, + ".cm-deletedChunk .cm-deletedText": { background: "var(--diff-deleted)" }, + "&.cm-merge-b .cm-changedText": { background: "var(--diff-inserted)" }, ".cm-changeGutter": { width: "3px", paddingLeft: "0" }, "&.cm-merge-a .cm-changedLineGutter, .cm-deletedLineGutter": { background: "var(--gutter-deleted)" }, "&.cm-merge-b .cm-changedLineGutter": { background: "var(--gutter-modified)" }, @@ -286,7 +368,7 @@ export function viewerBase(opts: { readOnly: boolean; lineNumbers?: boolean; fin : [search({ top: true }), keymap.of([...searchKeymap, ...defaultKeymap])], syntaxHighlighting(viewerHighlight), viewerTheme, - wholeChangedLines, + changedLines, EditorState.readOnly.of(opts.readOnly), EditorView.editable.of(!opts.readOnly), // Non-editable revision panes still need keyboard selection and copy. diff --git a/src/features/vcs/comparison/__tests__/idea.test.ts b/src/features/vcs/comparison/__tests__/idea.test.ts new file mode 100644 index 00000000..a870a20e --- /dev/null +++ b/src/features/vcs/comparison/__tests__/idea.test.ts @@ -0,0 +1,133 @@ +/** + * IntelliJ's own comparison tests (LineComparisonUtilTest, + * WordComparisonUtilTest, SplitComparisonUtilTest; Apache-2.0, Copyright + * JetBrains s.r.o. and contributors), converted from their Kotlin DSL into + * `ideaCases.json`, run against the port. + */ +import { describe, expect, it } from "vitest"; +import { + compareLineFragments, + compareLinesInner, + compareWords, + processBlocks, + type ComparisonPolicy, + type LineFragment, + type Range, +} from ".."; +import cases from "./ideaCases.json"; + +type Policy = "default" | "trim" | "ignore"; +type Change = [number, number, number, number]; + +interface Case { + source: string; + test: string; + type: "lines" | "lines_inner" | "words" | "splitter"; + squash: boolean; + trim: boolean; + before: string; + after: string; + changes: Partial>; + matchings: Partial>; + policies: Policy[]; +} + +/** PolicyData.get: "ignore" falls back to "trim", then "default". */ +const forPolicy = (data: Partial>, policy: Policy): T | undefined => + policy === "ignore" + ? (data.ignore ?? data.trim ?? data.default) + : policy === "trim" + ? (data.trim ?? data.default) + : data.default; + +const POLICY: Record = { default: "default", trim: "trim", ignore: "ignore" }; + +/** Changed character offsets marked by non-space characters, '.' skipped (DiffTestCase.parseMatching). */ +function parseMatching(matching: string): number[] { + return [...matching.replace(/\./g, "")].flatMap((c, i) => (c !== " " ? [i] : [])); +} + +/** Changed lines: lines (split at '_' / '*') with a character other than ' ' and '_' (parseLineMatching). */ +function parseLineMatching(matching: string): number[] { + const out: number[] = []; + let line = 0; + let index = 0; + while (index < matching.length) { + let end = matching.slice(index).search(/[_*]/) + 1; + end = end === 0 ? matching.length : index + end; + if (/[^ _]/.test(matching.slice(index, end))) out.push(line); + line++; + index = end; + } + return out; +} + +const charSets = (fragments: readonly Range[]) => { + const s1 = new Set(); + const s2 = new Set(); + for (const f of fragments) { + for (let i = f.start1; i < f.end1; i++) s1.add(i); + for (let i = f.start2; i < f.end2; i++) s2.add(i); + } + return [[...s1].sort((a, b) => a - b), [...s2].sort((a, b) => a - b)]; +}; + +const lineSets = (fragments: readonly LineFragment[]) => + charSets(fragments.map((f) => ({ start1: f.startLine1, end1: f.endLine1, start2: f.startLine2, end2: f.endLine2 }))); + +const lineChanges = (fragments: readonly LineFragment[]): Change[] => + fragments.map((f) => [f.startLine1, f.endLine1, f.startLine2, f.endLine2]); + +const rangeChanges = (fragments: readonly Range[]): Change[] => + fragments.map((f) => [f.start1, f.end1, f.start2, f.end2]); + +function checkWords(fragments: readonly Range[], changes?: Change[], matching?: [string, string]) { + if (changes) expect(rangeChanges(fragments)).toEqual(changes); + if (matching) expect(charSets(fragments)).toEqual([parseMatching(matching[0]), parseMatching(matching[1])]); +} + +function run(c: Case, policy: Policy) { + const p = POLICY[policy]; + const changes = forPolicy(c.changes, policy); + const matching = forPolicy(c.matchings, policy); + expect(changes ?? matching, "the case has expectations").toBeDefined(); + switch (c.type) { + case "lines": { + const fragments = compareLineFragments(c.before, c.after, p); + if (changes) expect(lineChanges(fragments)).toEqual(changes); + if (matching) + expect(lineSets(fragments)).toEqual([parseLineMatching(matching[0]), parseLineMatching(matching[1])]); + break; + } + case "lines_inner": { + const fragments = processBlocks(compareLinesInner(c.before, c.after, p), c.before, c.after, p, true, false); + expect(fragments).toHaveLength(1); + const [f] = fragments; + expect([f!.startOffset1, f!.endOffset1, f!.startOffset2, f!.endOffset2]).toEqual([ + 0, + c.before.length, + 0, + c.after.length, + ]); + checkWords(f!.inner!, changes, matching); + checkWords(compareWords(c.before, c.after, p), changes, matching); + break; + } + case "words": + checkWords(compareWords(c.before, c.after, p), changes, matching); + break; + case "splitter": { + const inner = compareLinesInner(c.before, c.after, p); + expect(lineChanges(processBlocks(inner, c.before, c.after, p, c.squash, c.trim))).toEqual(changes); + break; + } + } +} + +describe("IntelliJ comparison tests", () => { + (cases as Case[]).forEach((c, i) => { + for (const policy of new Set(c.policies)) + it(`${c.source}.${c.test} #${i} ${c.type} ${JSON.stringify(c.before)} - ${JSON.stringify(c.after)} (${policy})`, () => + run(c, policy)); + }); +}); diff --git a/src/features/vcs/comparison/__tests__/ideaCases.json b/src/features/vcs/comparison/__tests__/ideaCases.json new file mode 100644 index 00000000..32839c7d --- /dev/null +++ b/src/features/vcs/comparison/__tests__/ideaCases.json @@ -0,0 +1,2546 @@ +[ + { + "source": "LineComparisonUtilTest", + "test": "testEqualStrings", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "", + "after": "" + }, + { + "source": "LineComparisonUtilTest", + "test": "testEqualStrings", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x", + "after": "x" + }, + { + "source": "LineComparisonUtilTest", + "test": "testEqualStrings", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x\ny\nz\n", + "after": "x\ny\nz\n" + }, + { + "source": "LineComparisonUtilTest", + "test": "testEqualStrings", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "\n", + "after": "\n" + }, + { + "source": "LineComparisonUtilTest", + "test": "testEqualStrings", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": " x\ny ", + "after": " x\ny " + }, + { + "source": "LineComparisonUtilTest", + "test": "testTrivialCases", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 1, 0, 1]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x\n", + "after": "y\n" + }, + { + "source": "LineComparisonUtilTest", + "test": "testTrivialCases", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 1, 0, 1]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x", + "after": "" + }, + { + "source": "LineComparisonUtilTest", + "test": "testTrivialCases", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 1, 0, 1]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "", + "after": "x" + }, + { + "source": "LineComparisonUtilTest", + "test": "testTrivialCases", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 1, 0, 1]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x", + "after": "y" + }, + { + "source": "LineComparisonUtilTest", + "test": "testTrivialCases", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 1, 0, 1]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x\nz", + "after": "y\nz" + }, + { + "source": "LineComparisonUtilTest", + "test": "testTrivialCases", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[1, 2, 1, 2]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "z\nx", + "after": "z\ny" + }, + { + "source": "LineComparisonUtilTest", + "test": "testTrivialCases", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[1, 1, 1, 2]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x", + "after": "x\n" + }, + { + "source": "LineComparisonUtilTest", + "test": "testTrivialCases", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[1, 2, 1, 1]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x\n", + "after": "x" + }, + { + "source": "LineComparisonUtilTest", + "test": "testSimpleCases", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 1, 0, 1]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x\nz", + "after": "y\nz" + }, + { + "source": "LineComparisonUtilTest", + "test": "testSimpleCases", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[1, 2, 1, 2]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x\n", + "after": "x\nz" + }, + { + "source": "LineComparisonUtilTest", + "test": "testSimpleCases", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 2, 0, 2]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x\ny", + "after": "n\nm" + }, + { + "source": "LineComparisonUtilTest", + "test": "testSimpleCases", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [ + [0, 1, 0, 1], + [2, 3, 2, 3] + ] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x\ny\nz", + "after": "n\ny\nm" + }, + { + "source": "LineComparisonUtilTest", + "test": "testSimpleCases", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [ + [0, 1, 0, 2], + [2, 3, 3, 3] + ] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x\ny\nz", + "after": "n\nk\ny" + }, + { + "source": "LineComparisonUtilTest", + "test": "testSimpleCases", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [ + [0, 1, 0, 0], + [2, 3, 1, 1] + ] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x\ny\nz", + "after": "y" + }, + { + "source": "LineComparisonUtilTest", + "test": "testSimpleCases", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [ + [0, 2, 0, 0], + [3, 3, 1, 3] + ] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "a\nb\nx", + "after": "x\nm\nn" + }, + { + "source": "LineComparisonUtilTest", + "test": "testEmptyLastLine", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 1, 0, 0]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x\n", + "after": "" + }, + { + "source": "LineComparisonUtilTest", + "test": "testEmptyLastLine", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 0, 0, 1]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "", + "after": "x\n" + }, + { + "source": "LineComparisonUtilTest", + "test": "testEmptyLastLine", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[1, 2, 1, 1]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x\n", + "after": "x" + }, + { + "source": "LineComparisonUtilTest", + "test": "testEmptyLastLine", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[1, 2, 1, 2]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x\n", + "after": "x\nz " + }, + { + "source": "LineComparisonUtilTest", + "test": "testWhitespaceOnlyChanges", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 1, 0, 1]], + "trim": [] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x ", + "after": " x" + }, + { + "source": "LineComparisonUtilTest", + "test": "testWhitespaceOnlyChanges", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 1, 0, 1]], + "trim": [] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x \t", + "after": "\t x" + }, + { + "source": "LineComparisonUtilTest", + "test": "testWhitespaceOnlyChanges", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 2, 0, 1]], + "trim": [[1, 2, 1, 1]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x\n", + "after": "x " + }, + { + "source": "LineComparisonUtilTest", + "test": "testWhitespaceOnlyChanges", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 2, 0, 2]], + "trim": [] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": " x\ny ", + "after": "x \n y" + }, + { + "source": "LineComparisonUtilTest", + "test": "testWhitespaceOnlyChanges", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 1, 0, 1]], + "ignore": [] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x y ", + "after": "x y" + }, + { + "source": "LineComparisonUtilTest", + "test": "testWhitespaceOnlyChanges", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 3, 0, 3]], + "trim": [[2, 3, 2, 3]], + "ignore": [] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x y\nx y\nx y", + "after": " x y \nx y \nx y" + }, + { + "source": "LineComparisonUtilTest", + "test": "testAlgorithmSpecific", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [ + [0, 3, 0, 0], + [4, 4, 1, 4] + ] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x\ny\nz\nAAAAA", + "after": "AAAAA\nx\ny\nz" + }, + { + "source": "LineComparisonUtilTest", + "test": "testAlgorithmSpecific", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 3, 0, 3]], + "trim": [ + [0, 1, 0, 0], + [2, 3, 1, 3] + ] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x\ny\nz", + "after": " y\n m\n n" + }, + { + "source": "LineComparisonUtilTest", + "test": "testAlgorithmSpecific", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 1, 0, 0]] + }, + "matchings": {}, + "policies": ["default"], + "before": "}\n }", + "after": " }" + }, + { + "source": "LineComparisonUtilTest", + "test": "testAlgorithmSpecific", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [ + [1, 1, 1, 3], + [2, 2, 4, 5] + ] + }, + "matchings": {}, + "policies": ["default"], + "before": "{\n}", + "after": "{\n {\n }\n}\nx" + }, + { + "source": "LineComparisonUtilTest", + "test": "testNonDeterministicCases", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[1, 1, 1, 3]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "", + "after": "\n\n" + }, + { + "source": "LineComparisonUtilTest", + "test": "testNonDeterministicCases", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[1, 3, 1, 1]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "\n\n", + "after": "" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test regression - shifted similar lines should be matched as a single change, not insertion-deletion`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["--_---", "---_----"] + }, + "policies": ["default"], + "before": " X\n X", + "after": " X\n X" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test prefer chunks bounded by empty line`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" _ _ _ _ _ _ _ _ _ _ _ _ ", " _ _ _ _ _ _ _-_-_-_-_-_-_-_ _ _ _ _ _ "] + }, + "policies": ["default", "trim", "ignore"], + "before": "A\nB\no\no\nY\nZ\n \nA\nB\nz\nz\nY\nZ", + "after": "A\nB\no\no\nY\nZ\n \nA\nB\nu\nu\nY\nZ\n \nA\nB\nz\nz\nY\nZ" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test prefer chunks bounded by empty line`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" _ _ _ _ _ _ _ _ _ _ _ _ ", " _ _ _ _ _ _ _ _ _-_-_-_-_-_-_-_ _ _ _ "] + }, + "policies": ["default", "trim", "ignore"], + "before": "A\nB\no\no\nY\nZ\n \nA\nB\n \n \nY\nZ", + "after": "A\nB\no\no\nY\nZ\n \nA\nB\nu\nu\nY\nZ\n \nA\nB\n \n \nY\nZ" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test prefer chunks bounded by empty line`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" _ _ _ _ _ _ _ _ ", " _ _ _ _ _-_-_-_-_-_ _ _ _ "] + }, + "policies": ["default", "trim", "ignore"], + "before": "A\nB\no\no\n \nA\nB\nz\nz", + "after": "A\nB\no\no\n \nA\nB\nu\nu\n \nA\nB\nz\nz" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test prefer chunks bounded by empty line`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" _ _ _ _ _ _ _ _ ", " _ _ _ _ _-_-_-_-_-_ _ _ _ "] + }, + "policies": ["default", "trim", "ignore"], + "before": "o\no\nY\nZ\n \nz\nz\nY\nZ", + "after": "o\no\nY\nZ\n \nu\nu\nY\nZ\n \nz\nz\nY\nZ" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test prefer chunks bounded by short line`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [ + " _ _ _ _ _ _ _ _ _ _ _ _ ", + " _ _ _ _ _ _ _-----_-----_-----_-----_-----_-----_-_ _ _ _ _ _ " + ] + }, + "policies": ["default", "trim", "ignore"], + "before": "A====\nB====\no====\no====\nY====\nZ====\n!\nA====\nB====\nz====\nz====\nY====\nZ====", + "after": "A====\nB====\no====\no====\nY====\nZ====\n!\nA====\nB====\nu====\nu====\nY====\nZ====\n!\nA====\nB====\nz====\nz====\nY====\nZ====" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test prefer chunks bounded by short line`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [ + " _ _ _ _ _ _ _ _ _ _ _ _ ", + " _ _ _ _ _ _ _-----_-----_-----_-----_-----_-----_-_ _ _ _ _ _ " + ] + }, + "policies": ["default", "trim", "ignore"], + "before": "A====\nB====\no====\no====\nY====\nZ====\n \nA====\nB====\n!\n!\nY====\nZ====", + "after": "A====\nB====\no====\no====\nY====\nZ====\n \nA====\nB====\nu====\nu====\nY====\nZ====\n \nA====\nB====\n!\n!\nY====\nZ====" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test prefer chunks bounded by short line`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [ + " _ _ _ _ _ _ _ _ _ _ _ _ ", + " _ _ _ _ _ _ _ _ _-----_-----_-----_-----_-_-----_-----_ _ _ _ " + ] + }, + "policies": ["default", "trim", "ignore"], + "before": "A====\nB====\no====\no====\nY====\nZ====\n!\nA====\nB====\n \n \nY====\nZ====", + "after": "A====\nB====\no====\no====\nY====\nZ====\n!\nA====\nB====\nu====\nu====\nY====\nZ====\n!\nA====\nB====\n \n \nY====\nZ====" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test prefer chunks bounded by empty line - using inserted content`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["-_ _ _ _ ", " _ _ _-_-_-_-_ "] + }, + "policies": ["default", "trim", "ignore"], + "before": "e\nx\nA\nB\nz", + "after": "x\nA\nB\n \nq\nA\nB\nz" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test prefer smaller amount of chunks`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["-_-_ _ _", " _ _"] + }, + "policies": ["default", "trim", "ignore"], + "before": "X\nA\nX\nY\n", + "after": "X\nY\n" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test prefer smaller amount of chunks`", + "type": "lines", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 3, 0, 0]] + }, + "matchings": { + "default": ["-**-*__ ", "__ "] + }, + "policies": ["default", "trim", "ignore"], + "before": " \n\nx\n\n\ny", + "after": "\n\ny" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test prefer smaller amount of chunks`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [ + " _-------_-------_-------_-------_ _ _ _ _-------", + " _ _ _ _ " + ] + }, + "policies": ["default", "trim", "ignore"], + "before": "U======\nX======\nY======\nz======\nX======\nY======\nX======\nU======\nX======\nz======", + "after": "U======\nY======\nX======\nU======\nX======" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test regression - can trim chunks after 'compareTwoSteps'`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["-*_ _--*--", "_ "] + }, + "policies": ["default"], + "before": "q\n\n7\n 6\n 7", + "after": "\n7" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test regression - can trim chunks after 'optimizeLineChunks'`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" _-------_ _ _ _", " _ _ _ _"] + }, + "policies": ["default", "trim", "ignore"], + "before": "A=====\n B=====\n }\n}\nB=====\n", + "after": "A=====\n }\n}\nB=====\n" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test bad cases caused by 'compareTwoStep' logic`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["-_ ", " _-_-"] + }, + "policies": ["default", "trim", "ignore"], + "before": "x\n!", + "after": "!\nx\ny" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test bad cases caused by 'compareTwoStep' logic`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["-_ _-", " _-"] + }, + "policies": ["default", "trim", "ignore"], + "before": "!\nx\ny", + "after": "x\n!" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test bad cases caused by 'compareTwoStep' logic`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["-_--", "-_-_-"], + "trim": ["-_ ", " _-_-"] + }, + "policies": ["default", "trim", "ignore"], + "before": "x\n! ", + "after": "!\nx\ny" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test bad cases caused by 'compareTwoStep' logic`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["-_ _-", " _--"] + }, + "policies": ["default", "trim", "ignore"], + "before": "!\nx\ny", + "after": "x\n! " + }, + { + "source": "LineComparisonUtilTest", + "test": "`test bad cases caused by 'compareTwoStep' logic`", + "type": "splitter", + "squash": false, + "trim": false, + "changes": { + "default": [ + [0, 1, 0, 1], + [1, 2, 1, 2], + [2, 3, 2, 3] + ] + }, + "matchings": {}, + "policies": ["default"], + "before": "M===\nX===\nY===", + "after": " Y===\nX===\nN" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test bad cases caused by 'compareSmart' logic`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" _-------_ _ _ ", " _ _ _ "], + "trim": [" _ _--_-_------", " _--_-_ "] + }, + "policies": ["default", "trim", "ignore"], + "before": "A=====\n B=====\n }\n}\nB=====", + "after": "A=====\n }\n}\nB=====" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test bad cases caused by 'compareSmart' logic`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" _-------_-_--_-_-_ _", " _--_-_ _"] + }, + "policies": ["default", "trim", "ignore"], + "before": "A=====\n B=====\nX\n }\n}\nZ\nB=====\n", + "after": "A=====\n }\n}\nB=====\n" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test trim changed blocks after second step correction`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" _------_ _ ", " _ _ "], + "ignore": [" _ _-_-----", " _-_ "] + }, + "policies": ["default", "trim", "ignore"], + "before": "====}\n==== }\nY\n====}", + "after": "====}\nY\n====}" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test second step correction processes all confusing lines`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["-----_ _ _ _-----", " _ _ "] + }, + "policies": ["default"], + "before": "====}\n==== }\nY\n==== }\n====}", + "after": "==== }\nY\n==== }" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test ignore whitespace policy does not applies two-step correction`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["-_-_ ", " "], + "trim": [" _-_---", " "] + }, + "policies": ["default", "trim", "ignore"], + "before": "1\n \n 1", + "after": " 1" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test ignore whitespace policy does not applies two-step correction`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" _-_-", " "] + }, + "policies": ["default", "trim", "ignore"], + "before": " 1\n \n1", + "after": " 1" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test ignore whitespace policy does not applies two-step correction`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["-_--_-", "--"], + "trim": ["-_ _-", " "] + }, + "policies": ["default", "trim", "ignore"], + "before": "X\n Y\nX", + "after": "Y " + }, + { + "source": "LineComparisonUtilTest", + "test": "`test regression - second step correction should be performed if there are no ambiguous matchings`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["-_--", "--_-"], + "trim": [" _ ", " _ "] + }, + "policies": ["default", "trim", "ignore"], + "before": "}\n }", + "after": " }\n}" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test regression - second step correction should be performed if there are no ambiguous matchings`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["--_ _--", "-_ _-"], + "trim": [" _ _ ", " _ _ "] + }, + "policies": ["default", "trim", "ignore"], + "before": " }\n}\n }", + "after": "}\n}\n}" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test regression - second step correction should be performed if there are no ambiguous matchings`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" _--__-", " __-"], + "trim": ["-_ __-", " __-"] + }, + "policies": ["default", "trim", "ignore"], + "before": "X\nX \n\nY", + "after": "X\n\nZ" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test regression - second step with too many possible matchings`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["--_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _-_-_-_-_-_--", "-_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _--"], + "trim": [" _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _-_-_-_-_--", " _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ "] + }, + "policies": ["default", "trim", "ignore"], + "before": " X\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX\n X", + "after": "X\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX\nX " + }, + { + "source": "LineComparisonUtilTest", + "test": "`test regression - second step correction should search for matchings in its prefix`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["-_ _-_--__ ", " __ "], + "trim": ["-_-_-_ __ ", " __ "] + }, + "policies": ["default", "trim", "ignore"], + "before": "Z\nX\nZ\nX \n\nY", + "after": "X\n\nY" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test regression - second step correction should search for matchings in its prefix`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["-_-_-_-_--__ ", "--_-__ "], + "trim": ["-_-_ _-_ __ ", " _ __ "] + }, + "policies": ["default", "trim", "ignore"], + "before": "Z\nX\nK\nZ\nX \n\nY", + "after": "K \nX\n\nY" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test regression - multiple similar braces nearby`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" _ _ _---_ _", " _ _ _----_ _"] + }, + "policies": ["default", "trim", "ignore"], + "before": "function fn() {\n return {\n a: 1\n }\n}\n", + "after": "function fn() {\n return {\n a: 1\n };\n}\n" + }, + { + "source": "LineComparisonUtilTest", + "test": "`test regression - multiple similar braces nearby`", + "type": "lines", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" _ _ _ _-_", " _ _ _ _--_"] + }, + "policies": ["default", "trim", "ignore"], + "before": "function fn() {\n return {\n a: 1\n }\n}\n", + "after": "function fn() {\n return {\n a: 1\n }\n};\n" + }, + { + "source": "WordComparisonUtilTest", + "test": "testSimpleCases", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["- ", "- "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x z", + "after": "y z" + }, + { + "source": "WordComparisonUtilTest", + "test": "testSimpleCases", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["- ", "- "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x z", + "after": "y z" + }, + { + "source": "WordComparisonUtilTest", + "test": "testSimpleCases", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["-- ", "- "], + "trim": [" - ", "- "] + }, + "policies": ["default", "trim", "ignore"], + "before": " x z", + "after": "y z" + }, + { + "source": "WordComparisonUtilTest", + "test": "testSimpleCases", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["- -", "- "], + "trim": ["- ", "- "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x z ", + "after": "y z" + }, + { + "source": "WordComparisonUtilTest", + "test": "testSimpleCases", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["- -", "- "], + "trim": ["- ", "- "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x z ", + "after": "y z" + }, + { + "source": "WordComparisonUtilTest", + "test": "testSimpleCases", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["- ", "-- -"], + "trim": ["- ", " - "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x z", + "after": " y z " + }, + { + "source": "WordComparisonUtilTest", + "test": "testSimpleCases", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" -", " --"], + "trim": [" -", " - "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x y", + "after": "x z " + }, + { + "source": "WordComparisonUtilTest", + "test": "testSimpleCases", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" --", " "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x,y", + "after": "x" + }, + { + "source": "WordComparisonUtilTest", + "test": "testSimpleCases", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["-- ", " "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x,y", + "after": "y" + }, + { + "source": "WordComparisonUtilTest", + "test": "testSimpleCases", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" - ", " - "] + }, + "policies": ["default", "trim", "ignore"], + "before": ".x=", + "after": ".!=" + }, + { + "source": "WordComparisonUtilTest", + "test": "testSimpleCases", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" ---- ", " ---- "] + }, + "policies": ["default", "trim", "ignore"], + "before": "X xyz1 Z", + "after": "X xyz2 Z" + }, + { + "source": "WordComparisonUtilTest", + "test": "testPunctuation", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["- - -", " "], + "trim": [" - ", " "] + }, + "policies": ["default", "trim", "ignore"], + "before": " x.z.x ", + "after": "x..x" + }, + { + "source": "WordComparisonUtilTest", + "test": "testPunctuation", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" ", "- - -"], + "trim": [" ", " - "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x..x", + "after": " x.z.x " + }, + { + "source": "WordComparisonUtilTest", + "test": "testPunctuation", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["- ", "- "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x ... z", + "after": "y ... z" + }, + { + "source": "WordComparisonUtilTest", + "test": "testPunctuation", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" -", " -"] + }, + "policies": ["default", "trim", "ignore"], + "before": "x ... z", + "after": "x ... y" + }, + { + "source": "WordComparisonUtilTest", + "test": "testPunctuation", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" - -", " -"] + }, + "policies": ["default", "trim", "ignore"], + "before": "x ,... z", + "after": "x ... y" + }, + { + "source": "WordComparisonUtilTest", + "test": "testPunctuation", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" --- -", " -"], + "ignore": [" - -", " -"] + }, + "policies": ["default", "trim", "ignore"], + "before": "x . , .. z", + "after": "x ... y" + }, + { + "source": "WordComparisonUtilTest", + "test": "testPunctuation", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" - ", " "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x==y==z", + "after": "x====z" + }, + { + "source": "WordComparisonUtilTest", + "test": "testPunctuation", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" ", " - "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x====z", + "after": "x==t==z" + }, + { + "source": "WordComparisonUtilTest", + "test": "testPunctuation", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" - ", " -- - ----"], + "ignore": [" - ", " - - ---"] + }, + "policies": ["default", "trim", "ignore"], + "before": "X Y ) {\n A B C", + "after": "X Y Z ) {\n y B C ) {" + }, + { + "source": "WordComparisonUtilTest", + "test": "testPunctuation", + "type": "words", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" ------------ ", " "] + }, + "policies": ["default", "trim", "ignore"], + "before": "@Deprecated @NotNull", + "after": "@NotNull" + }, + { + "source": "WordComparisonUtilTest", + "test": "testPunctuation", + "type": "words", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" ------------- ", " "] + }, + "policies": ["default", "trim", "ignore"], + "before": "@Deprecated\n @NotNull", + "after": "@NotNull" + }, + { + "source": "WordComparisonUtilTest", + "test": "testOldDiffBug", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" -- ", " "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x'y'>", + "after": "x'>" + }, + { + "source": "WordComparisonUtilTest", + "test": "testOldDiffBug", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" ", " -- "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x'>", + "after": "x'y'>" + }, + { + "source": "WordComparisonUtilTest", + "test": "testOldDiffBug", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" ", " -- "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x'>", + "after": "x'y'>" + }, + { + "source": "WordComparisonUtilTest", + "test": "testOldDiffBug", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" -- ", " "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x'y'>", + "after": "x'>" + }, + { + "source": "WordComparisonUtilTest", + "test": "testWhitespaceOnlyChanges", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" -- ", " -- "] + }, + "policies": ["default", "trim"], + "before": "x =z", + "after": "x= z" + }, + { + "source": "WordComparisonUtilTest", + "test": "testWhitespaceOnlyChanges", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" -- ", " ---"], + "ignore": [" ", " -"] + }, + "policies": ["default", "trim", "ignore"], + "before": "x =", + "after": "x= z" + }, + { + "source": "WordComparisonUtilTest", + "test": "testNewlines", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["- ------ -", " "], + "trim": [" - ", " "], + "ignore": [" - ", " "] + }, + "policies": ["default", "trim", "ignore"], + "before": " x \n y \n z ", + "after": "x z" + }, + { + "source": "WordComparisonUtilTest", + "test": "testNewlines", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" ", "- ------ -"], + "trim": [" ", " - "], + "ignore": [" ", " - "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x z", + "after": " x \n y \n z " + }, + { + "source": "WordComparisonUtilTest", + "test": "testNewlines", + "type": "words", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["- ", " -"], + "trim": [" ", " "] + }, + "policies": ["default", "trim", "ignore"], + "before": "\ni", + "after": "i\n" + }, + { + "source": "WordComparisonUtilTest", + "test": "testNewlines", + "type": "words", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["- ", " -"] + }, + "policies": ["default", "trim", "ignore"], + "before": "i\n", + "after": "\ni" + }, + { + "source": "WordComparisonUtilTest", + "test": "testNewlines", + "type": "words", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "ignore": [" ", " "] + }, + "policies": ["ignore"], + "before": "x\ny", + "after": "xy" + }, + { + "source": "WordComparisonUtilTest", + "test": "testNewlines", + "type": "words", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "ignore": ["-------", "------"] + }, + "policies": ["ignore"], + "before": "A x\ny B", + "after": "a xy b" + }, + { + "source": "WordComparisonUtilTest", + "test": "testNewlines", + "type": "words", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "ignore": ["- -", "- -"] + }, + "policies": ["ignore"], + "before": "A xy B", + "after": "a xy b" + }, + { + "source": "WordComparisonUtilTest", + "test": "testNewlines", + "type": "words", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["--- ", "- "] + }, + "policies": ["default", "trim", "ignore"], + "before": "A\nB\n", + "after": "X\n" + }, + { + "source": "WordComparisonUtilTest", + "test": "testFixedBugs", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" -", " --- "], + "trim": [" ", " --- "], + "ignore": [" ", " - "] + }, + "policies": ["default", "trim", "ignore"], + "before": ".! ", + "after": ". y!" + }, + { + "source": "WordComparisonUtilTest", + "test": "testFixedBugs", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" -", "---- -"], + "trim": [" -", " - -"], + "ignore": [" -", " - -"] + }, + "policies": ["default", "trim", "ignore"], + "before": " x n", + "after": " y\n x m" + }, + { + "source": "WordComparisonUtilTest", + "test": "testFixedBugs", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" -", " ---"], + "trim": [" ", " - "], + "ignore": [" ", " - "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x\n", + "after": "x! " + }, + { + "source": "WordComparisonUtilTest", + "test": "testInnerWhitespaces", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" --- ", " - - "], + "ignore": [" - ", " - - "] + }, + "policies": ["default", "trim", "ignore"], + "before": "<< x >>", + "after": "<.<>.>" + }, + { + "source": "WordComparisonUtilTest", + "test": "testInnerWhitespaces", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" - - ", "- -"], + "ignore": [" ", "- -"] + }, + "policies": ["default", "trim", "ignore"], + "before": "<< x >>", + "after": "y<>y" + }, + { + "source": "WordComparisonUtilTest", + "test": "testInnerWhitespaces", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" ", " -- "], + "ignore": [" ", " - "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x .. z", + "after": "x y .. z" + }, + { + "source": "WordComparisonUtilTest", + "test": "testInnerWhitespaces", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["-- -", " ---"], + "trim": [" -", " - "] + }, + "policies": ["default", "trim", "ignore"], + "before": " x..z", + "after": "x..y " + }, + { + "source": "WordComparisonUtilTest", + "test": "testInnerWhitespaces", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["- -- - - -- -", " "], + "trim": [" -- -- ", " "], + "ignore": [" - - ", " "] + }, + "policies": ["default", "trim", "ignore"], + "before": " x y x \n x z x ", + "after": "x x\nx x" + }, + { + "source": "WordComparisonUtilTest", + "test": "testAlgorithmSpecific", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["--- ", " ---"] + }, + "policies": ["default", "trim", "ignore"], + "before": "...x", + "after": "x..." + }, + { + "source": "WordComparisonUtilTest", + "test": "testAlgorithmSpecific", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["-- ", " "], + "ignore": ["- ", " "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x x y", + "after": "x y" + }, + { + "source": "WordComparisonUtilTest", + "test": "testAlgorithmSpecific", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" --", " "], + "ignore": [" -", " "] + }, + "policies": ["default", "trim", "ignore"], + "before": "y x x", + "after": "y x" + }, + { + "source": "WordComparisonUtilTest", + "test": "testAlgorithmSpecific", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["---- ", " "], + "ignore": ["--- ", " "] + }, + "policies": ["default", "trim", "ignore"], + "before": "A X A B", + "after": "A B" + }, + { + "source": "WordComparisonUtilTest", + "test": "testAlgorithmSpecific", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" ", " ---- "], + "ignore": [" ", " --- "] + }, + "policies": ["default", "trim", "ignore"], + "before": "A.X A.Z", + "after": "A.X A.Y A.Z" + }, + { + "source": "WordComparisonUtilTest", + "test": "testAlgorithmSpecific", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" ", " ---- "], + "ignore": [" ", " --- "] + }, + "policies": ["default", "trim", "ignore"], + "before": "X.A Z.A", + "after": "X.A Y.A Z.A" + }, + { + "source": "WordComparisonUtilTest", + "test": "testAlgorithmSpecific", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" ---", "--- "] + }, + "policies": ["default"], + "before": ". ", + "after": " ." + }, + { + "source": "WordComparisonUtilTest", + "test": "testAlgorithmSpecific", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" - -- ", " - -- "], + "trim": [" - -- ", " -- "], + "ignore": [" - ", " - "] + }, + "policies": ["default", "trim", "ignore"], + "before": "A B\nC D", + "after": "A\nB C\nD" + }, + { + "source": "WordComparisonUtilTest", + "test": "testAlgorithmSpecific", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["- - - ", "- - - "] + }, + "policies": ["default", "trim", "ignore"], + "before": "B\nC\nD\n", + "after": "X\nY\nZ\n" + }, + { + "source": "WordComparisonUtilTest", + "test": "testAlgorithmSpecific", + "type": "words", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" - ", " -- "] + }, + "policies": ["default"], + "before": "!x\n!\nz", + "after": "!\n!\ny z" + }, + { + "source": "WordComparisonUtilTest", + "test": "`test trailing punctuation`", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" ", " -- "], + "trim": [" ", " "] + }, + "policies": ["default", "trim", "ignore"], + "before": "X = { };", + "after": "X = { \n };" + }, + { + "source": "WordComparisonUtilTest", + "test": "`test trailing punctuation`", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" -- ", " ----"], + "trim": [" -- ", " -- "] + }, + "policies": ["default", "trim", "ignore"], + "before": "X = { };\n", + "after": "X = { \n };\n" + }, + { + "source": "WordComparisonUtilTest", + "test": "`test legacy cases from ByWordTest`", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["--- ---", "-- --"] + }, + "policies": ["default", "trim", "ignore"], + "before": "abc def, 123", + "after": "ab def, 12" + }, + { + "source": "WordComparisonUtilTest", + "test": "`test legacy cases from ByWordTest`", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["- -- ", "- "], + "trim": [" -- ", "- "] + }, + "policies": ["default", "trim", "ignore"], + "before": " a[xy]+1", + "after": ",a[]+1" + }, + { + "source": "WordComparisonUtilTest", + "test": "`test legacy cases from ByWordTest`", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["------------- ", "---- "] + }, + "policies": ["default", "trim", "ignore"], + "before": "0987\n a.g();\n", + "after": "yyyy\n" + }, + { + "source": "WordComparisonUtilTest", + "test": "`test legacy cases from ByWordTest`", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" ---- ", " ------ ---- "], + "trim": [" ---- ", " --- ---- "] + }, + "policies": ["default", "trim", "ignore"], + "before": " abc\n2222\n", + "after": " x = abc\nzzzz\n" + }, + { + "source": "WordComparisonUtilTest", + "test": "`test legacy cases from ByWordTest`", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" ", " -- "], + "trim": [" ", " "] + }, + "policies": ["default", "trim", "ignore"], + "before": "f(a, b);", + "after": "f(a,\n b);" + }, + { + "source": "WordComparisonUtilTest", + "test": "`test legacy cases from ByWordTest`", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["- - ", " - -- "], + "trim": [" - ", " - -- "], + "ignore": [" - ", " - "] + }, + "policies": ["default", "trim", "ignore"], + "before": " o.f(a)", + "after": "o. f( b)" + }, + { + "source": "WordComparisonUtilTest", + "test": "`test legacy cases from ByWordTest`", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "trim": [" --- ", "---"] + }, + "policies": ["trim"], + "before": " 123 ", + "after": "xyz" + }, + { + "source": "WordComparisonUtilTest", + "test": "testEmptyRangePositions", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": { + "default": [[1, 2, 1, 1]] + }, + "matchings": { + "default": [" - ", " "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x? y", + "after": "x y" + }, + { + "source": "WordComparisonUtilTest", + "test": "testEmptyRangePositions", + "type": "lines_inner", + "squash": false, + "trim": false, + "changes": { + "default": [[2, 3, 2, 2]] + }, + "matchings": { + "default": [" - ", " "] + }, + "policies": ["default", "trim", "ignore"], + "before": "x ?y", + "after": "x y" + }, + { + "source": "WordComparisonUtilTest", + "test": "testContinuousScript", + "type": "words", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["----", "----"] + }, + "policies": ["default"], + "before": "ABCD", + "after": "DABC" + }, + { + "source": "WordComparisonUtilTest", + "test": "testContinuousScript", + "type": "words", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" -", "- "] + }, + "policies": ["default"], + "before": "汉语漢語", + "after": "語汉语漢" + }, + { + "source": "WordComparisonUtilTest", + "test": "testContinuousScript", + "type": "words", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["-- ", "-- "] + }, + "policies": ["default"], + "before": "AB漢CD", + "after": "DA漢CD" + }, + { + "source": "WordComparisonUtilTest", + "test": "testContinuousScript", + "type": "words", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["--- ", "--- "] + }, + "policies": ["default"], + "before": "AB漢CD", + "after": "DA语CD" + }, + { + "source": "WordComparisonUtilTest", + "test": "testContinuousScript", + "type": "words", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["---", "---"] + }, + "policies": ["default"], + "before": "a_c", + "after": "x_c" + }, + { + "source": "WordComparisonUtilTest", + "test": "testContinuousScript", + "type": "words", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": [" -", " +"] + }, + "policies": ["default"], + "before": "!\n?", + "after": "!\n+" + }, + { + "source": "WordComparisonUtilTest", + "test": "testHighSurrogates", + "type": "words", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["-- ", " --"] + }, + "policies": ["default"], + "before": "😂🔫🧒🤫", + "after": "🔫🧒🤫😂" + }, + { + "source": "WordComparisonUtilTest", + "test": "testHighSurrogates", + "type": "words", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["--", "--"] + }, + "policies": ["default"], + "before": "😂", + "after": "🔫" + }, + { + "source": "WordComparisonUtilTest", + "test": "testHighSurrogates", + "type": "words", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["--", "--"] + }, + "policies": ["default"], + "before": "🤫", + "after": "🔫" + }, + { + "source": "WordComparisonUtilTest", + "test": "testHighSurrogates", + "type": "words", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["---", "---"], + "trim": ["-- ", " --"] + }, + "policies": ["default", "trim", "ignore"], + "before": "😂 ", + "after": " 🔫" + }, + { + "source": "WordComparisonUtilTest", + "test": "testHighSurrogates", + "type": "words", + "squash": false, + "trim": false, + "changes": {}, + "matchings": { + "default": ["-- - ", "--- -"], + "trim": ["-- - ", " -- "], + "ignore": ["-- ", " -- "] + }, + "policies": ["default", "trim", "ignore"], + "before": "😂🤫 🧒", + "after": " 🔫🤫🧒 " + }, + { + "source": "SplitComparisonUtilTest", + "test": "testSplitter", + "type": "splitter", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 1, 0, 1]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x", + "after": "z" + }, + { + "source": "SplitComparisonUtilTest", + "test": "testSplitter", + "type": "splitter", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 2, 0, 2]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x\ny", + "after": "a\nb" + }, + { + "source": "SplitComparisonUtilTest", + "test": "testSplitter", + "type": "splitter", + "squash": false, + "trim": false, + "changes": { + "default": [ + [0, 1, 0, 1], + [1, 2, 1, 2] + ] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x\ny", + "after": "a\nb y" + }, + { + "source": "SplitComparisonUtilTest", + "test": "testSplitter", + "type": "splitter", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 1, 0, 2]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x y", + "after": "x a\nb y" + }, + { + "source": "SplitComparisonUtilTest", + "test": "testSplitter", + "type": "splitter", + "squash": false, + "trim": false, + "changes": { + "default": [ + [0, 1, 0, 1], + [1, 2, 1, 2] + ] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x\ny", + "after": "x a\ny b" + }, + { + "source": "SplitComparisonUtilTest", + "test": "testSplitter", + "type": "splitter", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 2, 0, 2]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x\n", + "after": "x a\n..." + }, + { + "source": "SplitComparisonUtilTest", + "test": "testSplitter", + "type": "splitter", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 2, 0, 2]] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "x\ny\n", + "after": "a\nb\n" + }, + { + "source": "SplitComparisonUtilTest", + "test": "testSplitter", + "type": "splitter", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 2, 0, 2]] + }, + "matchings": {}, + "policies": ["default"], + "before": "x\ny", + "after": " x \n y " + }, + { + "source": "SplitComparisonUtilTest", + "test": "testSplitter", + "type": "splitter", + "squash": false, + "trim": false, + "changes": { + "default": [ + [0, 1, 0, 1], + [1, 2, 1, 2] + ] + }, + "matchings": {}, + "policies": ["default"], + "before": "x\ny", + "after": " x \n y." + }, + { + "source": "SplitComparisonUtilTest", + "test": "testSplitter", + "type": "splitter", + "squash": false, + "trim": false, + "changes": { + "default": [ + [0, 1, 0, 0], + [1, 2, 0, 1], + [2, 3, 1, 1] + ] + }, + "matchings": {}, + "policies": ["default"], + "before": "a\nx\nb\n", + "after": " x\n" + }, + { + "source": "SplitComparisonUtilTest", + "test": "testSplitter", + "type": "splitter", + "squash": false, + "trim": false, + "changes": { + "default": [ + [0, 1, 0, 0], + [1, 2, 0, 1], + [2, 3, 1, 1] + ] + }, + "matchings": {}, + "policies": ["default", "trim", "ignore"], + "before": "a\nx\nb\n", + "after": "!x\n" + }, + { + "source": "SplitComparisonUtilTest", + "test": "testSplitter", + "type": "splitter", + "squash": false, + "trim": false, + "changes": { + "default": [[0, 1, 0, 2]] + }, + "matchings": {}, + "policies": ["default"], + "before": "< i", + "after": " { + const codes: number[] = []; + const offsets: number[] = []; + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i); + if (isPunctuation(c)) { + codes.push(c); + offsets.push(i); + } + } + return { codes, offsets }; + }; + const chars1 = chars(text1); + const chars2 = chars(text2); + const changes = diffSequences(chars1.codes, chars2.codes); + const builder = new ChangeBuilder(text1.length, text2.length); + for (const r of unchanged(changes)) + for (let i = 0; i < r.end1 - r.start1; i++) + builder.markEqual(chars1.offsets[r.start1 + i]!, chars2.offsets[r.start2 + i]!); + return builder.finish(); +} diff --git a/src/features/vcs/comparison/byLine.ts b/src/features/vcs/comparison/byLine.ts new file mode 100644 index 00000000..caaa8e01 --- /dev/null +++ b/src/features/vcs/comparison/byLine.ts @@ -0,0 +1,205 @@ +/** + * Line-level comparison (IntelliJ's ByLineRt with SmartLineChangeCorrector, + * ported; Apache-2.0, Copyright JetBrains s.r.o. and contributors): lines are + * first matched ignoring whitespace and "unimportant" lines (blank, `}`), the + * gaps are filled, boundaries optimized, then whitespace-only matches that + * the policy doesn't allow are corrected. + */ +import { optimizeLineChunks, UNIMPORTANT_LINE_CHAR_COUNT, type LineInfo } from "./chunkOptimizer"; +import { + ChangeBuilder, + create, + type DiffIterable, + diffSequences, + expandEqual, + ExpandChangeBuilder, + unchanged, +} from "./iterables"; +import { isEmptyRange, type Range } from "./lcs"; +import { type ComparisonPolicy, isSpace, lineKey } from "./text"; + +interface Line extends LineInfo { + readonly content: string; + /** The key ignoring whitespace. */ + readonly iwKey: string; +} + +function toLine(content: string, policy: ComparisonPolicy): Line { + let nonSpaceChars = 0; + for (let i = 0; i < content.length; i++) if (!isSpace(content[i])) nonSpaceChars++; + const iwKey = lineKey(content, "ignore"); + return { content, key: policy === "ignore" ? iwKey : lineKey(content, policy), iwKey, nonSpaceChars }; +} + +/** Line changes between two lists of line texts (without line breaks). */ +export function compareLines( + lines1: readonly string[], + lines2: readonly string[], + policy: ComparisonPolicy, +): DiffIterable { + const l1 = lines1.map((l) => toLine(l, policy)); + const l2 = lines2.map((l) => toLine(l, policy)); + const iw1 = l1.map((l) => l.iwKey); + const iw2 = l2.map((l) => l.iwKey); + if (policy === "ignore") { + const changes = optimizeLineChunks(l1, l2, compareSmart(l1, l2, iw1, iw2)); + const keys1 = l1.map((l) => l.key); + const keys2 = l2.map((l) => l.key); + const expanded: Range[] = []; + for (const c of changes.changes) { + const r = expandEqual(keys1, keys2, c.start1, c.start2, c.end1, c.end2); + if (!isEmptyRange(r)) expanded.push(r); + } + return create(expanded, l1.length, l2.length); + } + const iwChanges = optimizeLineChunks(l1, l2, compareSmart(l1, l2, iw1, iw2)); + return correctChangesSecondStep(l1, l2, iwChanges); +} + +/** + * Matches lines ignoring the unimportant ones, then compares the gaps between + * the matches with all lines (compareSmart + SmartLineChangeCorrector). + */ +function compareSmart( + lines1: readonly Line[], + lines2: readonly Line[], + keys1: string[], + keys2: string[], +): DiffIterable { + const big = (lines: readonly Line[]) => { + const indexes: number[] = []; + lines.forEach((l, i) => l.nonSpaceChars > UNIMPORTANT_LINE_CHAR_COUNT && indexes.push(i)); + return indexes; + }; + const indexes1 = big(lines1); + const indexes2 = big(lines2); + const changes = diffSequences( + indexes1.map((i) => keys1[i]!), + indexes2.map((i) => keys2[i]!), + ); + const builder = new ChangeBuilder(lines1.length, lines2.length); + const matchGap = (start1: number, end1: number, start2: number, end2: number) => { + const expand = expandEqual(keys1, keys2, start1, start2, end1, end2); + const inner = diffSequences(keys1.slice(expand.start1, expand.end1), keys2.slice(expand.start2, expand.end2)); + builder.markEqual(start1, start2, expand.start1, expand.start2); + for (const r of unchanged(inner)) + builder.markEqualCount(expand.start1 + r.start1, expand.start2 + r.start2, r.end1 - r.start1); + builder.markEqual(expand.end1, expand.end2, end1, end2); + }; + let last1 = 0; + let last2 = 0; + for (const r of unchanged(changes)) { + for (let i = 0; i < r.end1 - r.start1; i++) { + const start1 = indexes1[r.start1 + i]!; + const start2 = indexes2[r.start2 + i]!; + matchGap(last1, start1, last2, start2); + builder.markEqual(start1, start2, start1 + 1, start2 + 1); + last1 = start1 + 1; + last2 = start2 + 1; + } + } + matchGap(last1, lines1.length, last2, lines2.length); + return builder.finish(); +} + +/** + * Fixes lines matched ignoring whitespace but not equal under the policy + * (ByLineRt.correctChangesSecondStep). Equal lines stay matched, unmatched + * stay unmatched; for whitespace-equal runs the alignment with the most equal + * pairs wins, so ".{ ..{ ...{" vs "..{ ...{" matches the last two. + */ +function correctChangesSecondStep( + lines1: readonly Line[], + lines2: readonly Line[], + changes: DiffIterable, +): DiffIterable { + const builder = new ExpandChangeBuilder( + lines1.map((l) => l.key), + lines2.map((l) => l.key), + ); + const equal = (i1: number, i2: number) => lines1[i1]!.key === lines2[i2]!.key; + let sample: string | null = null; + let last1 = 0; + let last2 = 0; + + const alignExactMatching = (sub1: number[], sub2: number[]) => { + const n = Math.max(sub1.length, sub2.length); + // brute force over the combinations: limit the search space + if (n > 10 || sub1.length === sub2.length) { + for (let i = 0; i < Math.min(sub1.length, sub2.length); i++) + if (equal(sub1[i]!, sub2[i]!)) builder.markEqual(sub1[i]!, sub2[i]!); + return; + } + if (sub1.length < sub2.length) { + const matching = bestMatchingAlignment(sub1, sub2, equal); + for (let i = 0; i < sub1.length; i++) { + const index2 = sub2[matching[i]!]!; + if (equal(sub1[i]!, index2)) builder.markEqual(sub1[i]!, index2); + } + } else { + const matching = bestMatchingAlignment(sub2, sub1, (i2, i1) => equal(i1, i2)); + for (let i = 0; i < sub2.length; i++) { + const index1 = sub1[matching[i]!]!; + if (equal(index1, sub2[i]!)) builder.markEqual(index1, sub2[i]!); + } + } + }; + + const flush = (line1: number, line2: number) => { + if (sample === null) return; + const start1 = Math.max(last1, builder.index1); + const start2 = Math.max(last2, builder.index2); + const sub1: number[] = []; + const sub2: number[] = []; + for (let i = start1; i < line1; i++) + if (lines1[i]!.iwKey === sample) { + sub1.push(i); + last1 = i + 1; + } + for (let i = start2; i < line2; i++) + if (lines2[i]!.iwKey === sample) { + sub2.push(i); + last2 = i + 1; + } + alignExactMatching(sub1, sub2); + sample = null; + }; + + for (const r of unchanged(changes)) { + for (let i = 0; i < r.end1 - r.start1; i++) { + const index1 = r.start1 + i; + const index2 = r.start2 + i; + if (sample === lines1[index1]!.iwKey) continue; + flush(index1, index2); + if (equal(index1, index2)) builder.markEqual(index1, index2); + else sample = lines1[index1]!.iwKey; + } + } + flush(changes.length1, changes.length2); + return builder.finish(); +} + +/** The combination of `sub2` positions for `sub1` with the most equal pairs (getBestMatchingAlignment). */ +function bestMatchingAlignment(sub1: number[], sub2: number[], equal: (i1: number, i2: number) => boolean): number[] { + const size = sub1.length; + const comb = new Array(size).fill(0); + let best = Array.from({ length: size }, (_, i) => i); + let bestWeight = 0; + const combinations = (start: number, n: number, k: number) => { + if (k === size) { + let weight = 0; + for (let i = 0; i < size; i++) if (equal(sub1[i]!, sub2[comb[i]!]!)) weight++; + if (weight > bestWeight) { + bestWeight = weight; + best = [...comb]; + } + return; + } + for (let i = start; i <= n; i++) { + comb[k] = i; + combinations(i + 1, n, k + 1); + } + }; + combinations(0, sub2.length - 1, 0); + return best; +} diff --git a/src/features/vcs/comparison/byWord.ts b/src/features/vcs/comparison/byWord.ts new file mode 100644 index 00000000..f78c581f --- /dev/null +++ b/src/features/vcs/comparison/byWord.ts @@ -0,0 +1,428 @@ +/** + * Word-level comparison of a changed block (IntelliJ's ByWordRt + * .compareAndSplit with LineFragmentSplitter, ported; Apache-2.0, Copyright + * JetBrains s.r.o. and contributors): words and line breaks are diffed, the + * block is split into "logically different" line blocks, punctuation between + * matched words is matched by character, and whitespace by the policy. + */ +import { comparePunctuation } from "./byChar"; +import { optimizeWordChunks, type WordChunk } from "./chunkOptimizer"; +import { ChangeBuilder, create, type DiffIterable, diffSequences, subIterable, unchanged } from "./iterables"; +import { isEmptyRange, range, type Range } from "./lcs"; +import { + type ComparisonPolicy, + charCount, + expandWhitespaces, + expandWhitespacesBackward, + expandWhitespacesForward, + isAlpha, + isContinuousScript, + isEqualsIgnoreWhitespacesRange, + isEqualsRange, + isSpace, + trimEnd, + trimRange, + trimStart, +} from "./text"; + +export interface InlineChunk extends WordChunk { + /** Equal keys are equal chunks: the word itself, or "\n". */ + readonly key: string; +} + +/** + * The words of a text (ByWordRt.getInlineChunks): runs of letters and digits + * (anything but ASCII whitespace and punctuation), single characters of + * scripts written without spaces, and line breaks. Punctuation is matched + * separately. + */ +export function getInlineChunks(text: string): InlineChunk[] { + const chunks: InlineChunk[] = []; + const word = (from: number, to: number): InlineChunk => ({ + offset1: from, + offset2: to, + newline: false, + key: text.slice(from, to), + }); + let wordStart = -1; + for (let offset = 0; offset < text.length;) { + const ch = text.codePointAt(offset)!; + const n = charCount(ch); + const alpha = isAlpha(ch); + if (alpha && !isContinuousScript(ch)) { + if (wordStart === -1) wordStart = offset; + } else { + if (wordStart !== -1) { + chunks.push(word(wordStart, offset)); + wordStart = -1; + } + if (alpha) chunks.push(word(offset, offset + n)); + else if (ch === 10) chunks.push({ offset1: offset, offset2: offset + 1, newline: true, key: "\n" }); + } + offset += n; + } + if (wordStart !== -1) chunks.push(word(wordStart, text.length)); + return chunks; +} + +/** Word changes of two texts (ByWordRt.compare). */ +export function compareWords(text1: string, text2: string, policy: ComparisonPolicy): readonly Range[] { + const words1 = getInlineChunks(text1); + const words2 = getInlineChunks(text2); + const keys1 = words1.map((w) => w.key); + const keys2 = words2.map((w) => w.key); + let wordChanges = diffSequences(keys1, keys2); + wordChanges = optimizeWordChunks(keys1, keys2, words1, words2, text1, text2, wordChanges); + const delimiters = matchPunctuation(text1, text2, words1, words2, 0, 0, wordChanges); + return matchWhitespaces(text1, text2, delimiters, policy).changes; +} + +export interface LineBlock { + /** Changed ranges of the block's texts, relative to `offsets`. */ + readonly fragments: readonly Range[]; + readonly offsets: Range; + readonly newlines1: number; + readonly newlines2: number; +} + +/** Word changes of a changed line block, split into line blocks (ByWordRt.compareAndSplit). */ +export function compareAndSplit(text1: string, text2: string, policy: ComparisonPolicy): LineBlock[] { + const words1 = getInlineChunks(text1); + const words2 = getInlineChunks(text2); + const keys1 = words1.map((w) => w.key); + const keys2 = words2.map((w) => w.key); + let wordChanges = diffSequences(keys1, keys2); + wordChanges = optimizeWordChunks(keys1, keys2, words1, words2, text1, text2, wordChanges); + const wordBlocks = splitLineFragments(text1, text2, words1, words2, wordChanges); + + const changed = wordChanges.changes; + let index = 0; + return wordBlocks.map((block) => { + const { offsets, words } = block; + while (index < changed.length) { + const r = changed[index]!; + if (r.end1 < words.start1 || r.end2 < words.start2) index++; + else break; + } + const sub = subIterable(changed, words.start1, words.end1, words.start2, words.end2, index); + const subtext1 = text1.slice(offsets.start1, offsets.end1); + const subtext2 = text2.slice(offsets.start2, offsets.end2); + const subwords1 = words1.slice(words.start1, words.end1); + const subwords2 = words2.slice(words.start2, words.end2); + const delimiters = matchPunctuation(subtext1, subtext2, subwords1, subwords2, offsets.start1, offsets.start2, sub); + const iterable = matchWhitespaces(subtext1, subtext2, delimiters, policy); + return { + fragments: iterable.changes, + offsets, + newlines1: subwords1.filter((w) => w.newline).length, + newlines2: subwords2.filter((w) => w.newline).length, + }; + }); +} + +interface WordBlock { + readonly words: Range; + readonly offsets: Range; +} + +interface PendingChunk { + readonly block: WordBlock; + readonly hasEqualWords: boolean; + readonly hasWordsInside: boolean; + readonly isEqualIgnoreWhitespaces: boolean; +} + +/** + * Splits a changed block at matched line breaks and at matched first words of + * lines (LineFragmentSplitter). Word indexes: -1 is the start of the text, + * `words.length` its end. + */ +function splitLineFragments( + text1: string, + text2: string, + words1: readonly InlineChunk[], + words2: readonly InlineChunk[], + iterable: DiffIterable, +): WordBlock[] { + const result: WordBlock[] = []; + let last1 = -1; + let last2 = -1; + let pending: PendingChunk | null = null; + + const offset = (words: readonly InlineChunk[], text: string, index: number) => + index === -1 ? 0 : index === words.length ? text.length : words[index]!.offset2; + + const createChunk = (start1: number, start2: number, end1: number, end2: number, hasEqualWords: boolean) => { + const offsets = range( + offset(words1, text1, start1), + offset(words1, text1, end1), + offset(words2, text2, start2), + offset(words2, text2, end2), + ); + const words = range( + Math.max(0, start1 + 1), + Math.min(end1 + 1, words1.length), + Math.max(0, start2 + 1), + Math.min(end2 + 1, words2.length), + ); + const block = { words, offsets }; + let hasWordsInside = false; + for (let i = words.start1; i < words.end1 && !hasWordsInside; i++) hasWordsInside = !words1[i]!.newline; + for (let i = words.start2; i < words.end2 && !hasWordsInside; i++) hasWordsInside = !words2[i]!.newline; + return { + block, + hasEqualWords, + hasWordsInside, + isEqualIgnoreWhitespaces: isEqualsIgnoreWhitespacesRange(text1, text2, offsets), + }; + }; + + const shouldMerge = (c1: PendingChunk, c2: PendingChunk) => + // lines matched only by '\n' + (!c1.hasEqualWords && !c2.hasEqualWords) || + // whitespace-only changed lines + (c1.isEqualIgnoreWhitespaces && c2.isEqualIgnoreWhitespaces) || + // a block without words in it + !c1.hasWordsInside || + !c2.hasWordsInside; + + const merge = (c1: PendingChunk, c2: PendingChunk): PendingChunk => ({ + block: { + words: range(c1.block.words.start1, c2.block.words.end1, c1.block.words.start2, c2.block.words.end2), + offsets: range(c1.block.offsets.start1, c2.block.offsets.end1, c1.block.offsets.start2, c2.block.offsets.end2), + }, + hasEqualWords: c1.hasEqualWords || c2.hasEqualWords, + hasWordsInside: c1.hasWordsInside || c2.hasWordsInside, + isEqualIgnoreWhitespaces: c1.isEqualIgnoreWhitespaces && c2.isEqualIgnoreWhitespaces, + }); + + const addLineChunk = (end1: number, end2: number, hasEqualWords: boolean) => { + if (last1 > end1 || last2 > end2) return; + const chunk = createChunk(last1, last2, end1, end2, hasEqualWords); + if (isEmptyRange(chunk.block.offsets)) return; + if (pending && shouldMerge(pending, chunk)) pending = merge(pending, chunk); + else { + if (pending) result.push(pending.block); + pending = chunk; + } + last1 = end1; + last2 = end2; + }; + + const firstInLine = (words: readonly InlineChunk[], index: number) => index === 0 || words[index - 1]!.newline; + + let hasEqualWords = false; + for (const r of unchanged(iterable)) { + for (let i = 0; i < r.end1 - r.start1; i++) { + const index1 = r.start1 + i; + const index2 = r.start2 + i; + if (words1[index1]!.newline && words2[index2]!.newline) { + // split by matched newlines + addLineChunk(index1, index2, hasEqualWords); + hasEqualWords = false; + } else { + if (firstInLine(words1, index1) && firstInLine(words2, index2)) { + // split by matched first word in line + addLineChunk(index1 - 1, index2 - 1, hasEqualWords); + hasEqualWords = false; + } + hasEqualWords = true; + } + } + } + addLineChunk(words1.length, words2.length, hasEqualWords); + if (pending) result.push((pending as PendingChunk).block); + return result; +} + +/** + * Matches punctuation between words (AdjustmentPunctuationMatcher): the + * separators around each pair of matched words are compared by character, so + * "[ X { A ! B } Y ]" vs "( X ... Y )" compares "[" with "(", "{" + "}" with + * "..." and "]" with ")". + */ +function matchPunctuation( + text1: string, + text2: string, + words1: readonly InlineChunk[], + words2: readonly InlineChunk[], + startShift1: number, + startShift2: number, + changes: DiffIterable, +): DiffIterable { + const len1 = text1.length; + const len2 = text2.length; + const builder = new ChangeBuilder(len1, len2); + const startOffset1 = (i: number) => words1[i]!.offset1 - startShift1; + const startOffset2 = (i: number) => words2[i]!.offset1 - startShift2; + const endOffset1 = (i: number) => words1[i]!.offset2 - startShift1; + const endOffset2 = (i: number) => words2[i]!.offset2 - startShift2; + let lastStart1 = -1; + let lastStart2 = -1; + let lastEnd1 = -1; + let lastEnd2 = -1; + + const matchRange = (start1: number, start2: number, end1: number, end2: number) => { + if (start1 === end1 && start2 === end2) return; + const inner = comparePunctuation(text1.slice(start1, end1), text2.slice(start2, end2)); + for (const r of unchanged(inner)) + builder.markEqual(start1 + r.start1, start2 + r.start2, start1 + r.end1, start2 + r.end2); + }; + + // One sequence compared with two others as if they were one (comparePunctuation2Side). + const compare2Side = (text: string, text21: string, text22: string): [Range[], Range[]] => { + const changes = comparePunctuation(text, text21 + text22); + const ranges1: Range[] = []; + const ranges2: Range[] = []; + const split = text21.length; + for (const r of unchanged(changes)) { + if (r.end2 <= split) ranges1.push(r); + else if (r.start2 >= split) ranges2.push(range(r.start1, r.end1, r.start2 - split, r.end2 - split)); + else { + const len = split - r.start2; + ranges1.push(range(r.start1, r.start1 + len, r.start2, split)); + ranges2.push(range(r.start1 + len, r.end1, 0, r.end2 - split)); + } + } + return [ranges1, ranges2]; + }; + + const matchComplexRange = ( + start11: number, + start12: number, + end11: number, + end12: number, + start21: number, + start22: number, + end21: number, + end22: number, + ) => { + if (start11 === start21 && end11 === end21) { + const [first, second] = compare2Side( + text1.slice(start11, end11), + text2.slice(start12, end12), + text2.slice(start22, end22), + ); + for (const r of first) + builder.markEqual(start11 + r.start1, start12 + r.start2, start11 + r.end1, start12 + r.end2); + for (const r of second) + builder.markEqual(start11 + r.start1, start22 + r.start2, start11 + r.end1, start22 + r.end2); + } else if (start12 === start22 && end12 === end22) { + // mirrored: two left sides against one right side + const [first, second] = compare2Side( + text2.slice(start12, end12), + text1.slice(start11, end11), + text1.slice(start21, end21), + ); + for (const r of first) + builder.markEqual(start11 + r.start2, start12 + r.start1, start11 + r.end2, start12 + r.end1); + for (const r of second) + builder.markEqual(start21 + r.start2, start12 + r.start1, start21 + r.end2, start12 + r.end1); + } else { + throw new Error("unexpected punctuation ranges"); + } + }; + + const matchForward = (index1: number, index2: number) => { + lastStart1 = index1 === -1 ? 0 : endOffset1(index1); + lastStart2 = index2 === -1 ? 0 : endOffset2(index2); + lastEnd1 = index1 + 1 === words1.length ? len1 : startOffset1(index1 + 1); + lastEnd2 = index2 + 1 === words2.length ? len2 : startOffset2(index2 + 1); + }; + + const matchBackward = (index1: number, index2: number) => { + const start1 = index1 === 0 ? 0 : endOffset1(index1 - 1); + const start2 = index2 === 0 ? 0 : endOffset2(index2 - 1); + const end1 = index1 === words1.length ? len1 : startOffset1(index1); + const end2 = index2 === words2.length ? len2 : startOffset2(index2); + if (lastStart1 === start1 && lastStart2 === start2) { + // adjacent matched words, match the gap between ("A B" - "A B") + matchRange(start1, start2, end1, end2); + } else if (lastStart1 < start1 && lastStart2 < start2) { + // matched words with unmatched ones between ("A X B" - "A Y B") + matchRange(lastStart1, lastStart2, lastEnd1, lastEnd2); + matchRange(start1, start2, end1, end2); + } else { + // one side adjacent, the other with unmatched words between ("A B" - "A Y B") + matchComplexRange(lastStart1, lastStart2, lastEnd1, lastEnd2, start1, start2, end1, end2); + } + lastStart1 = lastStart2 = lastEnd1 = lastEnd2 = -1; + }; + + matchForward(-1, -1); + for (const r of unchanged(changes)) { + for (let i = 0; i < r.end1 - r.start1; i++) { + const index1 = r.start1 + i; + const index2 = r.start2 + i; + matchBackward(index1, index2); + builder.markEqual(startOffset1(index1), startOffset2(index2), endOffset1(index1), endOffset2(index2)); + matchForward(index1, index2); + } + } + matchBackward(words1.length, words2.length); + return builder.finish(); +} + +/** Whitespace handling of the changes by policy (ByWordRt.matchAdjustmentWhitespaces). */ +export function matchWhitespaces(text1: string, text2: string, iterable: DiffIterable, policy: ComparisonPolicy) { + if (policy === "ignore") return ignoreSpaces(iterable, text1, text2); + const corrected = defaultSpaces(iterable, text1, text2); + return policy === "trim" ? trimSpaces(corrected, text1, text2) : corrected; +} + +/** Equal whitespace at the edges of a change is unchanged (DefaultCorrector). */ +function defaultSpaces(iterable: DiffIterable, text1: string, text2: string): DiffIterable { + const changes: Range[] = []; + for (const r of iterable.changes) { + const endCut = expandWhitespacesBackward(text1, text2, r.start1, r.start2, r.end1, r.end2); + const startCut = expandWhitespacesForward(text1, text2, r.start1, r.start2, r.end1 - endCut, r.end2 - endCut); + const expanded = range(r.start1 + startCut, r.end1 - endCut, r.start2 + startCut, r.end2 - endCut); + if (!isEmptyRange(expanded)) changes.push(expanded); + } + return create(changes, text1.length, text2.length); +} + +/** Whitespace is matched where possible and ignored where not (IgnoreSpacesCorrector). */ +function ignoreSpaces(iterable: DiffIterable, text1: string, text2: string): DiffIterable { + const changes: Range[] = []; + for (const r of iterable.changes) { + const trimmed = trimRange(text1, text2, expandWhitespaces(text1, text2, r)); + if (!isEmptyRange(trimmed) && !isEqualsIgnoreWhitespacesRange(text1, text2, trimmed)) changes.push(trimmed); + } + return create(changes, text1.length, text2.length); +} + +function isLeadingSpace(text: string, start: number): boolean { + if (start < 0 || start === text.length || !isSpace(text[start])) return false; + for (let i = start - 1; i >= 0; i--) { + if (text[i] === "\n") return true; + if (!isSpace(text[i])) return false; + } + return true; +} + +function isTrailingSpace(text: string, end: number): boolean { + if (end < 0 || end === text.length || !isSpace(text[end])) return false; + for (let i = end; i < text.length; i++) { + if (text[i] === "\n") return true; + if (!isSpace(text[i])) return false; + } + return true; +} + +const isLeadingTrailingSpace = (text: string, i: number) => isLeadingSpace(text, i) || isTrailingSpace(text, i); + +/** Leading and trailing whitespace of lines is not a change (TrimSpacesCorrector). */ +function trimSpaces(iterable: DiffIterable, text1: string, text2: string): DiffIterable { + const changes: Range[] = []; + for (const r of iterable.changes) { + let { start1, start2, end1, end2 } = r; + if (isLeadingTrailingSpace(text1, start1)) start1 = trimStart(text1, start1, end1); + if (isLeadingTrailingSpace(text1, end1 - 1)) end1 = trimEnd(text1, start1, end1); + if (isLeadingTrailingSpace(text2, start2)) start2 = trimStart(text2, start2, end2); + if (isLeadingTrailingSpace(text2, end2 - 1)) end2 = trimEnd(text2, start2, end2); + const trimmed = range(start1, end1, start2, end2); + if (!isEmptyRange(trimmed) && !isEqualsRange(text1, text2, trimmed)) changes.push(trimmed); + } + return create(changes, text1.length, text2.length); +} diff --git a/src/features/vcs/comparison/chunkOptimizer.ts b/src/features/vcs/comparison/chunkOptimizer.ts new file mode 100644 index 00000000..6b5f767f --- /dev/null +++ b/src/features/vcs/comparison/chunkOptimizer.ts @@ -0,0 +1,183 @@ +/** + * Shifts ambiguous changes to where people expect them (IntelliJ's + * ChunkOptimizer, ported; Apache-2.0, Copyright JetBrains s.r.o. and + * contributors): touching changes are merged, and an insertion or deletion + * that could sit at several places moves to a word boundary (words) or next to + * an empty line (lines). + */ +import { createUnchanged, expandBackward, expandForward, type DiffIterable, unchanged } from "./iterables"; +import { range, type Range } from "./lcs"; +import { isSpace } from "./text"; + +/** `touchLeft`: the changes touch on the left side; returns the shift (> 0 forward, < 0 backward). */ +type GetShift = ( + touchLeft: boolean, + equalForward: number, + equalBackward: number, + range1: Range, + range2: Range, +) => number; + +function optimize(data1: readonly T[], data2: readonly T[], iterable: DiffIterable, getShift: GetShift) { + const ranges: Range[] = []; + const processLastRanges = (): void => { + if (ranges.length < 2) return; + const range1 = ranges[ranges.length - 2]!; + const range2 = ranges[ranges.length - 1]!; + // Changes that don't touch can't be optimized (the input would not be an LCS). + if (range1.end1 !== range2.start1 && range1.end2 !== range2.start2) return; + const count1 = range1.end1 - range1.start1; + const count2 = range2.end1 - range2.start1; + const equalForward = expandForward( + data1, + data2, + range1.end1, + range1.end2, + Math.min(range1.end1 + count2, data1.length), + Math.min(range1.end2 + count2, data2.length), + ); + const equalBackward = expandBackward( + data1, + data2, + Math.max(range2.start1 - count1, 0), + Math.max(range2.start2 - count1, 0), + range2.start1, + range2.start2, + ); + if (equalForward === 0 && equalBackward === 0) return; + // merge chunks left [A]B[B] -> [AB]B + if (equalForward === count2) { + ranges.splice(-2, 2, range(range1.start1, range1.end1 + count2, range1.start2, range1.end2 + count2)); + return processLastRanges(); + } + // merge chunks right [A]A[B] -> A[AB] + if (equalBackward === count1) { + ranges.splice(-2, 2, range(range2.start1 - count1, range2.end1, range2.start2 - count1, range2.end2)); + return processLastRanges(); + } + const shift = getShift(range1.end1 === range2.start1, equalForward, equalBackward, range1, range2); + if (shift !== 0) + ranges.splice( + -2, + 2, + range(range1.start1, range1.end1 + shift, range1.start2, range1.end2 + shift), + range(range2.start1 + shift, range2.end1, range2.start2 + shift, range2.end2), + ); + }; + for (const r of unchanged(iterable)) { + ranges.push(r); + processLastRanges(); + } + return createUnchanged(ranges, data1.length, data2.length); +} + +export interface WordChunk { + readonly offset1: number; + readonly offset2: number; + readonly newline: boolean; +} + +/** + * Words: fewer chunks, and fewer changed "sentences" (runs of words not + * separated by whitespace): "[AX] [AZ]" - "[AX] AY [AZ]", not "[AX A][Z]". + */ +export function optimizeWordChunks( + keys1: readonly string[], + keys2: readonly string[], + words1: readonly WordChunk[], + words2: readonly WordChunk[], + text1: string, + text2: string, + iterable: DiffIterable, +): DiffIterable { + const separated = (text: string, word1: WordChunk, word2: WordChunk) => { + if (word1.newline || word2.newline) return true; + for (let i = word1.offset2; i < word2.offset1; i++) if (isSpace(text[i])) return true; + return false; + }; + const edgeShift = (text: string, words: readonly WordChunk[], offset: number, count: number, ltr: boolean) => { + for (let i = 0; i < count; i++) { + const [w1, w2] = ltr + ? [words[offset + i]!, words[offset + i + 1]!] + : [words[offset - i - 1]!, words[offset - i]!]; + if (separated(text, w1, w2)) return i + 1; + } + return -1; + }; + return optimize(keys1, keys2, iterable, (touchLeft, equalForward, equalBackward, _range1, range2) => { + const words = touchLeft ? words1 : words2; + const text = touchLeft ? text1 : text2; + const touchStart = touchLeft ? range2.start1 : range2.start2; + // already separated by whitespace + if (separated(text, words[touchStart - 1]!, words[touchStart]!)) return 0; + // shift chunks left [X]A Y[A ZA] -> [XA] YA [ZA] + const left = edgeShift(text, words, touchStart, equalForward, true); + if (left > 0) return left; + // shift chunks right [AX A]Y A[Z] -> [AX] AY [AZ] + const right = edgeShift(text, words, touchStart - 1, equalBackward, false); + if (right > 0) return -right; + return 0; + }); +} + +/** Lines with at most this many non-space characters are "unimportant" (`}`, blank). */ +export const UNIMPORTANT_LINE_CHAR_COUNT = 3; + +export interface LineInfo { + readonly key: string; + readonly nonSpaceChars: number; +} + +/** + * Lines: fewer chunks, and insertions / deletions bounded by an empty (or + * unimportant) line: "ABooYZ [ABuuYZ ]ABzzYZ", not "ABooYZ AB[uuYZ AB]zzYZ". + */ +export function optimizeLineChunks( + lines1: readonly LineInfo[], + lines2: readonly LineInfo[], + iterable: DiffIterable, +): DiffIterable { + const nextUnimportant = (lines: readonly LineInfo[], offset: number, count: number, threshold: number) => { + for (let i = 0; i < count; i++) if (lines[offset + i]!.nonSpaceChars <= threshold) return i; + return -1; + }; + const prevUnimportant = (lines: readonly LineInfo[], offset: number, count: number, threshold: number) => { + for (let i = 0; i < count; i++) if (lines[offset - i]!.nonSpaceChars <= threshold) return i; + return -1; + }; + const toShift = (forward: number, backward: number): number | null => { + if (forward === -1 && backward === -1) return null; + if (forward === 0 || backward === 0) return 0; + return forward !== -1 ? forward : -backward; + }; + const keys1 = lines1.map((l) => l.key); + const keys2 = lines2.map((l) => l.key); + return optimize(keys1, keys2, iterable, (touchLeft, equalForward, equalBackward, range1, range2) => { + // an empty line boundary in unchanged lines: the insertion / deletion goes right before / after it + const unchangedBoundary = (threshold: number) => { + const lines = touchLeft ? lines1 : lines2; + const touchStart = touchLeft ? range2.start1 : range2.start2; + return toShift( + nextUnimportant(lines, touchStart, equalForward + 1, threshold), + prevUnimportant(lines, touchStart - 1, equalBackward + 1, threshold), + ); + }; + // an empty line boundary in changed lines: the insertion / deletion starts / ends with it + const changedBoundary = (threshold: number) => { + const lines = touchLeft ? lines2 : lines1; + const changeStart = touchLeft ? range1.end2 : range1.end1; + const changeEnd = touchLeft ? range2.start2 : range2.start1; + return toShift( + nextUnimportant(lines, changeStart, equalForward + 1, threshold), + prevUnimportant(lines, changeEnd - 1, equalBackward + 1, threshold), + ); + }; + return ( + unchangedBoundary(0) ?? + changedBoundary(0) ?? + unchangedBoundary(UNIMPORTANT_LINE_CHAR_COUNT) ?? + changedBoundary(UNIMPORTANT_LINE_CHAR_COUNT) ?? + 0 + ); + }); +} diff --git a/src/features/vcs/comparison/index.ts b/src/features/vcs/comparison/index.ts new file mode 100644 index 00000000..aec19be3 --- /dev/null +++ b/src/features/vcs/comparison/index.ts @@ -0,0 +1,255 @@ +/** + * IntelliJ's text comparison (ComparisonManagerImpl.compareLinesInner and + * processBlocks, ported; Apache-2.0, Copyright JetBrains s.r.o. and + * contributors): lines are compared, each changed block is compared by words + * and split into line blocks, then adjoining blocks are squashed back and, + * when ignoring whitespace and empty lines, whitespace-only blocks trimmed. + */ +import { compareLines } from "./byLine"; +import { compareAndSplit } from "./byWord"; +import { DiffTooBigError, type Range } from "./lcs"; +import { type ComparisonPolicy, isEqualTexts, stripSpaces } from "./text"; + +export type { ComparisonPolicy } from "./text"; +export type { Range } from "./lcs"; +export { compareWords } from "./byWord"; + +export interface LineFragment { + readonly startLine1: number; + readonly endLine1: number; + readonly startLine2: number; + readonly endLine2: number; + /** Text offsets; the end includes the last line's line break, when it has one. */ + readonly startOffset1: number; + readonly endOffset1: number; + readonly startOffset2: number; + readonly endOffset2: number; + /** + * Changed ranges inside the fragment, relative to its start offsets; null + * when not computed (by-line highlighting, or a block too big to compare). + */ + readonly inner: readonly Range[] | null; +} + +export interface CompareOptions { + policy: ComparisonPolicy; + /** Compare changed blocks by words ("Highlight words"). */ + words: boolean; + /** Drop leading / trailing whitespace-only blocks ("Ignore whitespaces and empty lines"). */ + trimChunks?: boolean; + /** Keep the line blocks words split a change into ("Highlight split changes"). */ + split?: boolean; +} + +/** Do not compare by words after that many blocks were too big (DiffConfig.MAX_BAD_LINES). */ +const MAX_BAD_LINES = 3; + +class LineOffsets { + private readonly starts: number[] = [0]; + + constructor(readonly text: string) { + for (let i = text.indexOf("\n"); i >= 0; i = text.indexOf("\n", i + 1)) this.starts.push(i + 1); + } + + get lineCount() { + return this.starts.length; + } + + lineStart(line: number) { + return this.starts[line]!; + } + + lineEnd(line: number, includeNewline = false) { + if (line === this.starts.length - 1) return this.text.length; + return this.starts[line + 1]! - (includeNewline ? 0 : 1); + } + + lines(): string[] { + return this.starts.map((_, i) => this.text.slice(this.lineStart(i), this.lineEnd(i))); + } + + /** Offsets of lines `[start, end)` (ComparisonManagerImpl.getOffsets). */ + offsets(start: number, end: number): [number, number] { + if (start === end) { + const offset = start < this.lineCount ? this.lineStart(start) : this.lineEnd(this.lineCount - 1, true); + return [offset, offset]; + } + return [this.lineStart(start), this.lineEnd(end - 1, true)]; + } +} + +/** Line fragments of two texts with their inner word changes, as IDEA's diff viewer computes them. */ +export function compareTexts(text1: string, text2: string, options: CompareOptions): LineFragment[] { + const { policy } = options; + const fragments = options.words + ? compareLinesInner(text1, text2, policy) + : compareLineFragments(text1, text2, policy); + return processBlocks(fragments, text1, text2, policy, !options.split, !!options.trimChunks); +} + +/** Changed lines (ComparisonManagerImpl.compareLines). */ +export function compareLineFragments(text1: string, text2: string, policy: ComparisonPolicy): LineFragment[] { + const offsets1 = new LineOffsets(text1); + const offsets2 = new LineOffsets(text2); + return compareLines(offsets1.lines(), offsets2.lines(), policy).changes.map((r) => { + const [startOffset1, endOffset1] = offsets1.offsets(r.start1, r.end1); + const [startOffset2, endOffset2] = offsets2.offsets(r.start2, r.end2); + return { + startLine1: r.start1, + endLine1: r.end1, + startLine2: r.start2, + endLine2: r.end2, + startOffset1, + endOffset1, + startOffset2, + endOffset2, + inner: null, + }; + }); +} + +/** Changed lines split into line blocks with their word changes (ComparisonManagerImpl.compareLinesInner). */ +export function compareLinesInner(text1: string, text2: string, policy: ComparisonPolicy): LineFragment[] { + return innerFragments(compareLineFragments(text1, text2, policy), text1, text2, policy); +} + +function innerFragments( + fragments: readonly LineFragment[], + text1: string, + text2: string, + policy: ComparisonPolicy, +): LineFragment[] { + const out: LineFragment[] = []; + let tooBig = 0; + for (const f of fragments) { + const sub1 = text1.slice(f.startOffset1, f.endOffset1); + const sub2 = text2.slice(f.startOffset2, f.endOffset2); + if (f.startLine1 === f.endLine1 || f.startLine2 === f.endLine2) { + // insertion / deletion + out.push(isEqualTexts(sub1, sub2, policy) ? { ...f, inner: [] } : f); + continue; + } + if (tooBig >= MAX_BAD_LINES) { + out.push(f); + continue; + } + try { + const blocks = compareAndSplit(sub1, sub2, policy); + let line1 = f.startLine1; + let line2 = f.startLine2; + blocks.forEach((block, i) => { + // the last block ends with the fragment, whatever its last line + const last = i === blocks.length - 1; + const endLine1 = last ? f.endLine1 : line1 + block.newlines1; + const endLine2 = last ? f.endLine2 : line2 + block.newlines2; + out.push({ + startLine1: line1, + endLine1, + startLine2: line2, + endLine2, + startOffset1: f.startOffset1 + block.offsets.start1, + endOffset1: f.startOffset1 + block.offsets.end1, + startOffset2: f.startOffset2 + block.offsets.start2, + endOffset2: f.startOffset2 + block.offsets.end2, + inner: block.fragments, + }); + line1 = endLine1; + line2 = endLine2; + }); + } catch (e) { + if (!(e instanceof DiffTooBigError)) throw e; + out.push(f); + tooBig++; + } + } + return out; +} + +const adjoining = (before: LineFragment, after: LineFragment) => + before.endLine1 === after.startLine1 && + before.endLine2 === after.startLine2 && + before.endOffset1 === after.startOffset1 && + before.endOffset2 === after.startOffset2; + +/** Squashes adjoining fragments and trims whitespace-only ones at their edges (processBlocks). */ +export function processBlocks( + fragments: LineFragment[], + text1: string, + text2: string, + policy: ComparisonPolicy, + squash: boolean, + trim: boolean, +): LineFragment[] { + if ((!squash && !trim) || !fragments.length) return fragments; + const out: LineFragment[] = []; + let start = 0; + for (let i = 1; i <= fragments.length; i++) { + if (i < fragments.length && adjoining(fragments[i - 1]!, fragments[i]!)) continue; + out.push(...processAdjoining(fragments.slice(start, i), text1, text2, policy, squash, trim)); + start = i; + } + return out; +} + +function processAdjoining( + fragments: LineFragment[], + text1: string, + text2: string, + policy: ComparisonPolicy, + squash: boolean, + trim: boolean, +): LineFragment[] { + let start = 0; + let end = fragments.length; + if (trim && policy === "ignore") { + const ignorable = (f: LineFragment) => + (f.inner !== null && f.inner.length === 0) || + stripSpaces(text1.slice(f.startOffset1, f.endOffset1)) === stripSpaces(text2.slice(f.startOffset2, f.endOffset2)); + while (start < end && ignorable(fragments[start]!)) start++; + while (start < end && ignorable(fragments[end - 1]!)) end--; + } + if (start === end) return []; + return squash ? [doSquash(fragments.slice(start, end))] : fragments.slice(start, end); +} + +function doSquash(fragments: LineFragment[]): LineFragment { + if (fragments.length === 1) return fragments[0]!; + const first = fragments[0]!; + const last = fragments[fragments.length - 1]!; + const inner: Range[] = []; + for (const f of fragments) { + const shift1 = f.startOffset1 - first.startOffset1; + const shift2 = f.startOffset2 - first.startOffset2; + const own = f.inner ?? [ + { start1: 0, end1: f.endOffset1 - f.startOffset1, start2: 0, end2: f.endOffset2 - f.startOffset2 }, + ]; + for (const r of own) { + const prev = inner[inner.length - 1]; + if (prev && prev.end1 === r.start1 + shift1 && prev.end2 === r.start2 + shift2) + inner[inner.length - 1] = { + start1: prev.start1, + end1: r.end1 + shift1, + start2: prev.start2, + end2: r.end2 + shift2, + }; + else + inner.push({ + start1: r.start1 + shift1, + end1: r.end1 + shift1, + start2: r.start2 + shift2, + end2: r.end2 + shift2, + }); + } + } + return { + startLine1: first.startLine1, + endLine1: last.endLine1, + startLine2: first.startLine2, + endLine2: last.endLine2, + startOffset1: first.startOffset1, + endOffset1: last.endOffset1, + startOffset2: first.startOffset2, + endOffset2: last.endOffset2, + inner, + }; +} diff --git a/src/features/vcs/comparison/iterables.ts b/src/features/vcs/comparison/iterables.ts new file mode 100644 index 00000000..ad3699df --- /dev/null +++ b/src/features/vcs/comparison/iterables.ts @@ -0,0 +1,191 @@ +/** + * Changed / unchanged ranges between two sequences (IntelliJ's DiffIterable + * and DiffIterableUtil, ported; Apache-2.0, Copyright JetBrains s.r.o. and + * contributors). Changes are kept as a sorted list; "fair" iterables, whose + * unchanged ranges have equal lengths on both sides, use the same shape. + */ +import { diffKeys, isEmptyRange, range, type Range } from "./lcs"; + +export interface DiffIterable { + readonly length1: number; + readonly length2: number; + readonly changes: readonly Range[]; +} + +export const create = (changes: readonly Range[], length1: number, length2: number): DiffIterable => ({ + length1, + length2, + changes, +}); + +/** The ranges between the changes (ChangeDiffIterableBase.UnchangedIterator). */ +export function unchanged(it: DiffIterable): Range[] { + const out: Range[] = []; + let last1 = 0; + let last2 = 0; + let i = 0; + const { changes } = it; + if (changes.length && changes[0]!.start1 === 0 && changes[0]!.start2 === 0) { + last1 = changes[0]!.end1; + last2 = changes[0]!.end2; + i = 1; + } + for (; i < changes.length; i++) { + const c = changes[i]!; + out.push(range(last1, c.start1, last2, c.start2)); + last1 = c.end1; + last2 = c.end2; + } + if (last1 !== it.length1 || last2 !== it.length2) out.push(range(last1, it.length1, last2, it.length2)); + return out; +} + +export const invert = (it: DiffIterable): DiffIterable => create(unchanged(it), it.length1, it.length2); + +export const createUnchanged = (ranges: readonly Range[], length1: number, length2: number) => + invert(create(ranges, length1, length2)); + +/** Both changed and unchanged ranges in order, without empty ones (DiffIterableUtil.iterateAll). */ +export function iterateAll(it: DiffIterable): [Range, boolean][] { + const out: [Range, boolean][] = []; + let last1 = 0; + let last2 = 0; + for (const c of it.changes) { + const gap = range(last1, c.start1, last2, c.start2); + if (!isEmptyRange(gap)) out.push([gap, true]); + out.push([c, false]); + last1 = c.end1; + last2 = c.end2; + } + const tail = range(last1, it.length1, last2, it.length2); + if (!isEmptyRange(tail)) out.push([tail, true]); + return out; +} + +/** Changes of sequences of keys compared by equality (DiffIterableUtil.diff). */ +export const diffSequences = (a: readonly T[], b: readonly T[]): DiffIterable => + create(diffKeys(a, b), a.length, b.length); + +/** The part of `changed` inside the given ranges, relative to their starts (SubiterableDiffIterable). */ +export function subIterable( + changed: readonly Range[], + start1: number, + end1: number, + start2: number, + end2: number, + firstIndex: number, +): DiffIterable { + const out: Range[] = []; + for (let i = firstIndex; i < changed.length; i++) { + const r = changed[i]!; + if (r.end1 < start1 || r.end2 < start2) continue; + if (r.start1 > end1 || r.start2 > end2) break; + const sub = range( + Math.max(start1, r.start1) - start1, + Math.min(end1, r.end1) - start1, + Math.max(start2, r.start2) - start2, + Math.min(end2, r.end2) - start2, + ); + if (!isEmptyRange(sub)) out.push(sub); + } + return create(out, end1 - start1, end2 - start2); +} + +/** Collects changes from the equal ranges marked in order (DiffIterableUtil.ChangeBuilder). */ +export class ChangeBuilder { + index1 = 0; + index2 = 0; + protected readonly changes: Range[] = []; + + constructor( + readonly length1: number, + readonly length2: number, + ) {} + + markEqual(index1: number, index2: number, end1 = index1 + 1, end2 = index2 + 1) { + if (index1 === end1 && index2 === end2) return; + if (this.index1 !== index1 || this.index2 !== index2) this.addChange(this.index1, this.index2, index1, index2); + this.index1 = end1; + this.index2 = end2; + } + + markEqualCount(index1: number, index2: number, count: number) { + this.markEqual(index1, index2, index1 + count, index2 + count); + } + + finish(): DiffIterable { + if (this.length1 !== this.index1 || this.length2 !== this.index2) { + this.addChange(this.index1, this.index2, this.length1, this.length2); + this.index1 = this.length1; + this.index2 = this.length2; + } + return create(this.changes, this.length1, this.length2); + } + + protected addChange(start1: number, start2: number, end1: number, end2: number) { + this.changes.push(range(start1, end1, start2, end2)); + } +} + +/** A ChangeBuilder that shrinks each change by its equal ends (ExpandChangeBuilder). */ +export class ExpandChangeBuilder extends ChangeBuilder { + constructor( + private readonly objects1: readonly T[], + private readonly objects2: readonly T[], + ) { + super(objects1.length, objects2.length); + } + + protected override addChange(start1: number, start2: number, end1: number, end2: number) { + const r = expandEqual(this.objects1, this.objects2, start1, start2, end1, end2); + if (!isEmptyRange(r)) super.addChange(r.start1, r.start2, r.end1, r.end2); + } +} + +/** Shrinks `[start, end)` by the equal elements at both ends (TrimUtil.expand). */ +export function expandEqual( + a: ArrayLike, + b: ArrayLike, + start1: number, + start2: number, + end1: number, + end2: number, +): Range { + const forward = expandForward(a, b, start1, start2, end1, end2); + start1 += forward; + start2 += forward; + const backward = expandBackward(a, b, start1, start2, end1, end2); + return range(start1, end1 - backward, start2, end2 - backward); +} + +export function expandForward( + a: ArrayLike, + b: ArrayLike, + start1: number, + start2: number, + end1: number, + end2: number, +): number { + const old = start1; + while (start1 < end1 && start2 < end2 && a[start1] === b[start2]) { + start1++; + start2++; + } + return start1 - old; +} + +export function expandBackward( + a: ArrayLike, + b: ArrayLike, + start1: number, + start2: number, + end1: number, + end2: number, +): number { + const old = end1; + while (start1 < end1 && start2 < end2 && a[end1 - 1] === b[end2 - 1]) { + end1--; + end2--; + } + return old - end1; +} diff --git a/src/features/vcs/comparison/lcs.ts b/src/features/vcs/comparison/lcs.ts new file mode 100644 index 00000000..0311a7ee --- /dev/null +++ b/src/features/vcs/comparison/lcs.ts @@ -0,0 +1,512 @@ +/** + * Longest common subsequence of two sequences, ported from the IntelliJ + * Platform (`com.intellij.util.diff`: Diff, Reindexer, MyersLCS, + * PatienceIntLCS, UniqueLCS; Apache-2.0, Copyright JetBrains s.r.o. and + * contributors): common prefix / suffix trimmed, elements unique to one side + * discarded, then Myers' O(ND) diff bounded by a threshold, falling back to + * patience diff when the sequences differ too much. + */ + +/** Half-open index ranges `[start1, end1)` and `[start2, end2)`. */ +export interface Range { + readonly start1: number; + readonly end1: number; + readonly start2: number; + readonly end2: number; +} + +export const range = (start1: number, end1: number, start2: number, end2: number): Range => ({ + start1, + end1, + start2, + end2, +}); + +export const isEmptyRange = (r: Range) => r.start1 === r.end1 && r.start2 === r.end2; + +/** The sequences differ too much to be compared (DiffTooBigException). */ +export class DiffTooBigError extends Error { + constructor() { + super("Diff is too big"); + } +} + +/** Thrown inside the LCS when a bounded search gives up (FilesTooBigForDiffException). */ +class FilesTooBig extends Error {} + +/** Changed ranges between two int sequences (Diff.buildChanges). */ +export function diffInts(a: ArrayLike, b: ArrayLike): Range[] { + const n1 = a.length; + const n2 = b.length; + let startShift = 0; + while (startShift < Math.min(n1, n2) && a[startShift] === b[startShift]) startShift++; + let endCut = 0; + while (endCut < Math.min(n1, n2) - startShift && a[n1 - endCut - 1] === b[n2 - endCut - 1]) endCut++; + const len1 = n1 - startShift - endCut; + const len2 = n2 - startShift - endCut; + if (len1 === 0 || len2 === 0) + return len1 || len2 ? [range(startShift, startShift + len1, startShift, startShift + len2)] : []; + const ints1 = Int32Array.from({ length: len1 }, (_, i) => a[startShift + i]!); + const ints2 = Int32Array.from({ length: len2 }, (_, i) => b[startShift + i]!); + try { + return buildChanges(ints1, ints2, startShift); + } catch (e) { + if (e instanceof FilesTooBig) throw new DiffTooBigError(); + throw e; + } +} + +/** Changed ranges between two sequences of keys, equal keys being equal elements. */ +export function diffKeys(a: readonly T[], b: readonly T[]): Range[] { + const numbers = new Map(); + const enumerate = (xs: readonly T[]) => + Int32Array.from(xs, (x) => { + let n = numbers.get(x); + if (n === undefined) numbers.set(x, (n = numbers.size + 1)); + return n; + }); + return diffInts(enumerate(a), enumerate(b)); +} + +function buildChanges(ints1: Int32Array, ints2: Int32Array, startShift: number): Range[] { + const out: Range[] = []; + let index1 = startShift; + let index2 = startShift; + const builder: LCSBuilder = { + addEqual(length) { + index1 += length; + index2 += length; + }, + addChange(first, second) { + out.push(range(index1, index1 + first, index2, index2 + second)); + index1 += first; + index2 += second; + }, + }; + const reindexer = new Reindexer(); + const [discarded1, discarded2] = reindexer.discardUnique(ints1, ints2); + if (discarded1.length === 0 && discarded2.length === 0) { + builder.addChange(ints1.length, ints2.length); + return out; + } + let changes: [Uint8Array, Uint8Array]; + try { + const lcs = new MyersLCS(discarded1, discarded2); + lcs.executeWithThreshold(); + changes = lcs.changes; + } catch (e) { + if (!(e instanceof FilesTooBig)) throw e; + const lcs = new PatienceLCS(discarded1, discarded2); + lcs.execute(true); + changes = lcs.changes; + } + reindexer.reindex(changes, builder); + return out; +} + +interface LCSBuilder { + addEqual(length: number): void; + addChange(first: number, second: number): void; +} + +const fill = (bits: Uint8Array, from: number, to: number, value = 1) => { + if (to > from) bits.fill(value, from, to); +}; + +/** Myers' O(ND) diff with linear space (MyersLCS); `changes` marks the unmatched elements. */ +class MyersLCS { + private readonly vForward: Int32Array; + private readonly vBackward: Int32Array; + + constructor( + private readonly first: Int32Array, + private readonly second: Int32Array, + private readonly start1 = 0, + private readonly count1 = first.length, + private readonly start2 = 0, + private readonly count2 = second.length, + private readonly changes1: Uint8Array = new Uint8Array(first.length), + private readonly changes2: Uint8Array = new Uint8Array(second.length), + ) { + fill(changes1, start1, start1 + count1); + fill(changes2, start2, start2 + count2); + this.vForward = new Int32Array(count1 + count2 + 1); + this.vBackward = new Int32Array(count1 + count2 + 1); + } + + get changes(): [Uint8Array, Uint8Array] { + return [this.changes1, this.changes2]; + } + + /** Bounds D by A + B·√N: expected O(N) under common assumptions. */ + executeLinear() { + this.run(20000 + 10 * Math.floor(Math.sqrt(this.count1 + this.count2)), false); + } + + executeWithThreshold() { + this.run(Math.max(20000 + 10 * Math.floor(Math.sqrt(this.count1 + this.count2)), DELTA_THRESHOLD_SIZE), true); + } + + private run(threshold: number, throwException: boolean) { + if (this.count1 === 0 || this.count2 === 0) return; + this.execute(0, this.count1, 0, this.count2, Math.min(threshold, this.count1 + this.count2), throwException); + } + + // LCS(old[oldStart, oldEnd), new[newStart, newEnd)) + private execute( + oldStart: number, + oldEnd: number, + newStart: number, + newEnd: number, + differenceEstimate: number, + throwException: boolean, + ) { + if (!(oldStart < oldEnd && newStart < newEnd)) return; + const { vForward: VF, vBackward: VB } = this; + const oldLength = oldEnd - oldStart; + const newLength = newEnd - newStart; + VF[newLength + 1] = 0; + VB[newLength + 1] = 0; + const halfD = (differenceEstimate + 1) >> 1; + let xx = -1; + let kk = -1; + let td = -1; + + loop: for (let d = 0; d <= halfD; d++) { + const L = newLength + Math.max(-d, -newLength + ((d ^ newLength) & 1)); + const R = newLength + Math.min(d, oldLength - ((d ^ oldLength) & 1)); + for (let k = L; k <= R; k += 2) { + let x = k === L || (k !== R && VF[k - 1]! < VF[k + 1]!) ? VF[k + 1]! : VF[k - 1]! + 1; + const y = x - k + newLength; + x += this.commonForward(oldStart + x, newStart + y, Math.min(oldEnd - oldStart - x, newEnd - newStart - y)); + VF[k] = x; + } + + if ((oldLength - newLength) % 2 !== 0) { + for (let k = L; k <= R; k += 2) { + if (oldLength - (d - 1) <= k && k <= oldLength + (d - 1)) { + if (VF[k]! + VB[newLength + oldLength - k]! >= oldLength) { + xx = VF[k]!; + kk = k; + td = 2 * d - 1; + break loop; + } + } + } + } + + for (let k = L; k <= R; k += 2) { + let x = k === L || (k !== R && VB[k - 1]! < VB[k + 1]!) ? VB[k + 1]! : VB[k - 1]! + 1; + const y = x - k + newLength; + x += this.commonBackward( + oldEnd - 1 - x, + newEnd - 1 - y, + Math.min(oldEnd - oldStart - x, newEnd - newStart - y), + ); + VB[k] = x; + } + + if ((oldLength - newLength) % 2 === 0) { + for (let k = L; k <= R; k += 2) { + if (oldLength - d <= k && k <= oldLength + d) { + if (VF[oldLength + newLength - k]! + VB[k]! >= oldLength) { + xx = oldLength - VB[k]!; + kk = oldLength + newLength - k; + td = 2 * d; + break loop; + } + } + } + } + } + + if (td > 1) { + const yy = xx - kk + newLength; + const oldDiff = (td + 1) >> 1; + if (0 < xx && 0 < yy) this.execute(oldStart, oldStart + xx, newStart, newStart + yy, oldDiff, throwException); + if (oldStart + xx < oldEnd && newStart + yy < newEnd) + this.execute(oldStart + xx, oldEnd, newStart + yy, newEnd, td - oldDiff, throwException); + } else if (td >= 0) { + let x = oldStart; + let y = newStart; + while (x < oldEnd && y < newEnd) { + const common = this.commonForward(x, y, Math.min(oldEnd - x, newEnd - y)); + if (common > 0) { + fill(this.changes1, this.start1 + x, this.start1 + x + common, 0); + fill(this.changes2, this.start2 + y, this.start2 + y + common, 0); + x += common; + y += common; + } else if (oldEnd - oldStart > newEnd - newStart) x++; + else y++; + } + } else if (throwException) { + // The difference is more than the given estimate. + throw new FilesTooBig(); + } + } + + private commonForward(oldIndex: number, newIndex: number, maxLength: number): number { + let x = oldIndex; + let y = newIndex; + maxLength = Math.min(maxLength, this.count1 - oldIndex, this.count2 - newIndex); + while (x - oldIndex < maxLength && this.first[this.start1 + x] === this.second[this.start2 + y]) { + x++; + y++; + } + return x - oldIndex; + } + + private commonBackward(oldIndex: number, newIndex: number, maxLength: number): number { + let x = oldIndex; + let y = newIndex; + maxLength = Math.min(maxLength, Math.min(oldIndex, newIndex) + 1); + while (oldIndex - x < maxLength && this.first[this.start1 + x] === this.second[this.start2 + y]) { + x--; + y--; + } + return oldIndex - x; + } +} + +const DELTA_THRESHOLD_SIZE = 20000; + +/** Patience diff (PatienceIntLCS): unique common elements anchor the match, Myers fills the gaps. */ +class PatienceLCS { + private readonly changes1: Uint8Array; + private readonly changes2: Uint8Array; + + constructor( + private readonly first: Int32Array, + private readonly second: Int32Array, + ) { + this.changes1 = new Uint8Array(first.length); + this.changes2 = new Uint8Array(second.length); + } + + get changes(): [Uint8Array, Uint8Array] { + return [this.changes1, this.changes2]; + } + + execute(failOnSmallReduction = false) { + this.run(0, this.first.length, 0, this.second.length, failOnSmallReduction ? 2 : -1); + } + + private run(start1: number, count1: number, start2: number, count2: number, thresholdCheckCounter: number) { + if (count1 === 0 && count2 === 0) return; + if (count1 === 0 || count2 === 0) return this.addChange(start1, count1, start2, count2); + + let startOffset = 0; + while ( + startOffset < Math.min(count1, count2) && + this.first[start1 + startOffset] === this.second[start2 + startOffset] + ) + startOffset++; + start1 += startOffset; + start2 += startOffset; + count1 -= startOffset; + count2 -= startOffset; + let endOffset = 0; + while ( + endOffset < Math.min(count1, count2) && + this.first[start1 + count1 - endOffset - 1] === this.second[start2 + count2 - endOffset - 1] + ) + endOffset++; + count1 -= endOffset; + count2 -= endOffset; + + if (count1 === 0 || count2 === 0) return this.addChange(start1, count1, start2, count2); + + if (thresholdCheckCounter === 0) this.checkReduction(count1, count2); + thresholdCheckCounter = Math.max(-1, thresholdCheckCounter - 1); + + const matching = uniqueLCS(this.first, this.second, start1, count1, start2, count2); + if (!matching) { + if (thresholdCheckCounter >= 0) this.checkReduction(count1, count2); + new MyersLCS( + this.first, + this.second, + start1, + count1, + start2, + count2, + this.changes1, + this.changes2, + ).executeLinear(); + return; + } + const [m1, m2] = matching; + const matched = m1.length; + this.run(start1, m1[0]!, start2, m2[0]!, thresholdCheckCounter); + for (let i = 1; i < matched; i++) { + const s1 = m1[i - 1]! + 1; + const s2 = m2[i - 1]! + 1; + const c1 = m1[i]! - s1; + const c2 = m2[i]! - s2; + if (c1 > 0 || c2 > 0) this.run(start1 + s1, c1, start2 + s2, c2, thresholdCheckCounter); + } + let s1: number, c1: number, s2: number, c2: number; + if (m1[matched - 1] === count1 - 1) [s1, c1] = [count1 - 1, 0]; + else [s1, c1] = [m1[matched - 1]! + 1, count1 - m1[matched - 1]! - 1]; + if (m2[matched - 1] === count2 - 1) [s2, c2] = [count2 - 1, 0]; + else [s2, c2] = [m2[matched - 1]! + 1, count2 - m2[matched - 1]! - 1]; + this.run(start1 + s1, c1, start2 + s2, c2, thresholdCheckCounter); + } + + private addChange(start1: number, count1: number, start2: number, count2: number) { + fill(this.changes1, start1, start1 + count1); + fill(this.changes2, start2, start2 + count2); + } + + private checkReduction(count1: number, count2: number) { + if (count1 * 2 < this.first.length) return; + if (count2 * 2 < this.second.length) return; + throw new FilesTooBig(); + } +} + +/** Longest increasing run of the elements unique on both sides (UniqueLCS), as relative indexes; null if none. */ +function uniqueLCS( + first: Int32Array, + second: Int32Array, + start1: number, + count1: number, + start2: number, + count2: number, +): [Int32Array, Int32Array] | null { + // key -> (offset1 + 1), or -1 when repeated; match: offset1 -> (offset2 + 1) + const map = new Map(); + const match = new Int32Array(count1); + for (let i = 0; i < count1; i++) { + const key = first[start1 + i]!; + const value = map.get(key) ?? 0; + if (value === -1) continue; + map.set(key, value === 0 ? i + 1 : -1); + } + let count = 0; + for (let i = 0; i < count2; i++) { + const key = second[start2 + i]!; + const value = map.get(key) ?? 0; + if (value === 0 || value === -1) continue; + if (match[value - 1] === 0) { + match[value - 1] = i + 1; + count++; + } else { + match[value - 1] = 0; + map.set(key, -1); + count--; + } + } + if (count === 0) return null; + + const sequence = new Int32Array(count); + const lastElement = new Int32Array(count); + const predecessor = new Int32Array(count1); + let length = 0; + for (let i = 0; i < count1; i++) { + const m = match[i]!; + if (m === 0) continue; + // first j with sequence[j] > m (elements are distinct) + let lo = 0; + let hi = length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (sequence[mid]! < m) lo = mid + 1; + else hi = mid; + } + sequence[lo] = m; + lastElement[lo] = i; + predecessor[i] = lo > 0 ? lastElement[lo - 1]! : -1; + if (lo === length) length++; + } + const ret: [Int32Array, Int32Array] = [new Int32Array(length), new Int32Array(length)]; + for (let i = length - 1, curr = lastElement[length - 1]!; curr !== -1; i--, curr = predecessor[curr]!) { + ret[0][i] = curr; + ret[1][i] = match[curr]! - 1; + } + return ret; +} + +/** Drops elements that can't be matched and maps the result back (Reindexer). */ +class Reindexer { + private readonly oldIndices: [Int32Array, Int32Array] = [new Int32Array(0), new Int32Array(0)]; + private readonly originalLengths = [-1, -1]; + private readonly discardedLengths = [-1, -1]; + + discardUnique(ints1: Int32Array, ints2: Int32Array): [Int32Array, Int32Array] { + const discarded1 = this.discard(ints2, ints1, 0); + return [discarded1, this.discard(discarded1, ints2, 1)]; + } + + private discard(needed: Int32Array, toDiscard: Int32Array, arrayIndex: 0 | 1): Int32Array { + this.originalLengths[arrayIndex] = toDiscard.length; + const present = new Set(needed); + const discarded: number[] = []; + const oldIndices: number[] = []; + for (let i = 0; i < toDiscard.length; i++) { + if (present.has(toDiscard[i]!)) { + discarded.push(toDiscard[i]!); + oldIndices.push(i); + } + } + this.oldIndices[arrayIndex] = Int32Array.from(oldIndices); + this.discardedLengths[arrayIndex] = discarded.length; + return Int32Array.from(discarded); + } + + reindex(discardedChanges: [Uint8Array, Uint8Array], builder: LCSBuilder) { + const [orig1, orig2] = this.originalLengths as [number, number]; + const [disc1, disc2] = this.discardedLengths as [number, number]; + const [old1, old2] = this.oldIndices; + let changes1: Uint8Array; + let changes2: Uint8Array; + if (disc1 === orig1 && disc2 === orig2) { + [changes1, changes2] = discardedChanges; + } else { + changes1 = new Uint8Array(orig1); + changes2 = new Uint8Array(orig2); + const increment = (indexes: Int32Array, i: number, set: Uint8Array, length: number) => { + fill(set, indexes[i]! + 1, i + 1 < indexes.length ? indexes[i + 1]! : length); + return i + 1; + }; + let x = 0; + let y = 0; + while (x < disc1 || y < disc2) { + if (x < disc1 && y < disc2 && !discardedChanges[0][x] && !discardedChanges[1][y]) { + x = increment(old1, x, changes1, orig1); + y = increment(old2, y, changes2, orig2); + } else if (discardedChanges[0][x]) { + changes1[old1[x]!] = 1; + x = increment(old1, x, changes1, orig1); + } else if (discardedChanges[1][y]) { + changes2[old2[y]!] = 1; + y = increment(old2, y, changes2, orig2); + } + } + fill(changes1, 0, disc1 === 0 ? orig1 : old1[0]!); + fill(changes2, 0, disc2 === 0 ? orig2 : old2[0]!); + } + + let x = 0; + let y = 0; + while (x < orig1 && y < orig2) { + const startX = x; + while (x < orig1 && y < orig2 && !changes1[x] && !changes2[y]) { + x++; + y++; + } + if (x > startX) builder.addEqual(x - startX); + let dx = 0; + let dy = 0; + while (x < orig1 && changes1[x]) { + dx++; + x++; + } + while (y < orig2 && changes2[y]) { + dy++; + y++; + } + if (dx !== 0 || dy !== 0) builder.addChange(dx, dy); + } + if (x !== orig1 || y !== orig2) builder.addChange(orig1 - x, orig2 - y); + } +} diff --git a/src/features/vcs/comparison/text.ts b/src/features/vcs/comparison/text.ts new file mode 100644 index 00000000..816e60d7 --- /dev/null +++ b/src/features/vcs/comparison/text.ts @@ -0,0 +1,128 @@ +/** + * Character classes, whitespace trimming and text equality under a + * comparison policy (IntelliJ's TrimUtil, ComparisonUtil and CharacterUtils, + * ported; Apache-2.0, Copyright JetBrains s.r.o. and contributors). + */ +import { range, type Range } from "./lcs"; + +export type ComparisonPolicy = "default" | "trim" | "ignore"; + +/** Whitespace for comparison purposes: space, tab and line break only (Strings.isWhiteSpace). */ +export const isSpace = (c: string | undefined) => c === " " || c === "\t" || c === "\n"; + +const isSpaceCode = (c: number) => c === 32 || c === 9 || c === 10; + +/** ASCII punctuation, except `_`. */ +export function isPunctuation(c: number): boolean { + if (c === 95) return false; + return (c >= 33 && c <= 47) || (c >= 58 && c <= 64) || (c >= 91 && c <= 96) || (c >= 123 && c <= 126); +} + +export const isWhiteSpaceCodePoint = (c: number) => c < 128 && isSpaceCode(c); + +export const isAlpha = (c: number) => !isWhiteSpaceCodePoint(c) && !isPunctuation(c); + +const DECIMAL_DIGIT = /\p{Nd}/u; +const IDEOGRAPHIC = /\p{Ideographic}/u; +const ALPHABETIC = /\p{Alphabetic}/u; +const SPACELESS_SCRIPT = /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Javanese}]/u; + +/** Characters that are words on their own: CJK, Thai, symbols, anything outside the BMP. */ +export function isContinuousScript(c: number): boolean { + if (c < 128) return false; + const s = String.fromCodePoint(c); + if (DECIMAL_DIGIT.test(s)) return false; + if (c > 0xffff) return true; + if (IDEOGRAPHIC.test(s)) return true; + if (!ALPHABETIC.test(s)) return true; + return SPACELESS_SCRIPT.test(s); +} + +export const charCount = (codePoint: number) => (codePoint >= 0x10000 ? 2 : 1); + +export function trimStart(text: string, start: number, end: number): number { + while (start < end && isSpace(text[start])) start++; + return start; +} + +export function trimEnd(text: string, start: number, end: number): number { + while (start < end && isSpace(text[end - 1])) end--; + return end; +} + +/** Both sides of a range without their surrounding whitespace. */ +export function trimRange(text1: string, text2: string, r: Range): Range { + const start1 = trimStart(text1, r.start1, r.end1); + const start2 = trimStart(text2, r.start2, r.end2); + return range(start1, trimEnd(text1, start1, r.end1), start2, trimEnd(text2, start2, r.end2)); +} + +/** Shrinks a range by the equal whitespace at both ends (TrimUtil.expandWhitespaces). */ +export function expandWhitespaces(text1: string, text2: string, r: Range): Range { + const forward = expandWhitespacesForward(text1, text2, r.start1, r.start2, r.end1, r.end2); + const start1 = r.start1 + forward; + const start2 = r.start2 + forward; + const backward = expandWhitespacesBackward(text1, text2, start1, start2, r.end1, r.end2); + return range(start1, r.end1 - backward, start2, r.end2 - backward); +} + +export function expandWhitespacesForward( + text1: string, + text2: string, + start1: number, + start2: number, + end1: number, + end2: number, +): number { + const old = start1; + while (start1 < end1 && start2 < end2 && text1[start1] === text2[start2] && isSpace(text1[start1])) { + start1++; + start2++; + } + return start1 - old; +} + +export function expandWhitespacesBackward( + text1: string, + text2: string, + start1: number, + start2: number, + end1: number, + end2: number, +): number { + const old = end1; + while (start1 < end1 && start2 < end2 && text1[end1 - 1] === text2[end2 - 1] && isSpace(text1[end1 - 1])) { + end1--; + end2--; + } + return old - end1; +} + +/** The text without whitespace (StringsKmp.equalsIgnoreWhitespaces compares these). */ +export const stripSpaces = (s: string) => s.replace(/[ \t\n]+/g, ""); + +/** The text without surrounding whitespace. */ +export function trimSpaces(s: string): string { + const start = trimStart(s, 0, s.length); + return s.slice(start, trimEnd(s, start, s.length)); +} + +/** A line's identity under a policy: equal keys are equal lines (ComparisonUtil.isEquals). */ +export function lineKey(s: string, policy: ComparisonPolicy): string { + return policy === "default" ? s : policy === "trim" ? trimSpaces(s) : stripSpaces(s); +} + +/** Texts equal under a policy, trimming every line for "trim" (ComparisonUtil.isEqualTexts). */ +export function isEqualTexts(text1: string, text2: string, policy: ComparisonPolicy): boolean { + if (policy === "default") return text1 === text2; + if (policy === "ignore") return stripSpaces(text1) === stripSpaces(text2); + const lines1 = text1.split("\n"); + const lines2 = text2.split("\n"); + return lines1.length === lines2.length && lines1.every((l, i) => trimSpaces(l) === trimSpaces(lines2[i]!)); +} + +export const isEqualsRange = (text1: string, text2: string, r: Range) => + text1.slice(r.start1, r.end1) === text2.slice(r.start2, r.end2); + +export const isEqualsIgnoreWhitespacesRange = (text1: string, text2: string, r: Range) => + stripSpaces(text1.slice(r.start1, r.end1)) === stripSpaces(text2.slice(r.start2, r.end2)); diff --git a/src/features/vcs/diffControls.ts b/src/features/vcs/diffControls.ts index 3b707e07..cc69725b 100644 --- a/src/features/vcs/diffControls.ts +++ b/src/features/vcs/diffControls.ts @@ -5,7 +5,7 @@ */ import { StateEffect, StateField, type Extension, type Range } from "@codemirror/state"; import { Decoration, EditorView, ViewPlugin, WidgetType, type DecorationSet } from "@codemirror/view"; -import { getChunks, type Chunk, type MergeView } from "@codemirror/merge"; +import { getChunks, getOriginalDoc, type Chunk, type MergeView } from "@codemirror/merge"; import { iconMarkup } from "@/ui"; import { transferChunk } from "./diffOptions"; @@ -17,6 +17,16 @@ export function applyChunk(mv: MergeView, index: number, source: "a" | "b") { dst.dispatch({ changes: transferChunk(chunk, source, src.state.doc, dst.state.doc), userEvent: "revert" }); } +/** Reverts a unified change with the same line-boundary handling as the split viewer. */ +export function revertUnifiedChunk(view: EditorView, pos: number) { + const chunk = getChunks(view.state)?.chunks.find((c) => c.fromB <= pos && c.endB >= pos); + if (!chunk) return; + view.dispatch({ + changes: transferChunk(chunk, "a", getOriginalDoc(view.state), view.state.doc), + userEvent: "revert", + }); +} + /** A ">>" / "<<" button; the chunk index comes from the nearest `data-chunk` (itself included). */ export function arrowButton(dir: "a-to-b" | "b-to-a", run: (chunkIndex: number) => void): HTMLButtonElement { const btn = document.createElement("button"); diff --git a/src/features/vcs/diffOptions.ts b/src/features/vcs/diffOptions.ts index 72e67f4a..4e729949 100644 --- a/src/features/vcs/diffOptions.ts +++ b/src/features/vcs/diffOptions.ts @@ -1,10 +1,12 @@ /** * Diff viewer settings (IDEA's diff toolbar): viewer kind, whitespace policy, * highlighting, collapsed unchanged fragments and synchronized scrolling, plus - * the whitespace-ignoring diff algorithm and per-chunk transfer helpers. + * chunks computed by IDEA's comparison and per-chunk transfer helpers. */ -import { Change, diff, type Chunk } from "@codemirror/merge"; -import type { Text } from "@codemirror/state"; +import { Change, Chunk, type DiffConfig } from "@codemirror/merge"; +import type { ChangeDesc, Text } from "@codemirror/state"; +import { compareTexts, type ComparisonPolicy, type LineFragment } from "./comparison"; +import { DiffTooBigError } from "./comparison/lcs"; export type DiffMode = "split" | "unified"; export type IgnorePolicy = "none" | "trim" | "whitespace" | "whitespaceAndLines"; @@ -82,151 +84,125 @@ export function saveDiffOptions(o: DiffViewOptions) { } } -interface Line { - /** Start offset. */ - from: number; - /** End offset including the line break. */ - to: number; - key: string; +const IDEA = Symbol("ideaDiff"); + +interface IdeaDiff { + policy: ComparisonPolicy; + words: boolean; + trimChunks: boolean; } -function lineKey(text: string, policy: IgnorePolicy): string { - if (policy === "trim") return text.trim(); - if (policy === "none") return text; - return text.replace(/\s+/g, ""); -} - -function splitLines(s: string, policy: IgnorePolicy): Line[] { - const out: Line[] = []; - let from = 0; - for (;;) { - const nl = s.indexOf("\n", from); - const end = nl < 0 ? s.length : nl; - const key = lineKey(s.slice(from, end), policy) + (policy === "none" && nl >= 0 ? "\n" : ""); - if (!(policy === "whitespaceAndLines" && key === "")) out.push({ from, to: nl < 0 ? s.length : nl + 1, key }); - if (nl < 0) return out; - from = nl + 1; - } -} - -/** First char code used to encode lines (skips control chars), and the surrogate gap. */ -const BASE = 0x21; - -function encoder() { - const codes = new Map(); - let next = BASE; - return (key: string): string | null => { - let c = codes.get(key); - if (c === undefined) { - if (next === 0xd800) next = 0xe000; - if (next > 0xffff) return null; - c = String.fromCharCode(next++); - codes.set(key, c); - } - return c; - }; -} - -/** Bounds the expensive diff search; larger inputs fall back to a cheaper, coarser match. */ -const DIFF_CONFIG = { scanLimit: 500 }; +const COMPARISON_POLICY: Record = { + none: "default", + trim: "trim", + whitespace: "ignore", + whitespaceAndLines: "ignore", +}; /** - * Diff of encoded lines (one char per line). Lines that occur exactly once on - * each side anchor the match (patience diff), and only the gaps between the - * anchors get a regular diff, so distant edits in long files stay separate - * without a quadratic search. + * A MergeView `diffConfig` whose chunks come from IDEA's comparison + * (`./comparison`) instead of `@codemirror/merge`'s own diff: lines matched as + * IDEA does, word fragments by IDEA's rules, no merging of nearby changes. */ -function diffLines(a: string, b: string): Change[] { - const seen = new Map(); - for (let i = 0; i < a.length; i++) { - const e = seen.get(a[i]!); - if (e) e.inA++; - else seen.set(a[i]!, { inA: 1, inB: 0, at: i }); - } - for (const c of b) { - const e = seen.get(c); - if (e) e.inB++; - } - // Unique common lines as [indexA, indexB] in A order, then their longest increasing run in B. - const pairs: [number, number][] = []; - for (let j = 0; j < b.length; j++) { - const e = seen.get(b[j]!); - if (e && e.inA === 1 && e.inB === 1) pairs.push([e.at, j]); - } - pairs.sort((x, y) => x[0] - y[0]); - const tails: number[] = []; - const prev = new Array(pairs.length); - for (let i = 0; i < pairs.length; i++) { - let lo = 0; - let hi = tails.length; - while (lo < hi) { - const mid = (lo + hi) >> 1; - if (pairs[tails[mid]!]![1] < pairs[i]![1]) lo = mid + 1; - else hi = mid; - } - prev[i] = lo > 0 ? tails[lo - 1]! : -1; - tails[lo] = i; - } - const anchors: [number, number][] = []; - for (let i = tails.length ? tails[tails.length - 1]! : -1; i >= 0; i = prev[i]!) anchors.push(pairs[i]!); - anchors.reverse().push([a.length, b.length]); - const out: Change[] = []; - let fromA = 0; - let fromB = 0; - for (const [toA, toB] of anchors) { - if (toA > fromA || toB > fromB) - for (const c of diff(a.slice(fromA, toA), b.slice(fromB, toB), DIFF_CONFIG)) - out.push(new Change(c.fromA + fromA, c.toA + fromA, c.fromB + fromB, c.toB + fromB)); - fromA = toA + 1; - fromB = toB + 1; - } - return out; +export function ideaDiffConfig(ignore: IgnorePolicy, highlight: HighlightPolicy): DiffConfig { + const idea: IdeaDiff = { + policy: COMPARISON_POLICY[ignore], + words: highlight === "words", + trimChunks: ignore === "whitespaceAndLines", + }; + const config: DiffConfig = {}; + (config as Record)[IDEA] = idea; + return config; } +/** The line fragment a chunk was built from. */ +const fragments = new WeakMap(); + /** - * Match lines first, then compare the contents of changed blocks for word - * highlighting. The line keys also implement the whitespace policy. + * Whether the chunk has IDEA's inner fragments (its words were compared): + * such a chunk gets the lighter line background (TextDiffType "ignored" + * colour) with its `changes` in full colour. */ -export function whitespaceDiff(policy: IgnorePolicy): (a: string, b: string) => readonly Change[] { - return (a, b) => { - const la = splitLines(a, policy); - const lb = splitLines(b, policy); - const enc = encoder(); - const ea: string[] = []; - const eb: string[] = []; - for (const l of la) { - const c = enc(l.key); - if (c === null) return diff(a, b, DIFF_CONFIG); - ea.push(c); - } - for (const l of lb) { - const c = enc(l.key); - if (c === null) return diff(a, b, DIFF_CONFIG); - eb.push(c); - } - const out: Change[] = []; - const at = (lines: Line[], i: number) => (i > 0 ? lines[i - 1]!.to : 0); - for (const c of diffLines(ea.join(""), eb.join(""))) { - const fromA = c.toA > c.fromA ? la[c.fromA]!.from : at(la, c.fromA); - const toA = c.toA > c.fromA ? la[c.toA - 1]!.to : fromA; - const fromB = c.toB > c.fromB ? lb[c.fromB]!.from : at(lb, c.fromB); - const toB = c.toB > c.fromB ? lb[c.toB - 1]!.to : fromB; - if (fromA === toA || fromB === toB) { - out.push(new Change(fromA, toA, fromB, toB)); - continue; - } - for (const inner of diff(a.slice(fromA, toA), b.slice(fromB, toB), DIFF_CONFIG)) - out.push(new Change(inner.fromA + fromA, inner.toA + fromA, inner.fromB + fromB, inner.toB + fromB)); - } - return out; - }; +export const hasInnerFragments = (chunk: Chunk) => !!fragments.get(chunk)?.inner; + +/** IDEA's line fragments as chunks; `changes` hold the inner fragments, or the whole block. */ +export function ideaChunks(a: Text, b: Text, ignore: IgnorePolicy, highlight: HighlightPolicy): readonly Chunk[] { + return toChunks(a, b, ideaOf(ideaDiffConfig(ignore, highlight))!); } +function toChunks(a: Text, b: Text, idea: IdeaDiff): readonly Chunk[] { + let compared: LineFragment[]; + try { + compared = compareTexts(a.toString(), b.toString(), idea); + } catch (error) { + if (!(error instanceof DiffTooBigError)) throw error; + // Repeated lines can exhaust IDEA's search. Keep the viewer usable with a bounded, coarser diff. + return build.call(Chunk, a, b, { scanLimit: 500 }); + } + return compared.map((f) => { + // `to` is one past the last line's end, past the document for its last line. + const toA = f.startLine1 === f.endLine1 ? f.startOffset1 : f.endLine1 === a.lines ? a.length + 1 : f.endOffset1; + const toB = f.startLine2 === f.endLine2 ? f.startOffset2 : f.endLine2 === b.lines ? b.length + 1 : f.endOffset2; + const changes = f.inner + ? f.inner.map((r) => new Change(r.start1, r.end1, r.start2, r.end2)) + : [new Change(0, f.endOffset1 - f.startOffset1, 0, f.endOffset2 - f.startOffset2)]; + const chunk = new Chunk(changes, f.startOffset1, toA, f.startOffset2, toB); + fragments.set(chunk, f); + return chunk; + }); +} + +// Every chunk computation of MergeView and unifiedMergeView goes through these. +const ideaOf = (conf?: DiffConfig) => (conf as Record | undefined)?.[IDEA]; +const { build, updateA, updateB } = Chunk; +Chunk.build = (a: Text, b: Text, conf?: DiffConfig) => { + const idea = ideaOf(conf); + return idea ? toChunks(a, b, idea) : build.call(Chunk, a, b, conf); +}; +Chunk.updateA = (chunks: readonly Chunk[], a: Text, b: Text, changes: ChangeDesc, conf?: DiffConfig) => { + const idea = ideaOf(conf); + return idea ? toChunks(a, b, idea) : updateA.call(Chunk, chunks, a, b, changes, conf); +}; +Chunk.updateB = (chunks: readonly Chunk[], a: Text, b: Text, changes: ChangeDesc, conf?: DiffConfig) => { + const idea = ideaOf(conf); + return idea ? toChunks(a, b, idea) : updateB.call(Chunk, chunks, a, b, changes, conf); +}; + +/** Text of lines `[from, to)` (0-based), without the last line break. */ +const linesContent = (doc: Text, from: number, to: number) => doc.sliceString(doc.line(from + 1).from, doc.line(to).to); + /** * The change that copies a chunk's content from one side to the other - * (IDEA's ">>" / "<<" and Revert), in the destination's coordinates. + * (IDEA's ">>" / "<<" and Revert), in the destination's coordinates. Lines are + * replaced as DiffUtil.applyModification does, so a missing final line break + * stays missing. */ export function transferChunk(chunk: Chunk, source: "a" | "b", src: Text, dst: Text) { + const f = fragments.get(chunk); + if (!f) return transferByOffsets(chunk, source, src, dst); + const [srcFrom, srcTo, dstFrom, dstTo] = + source === "a" + ? [f.startLine1, f.endLine1, f.startLine2, f.endLine2] + : [f.startLine2, f.endLine2, f.startLine1, f.endLine1]; + if (dstFrom === dstTo) { + const text = linesContent(src, srcFrom, srcTo); + return dstFrom === dst.lines + ? { from: dst.length, to: dst.length, insert: "\n" + text } + : { from: dst.line(dstFrom + 1).from, to: dst.line(dstFrom + 1).from, insert: text + "\n" }; + } + let from = dst.line(dstFrom + 1).from; + let to = dst.line(dstTo).to; + if (srcFrom === srcTo) { + // Lines removed with a line break: the one before them, or else the one after. + if (from > 0) from--; + else if (to < dst.length) to++; + return { from, to, insert: "" }; + } + return { from, to, insert: linesContent(src, srcFrom, srcTo) }; +} + +/** `transferChunk` for chunks not built from line fragments. */ +function transferByOffsets(chunk: Chunk, source: "a" | "b", src: Text, dst: Text) { const [srcFrom, srcTo, dstFrom, dstTo] = source === "a" ? [chunk.fromA, chunk.toA, chunk.fromB, chunk.toB] diff --git a/src/features/vcs/diffScroll.ts b/src/features/vcs/diffScroll.ts index 779c4e5f..d92bbb2a 100644 --- a/src/features/vcs/diffScroll.ts +++ b/src/features/vcs/diffScroll.ts @@ -8,7 +8,7 @@ import { RangeSetBuilder, type Extension } from "@codemirror/state"; import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view"; import { getChunks, type Chunk, type MergeView } from "@codemirror/merge"; -import { changedLineGutters } from "./cm"; +import { changedLineGutters, chunkKind, type DiffKind } from "./cm"; import { arrowButton } from "./diffControls"; type Side = "a" | "b"; @@ -91,9 +91,6 @@ export function transferY(bands: readonly Band[], y: number, from: Side): number return m1 > m0 ? s0 + ((y - m0) * (s1 - s0)) / (m1 - m0) : s0; } -type ChangeKind = "inserted" | "deleted" | "modified"; -const kindOf = (c: Chunk): ChangeKind => (c.fromA === c.toA ? "inserted" : c.fromB === c.toB ? "deleted" : "modified"); - /** The divider band between [a0, a1] on the left and [b0, b1] on the right (DiffDrawUtil.makeCurve). */ export function bandPath(w: number, a0: number, a1: number, b0: number, b1: number): string { // An empty side is a 2px sliver on its boundary. @@ -104,7 +101,7 @@ export function bandPath(w: number, a0: number, a1: number, b0: number, b1: numb return `M0 ${a0}C${c1} ${a0} ${c2} ${b0} ${w} ${b0}L${w} ${b1}C${c2} ${b1} ${c1} ${a1} 0 ${a1}Z`; } -const gapMark = (kind: ChangeKind, end: boolean) => +const gapMark = (kind: DiffKind, end: boolean) => Decoration.line({ class: `cm-diffGap cm-diffGap-${kind}${end ? " cm-diffGapEnd" : ""}` }); /** A line where the other side has lines this one lacks, as in IDEA's empty-side marker. */ @@ -122,7 +119,7 @@ function gapDeco(view: EditorView): DecorationSet { if (from < vpFrom) continue; // Past the end of a document without a final line break: below its last line. const line = doc.lineAt(Math.min(from, doc.length)); - builder.add(line.from, line.from, gapMark(kindOf(c), from > doc.length)); + builder.add(line.from, line.from, gapMark(chunkKind(c), from > doc.length)); } return builder.finish(); } @@ -143,7 +140,6 @@ const gapMarks = ViewPlugin.fromClass( const SVG = "http://www.w3.org/2000/svg"; const ARROW_HEIGHT = 16; -let gradients = 0; export interface UnalignedPanesOptions { /** Scrolling one pane carries the other along. */ @@ -162,7 +158,6 @@ export class UnalignedPanes { private readonly divider = document.createElement("div"); private readonly svg = document.createElementNS(SVG, "svg"); private readonly bandLayer = document.createElementNS(SVG, "g"); - private readonly gradient = `cm-diffBandGradient-${++gradients}`; private readonly arrowEls = new Map(); /** Scroll position each pane was last moved to by the other one, to tell its echo from the user. */ private readonly moved: Record = { @@ -179,10 +174,6 @@ export class UnalignedPanes { this.extension = [gapMarks, changedLineGutters, EditorView.updateListener.of(() => this.schedule())]; this.divider.className = "cm-diffDivider"; this.svg.setAttribute("aria-hidden", "true"); - this.svg.innerHTML = - `` + - `` + - ``; this.svg.appendChild(this.bandLayer); this.divider.appendChild(this.svg); } @@ -321,12 +312,11 @@ export class UnalignedPanes { const [b0, b1] = extent(mv.b, c.fromB, c.toB).map((y) => y + offB) as [number, number]; if (a0 > h + 1 && b0 > h + 1) break; if (a1 < -1 && b1 < -1) continue; - const kind = kindOf(c); - const fill = kind === "modified" ? ` fill="url(#${this.gradient})"` : ""; + const kind = chunkKind(c); const mid = (a0 + a1) / 2; const midB = (b0 + b1) / 2; paths += - `` + + `` + ``; if (arrows?.toB) this.placeArrow(shown, i, "a-to-b", arrowTop(a0, a1), h); if (arrows?.toA) this.placeArrow(shown, i, "b-to-a", arrowTop(b0, b1), h); diff --git a/src/styles/tokens.css b/src/styles/tokens.css index cb856a08..5200c8ef 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -73,6 +73,10 @@ html { --gutter-modified: #375fad; --gutter-added: #549159; --gutter-deleted: #868a91; + /* diff viewer change types (IDEA DIFF_MODIFIED / INSERTED / DELETED) */ + --diff-modified: #385570; + --diff-inserted: #294436; + --diff-deleted: #484a4a; /* coverage (IDEA LINE_FULL / PARTIAL / NONE_COVERAGE), following the theme's status colours */ --coverage-covered: var(--gutter-added); @@ -448,6 +452,9 @@ html { --gutter-modified: #88adf7; --gutter-added: #7fc784; --gutter-deleted: #767a8a; + --diff-modified: #c2d8f2; + --diff-inserted: #bee6be; + --diff-deleted: #d6d6d6; /* syntax (Light scheme + Default / Python defaults) */ --syn-keyword: #0033b3; @@ -699,6 +706,9 @@ html { --gutter-modified: #6e95e8; --gutter-added: #6fb875; --gutter-deleted: #a5a8ae; + --diff-modified: #000099; + --diff-inserted: #00400f; + --diff-deleted: #4d2e08; --syn-keyword: #e8a885; --syn-string: #8ccc94; @@ -976,6 +986,9 @@ html { --info: var(--palette-blue); --vcs-modified: var(--palette-blue); --gutter-modified: var(--palette-blue); + --diff-modified: color-mix(in srgb, var(--palette-blue) 30%, var(--palette-bg)); + --diff-inserted: color-mix(in srgb, var(--palette-green) 30%, var(--palette-bg)); + --diff-deleted: color-mix(in srgb, var(--palette-fg) 18%, var(--palette-bg)); --syn-link: var(--palette-blue); --link-hover: var(--palette-cyan); --danger: var(--palette-red); diff --git a/templates/theme-plugin/themes/harbor-night.theme.json b/templates/theme-plugin/themes/harbor-night.theme.json index 11258a0b..85401e89 100644 --- a/templates/theme-plugin/themes/harbor-night.theme.json +++ b/templates/theme-plugin/themes/harbor-night.theme.json @@ -63,6 +63,9 @@ "--gutter-modified": "#469e98", "--gutter-added": "#549159", "--gutter-deleted": "#75869d", + "--diff-modified": "#2f4f63", + "--diff-inserted": "#294436", + "--diff-deleted": "#43494f", // Shadows and window controls "--shadow-popup": "0 6px 20px rgba(0, 0, 0, 0.45)",