diff --git a/src/api/diff.test.ts b/src/api/diff.test.ts deleted file mode 100644 index 0e9d886..0000000 --- a/src/api/diff.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { test, expect, describe } from "bun:test"; -import { parseDiffWithHighlighting, highlightFileLines } from "./diff"; - -const SIMPLE_PATCH = `@@ -1,3 +1,3 @@ - context --old line -+new line - context`; - -describe("parseDiffWithHighlighting", () => { - test("returns empty hunks for empty patch", () => { - const result = parseDiffWithHighlighting("", "test.ts"); - expect(result.hunks).toHaveLength(0); - }); - - test("parses a basic patch into hunks", () => { - const result = parseDiffWithHighlighting(SIMPLE_PATCH, "test.ts"); - expect(result.hunks.length).toBeGreaterThan(0); - expect(result.hunks[0].type).toBe("hunk"); - }); - - test("returns cached result on second call with same cacheKey", () => { - const key = "cache-test-unique-key-1"; - const r1 = parseDiffWithHighlighting( - SIMPLE_PATCH, - "test.ts", - undefined, - key - ); - const r2 = parseDiffWithHighlighting( - SIMPLE_PATCH, - "test.ts", - undefined, - key - ); - expect(r1).toBe(r2); - }); - - test("produces distinct objects for different cache keys", () => { - const r1 = parseDiffWithHighlighting( - SIMPLE_PATCH, - "test.ts", - undefined, - "key-alpha" - ); - const r2 = parseDiffWithHighlighting( - SIMPLE_PATCH, - "test.ts", - undefined, - "key-beta" - ); - expect(r1).not.toBe(r2); - }); - - test("handles file rename (previousFilename)", () => { - const result = parseDiffWithHighlighting(SIMPLE_PATCH, "new.ts", "old.js"); - expect(result.hunks.length).toBeGreaterThan(0); - }); - - test("uses syntax highlighting html on delete and insert lines", () => { - const result = parseDiffWithHighlighting(SIMPLE_PATCH, "test.ts"); - const hunk = result.hunks.find((h) => h.type === "hunk"); - expect(hunk?.type).toBe("hunk"); - if (hunk?.type === "hunk") { - const lines = hunk.lines; - expect( - lines.every((l) => l.content.every((s) => typeof s.html === "string")) - ).toBe(true); - } - }); - - test("mergeModifiedLines: adjacent delete+insert within ratio become one normal line", () => { - const patch = `@@ -1,2 +1,2 @@ --foo bar -+foo baz`; - const result = parseDiffWithHighlighting(patch, "test.ts"); - const hunk = result.hunks.find((h) => h.type === "hunk"); - expect(hunk?.type).toBe("hunk"); - if (hunk?.type === "hunk") { - const merged = hunk.lines.find( - (l) => - l.type === "normal" && - l.oldLineNumber !== undefined && - l.newLineNumber !== undefined - ); - expect(merged).toBeDefined(); - } - }); - - test("inlineMaxCharEdits: lines too dissimilar to merge produce separate delete/insert", () => { - // 50-char rewrite: calculateChangeRatio = 1.0 > maxChangeRatio 0.45 → no merge - const longOld = "a".repeat(50); - const longNew = "b".repeat(50); - const patch = `@@ -1,1 +1,1 @@ --${longOld} -+${longNew}`; - const result = parseDiffWithHighlighting(patch, "test.ts"); - const hunk = result.hunks.find((h) => h.type === "hunk"); - expect(hunk?.type).toBe("hunk"); - if (hunk?.type === "hunk") { - // Lines that exceed the change ratio come out as separate delete/insert lines - const lineTypes = hunk.lines.map((l) => l.type); - expect(lineTypes).toContain("delete"); - expect(lineTypes).toContain("insert"); - } - }); - - test("inlineMaxCharEdits: word-level segments used when char diff exceeds limit", () => { - // "baz" → "ZZZZZZ": char edits = 3+6 = 9 > INLINE_MAX_CHAR_EDITS(4), word-level segments used - const patch = `@@ -1,1 +1,1 @@ --foo bar baz qux -+foo bar ZZZZZZ qux`; - const result = parseDiffWithHighlighting(patch, "test.ts"); - const hunk = result.hunks.find((h) => h.type === "hunk"); - expect(hunk?.type).toBe("hunk"); - if (hunk?.type === "hunk") { - const line = hunk.lines[0]; - expect(line.type).toBe("normal"); - // Word-level inline diff: delete whole "baz" word, insert whole "ZZZZZZ" word - const types = line.content.map((s) => s.type); - expect(types).toContain("delete"); - expect(types).toContain("insert"); - } - }); - - test("uses pre-highlighted file content when oldContent is provided", () => { - const oldContent = "context\nold line\ncontext"; - const newContent = "context\nnew line\ncontext"; - const result = parseDiffWithHighlighting( - SIMPLE_PATCH, - "test.ts", - undefined, - undefined, - oldContent, - newContent - ); - expect(result.hunks.length).toBeGreaterThan(0); - }); - - test("inserts skip block between non-adjacent hunks", () => { - const patch = `@@ -1,3 +1,3 @@ - a --b -+B - c -@@ -10,3 +10,3 @@ - x --y -+Y - z`; - const result = parseDiffWithHighlighting(patch, "test.ts"); - const skip = result.hunks.find((h) => h.type === "skip"); - expect(skip).toBeDefined(); - expect(skip?.type).toBe("skip"); - }); - - test("does not pair delete with insert from a different section that happens to have identical content", () => { - // Simulates the real-world case: a new function is inserted with param `x`, - // then an existing function has param `x` removed and `y` added. - // The delete of `x` must NOT pair with the insert of `x` in the new function. - const patch = `@@ -1,5 +1,11 @@ - context1 - context2 -+def new_func( -+ x: str, -+): -+ pass -+ - def existing_func( -- x: str, -+ y: str, - other: str,`; - const result = parseDiffWithHighlighting(patch, "test.py"); - const hunk = result.hunks.find((h) => h.type === "hunk"); - expect(hunk?.type).toBe("hunk"); - if (hunk?.type === "hunk") { - // The `x: str` in the new function must remain as a separate insert - const insertX = hunk.lines.find( - (l) => l.type === "insert" && l.newLineNumber === 4 - ); - expect(insertX).toBeDefined(); - - // The delete of `x` (old:4) and insert of `y` (new:9) must be merged - const mergedLine = hunk.lines.find( - (l) => - l.type === "normal" && l.oldLineNumber === 4 && l.newLineNumber === 9 - ); - expect(mergedLine).toBeDefined(); - if (mergedLine) { - const segments = mergedLine.content; - expect( - segments.some((s) => s.type === "delete" && s.value === "x") - ).toBe(true); - expect( - segments.some((s) => s.type === "insert" && s.value === "y") - ).toBe(true); - } - } - }); -}); - -describe("highlightFileLines", () => { - const content = "line 1\nline 2\nline 3\nline 4\nline 5"; - - test("returns the requested number of lines", () => { - const result = highlightFileLines(content, "test.ts", 1, 3); - expect(result).toHaveLength(3); - }); - - test("all returned lines have type=normal", () => { - const result = highlightFileLines(content, "test.ts", 1, 5); - expect(result.every((l) => l.type === "normal")).toBe(true); - }); - - test("line numbers match the requested range", () => { - const result = highlightFileLines(content, "test.ts", 2, 3); - expect(result[0].oldLineNumber).toBe(2); - expect(result[0].newLineNumber).toBe(2); - expect(result[1].oldLineNumber).toBe(3); - expect(result[2].oldLineNumber).toBe(4); - }); - - test("each line has a single content segment with html", () => { - const result = highlightFileLines("const x = 1;", "test.ts", 1, 1); - expect(result[0].content).toHaveLength(1); - expect(typeof result[0].content[0].html).toBe("string"); - }); - - test("handles startLine beyond file length gracefully", () => { - const result = highlightFileLines(content, "test.ts", 20, 2); - expect(result).toHaveLength(2); - result.forEach((line) => { - expect(line.content[0].value).toBe(""); - }); - }); - - test("guesses language from extension for syntax highlighting", () => { - const jsContent = "function foo() { return 1; }"; - const result = highlightFileLines(jsContent, "script.js", 1, 1); - // With JS syntax highlighting, html will contain span tags - expect(result[0].content[0].html).toContain("function"); - }); -}); diff --git a/src/api/diff.ts b/src/api/diff.ts deleted file mode 100644 index 15c202f..0000000 --- a/src/api/diff.ts +++ /dev/null @@ -1,785 +0,0 @@ -import gitDiffParser, { - Hunk as _Hunk, - Change as _Change, - DeleteChange, - InsertChange, -} from "gitdiff-parser"; -import { diffArrays } from "diff"; -import { refractor } from "refractor/all"; -import { INLINE_MAX_CHAR_EDITS } from "../diff-parse-constants"; -import { - buildInlineDiffSegments, - escapeHtml, - hastToHtml, - highlightFileByLines, - tokenizeWords, - type RawLineSegment, -} from "../shared/diff-utils"; - -// ============================================================================ -// Types -// ============================================================================ - -export interface LineSegment { - value: string; - html: string; // Pre-highlighted HTML - type: "insert" | "delete" | "normal"; -} - -type ReplaceKey = T extends unknown - ? Omit & Record - : never; - -export interface DiffLine { - type: "insert" | "delete" | "normal"; - lineNumber?: number; - oldLineNumber?: number; - newLineNumber?: number; - content: LineSegment[]; -} - -export interface DiffHunk { - type: "hunk"; - oldStart: number; - newStart: number; - lines: DiffLine[]; -} - -export interface DiffSkipBlock { - type: "skip"; - count: number; - content: string; -} - -export interface ParsedDiff { - hunks: (DiffHunk | DiffSkipBlock)[]; -} - -interface ParseOptions { - maxDiffDistance: number; - maxChangeRatio: number; - mergeModifiedLines: boolean; - inlineMaxCharEdits: number; -} - -type Line = ReplaceKey<_Change, "content", RawLineSegment[]>; - -interface Hunk extends Omit<_Hunk, "changes"> { - type: "hunk"; - lines: Line[]; -} - -interface SkipBlock { - count: number; - type: "skip"; - content: string; -} - -// ============================================================================ -// Language Detection -// ============================================================================ - -const extToLang: Record = { - js: "javascript", - jsx: "jsx", - ts: "typescript", - tsx: "tsx", - mjs: "javascript", - cjs: "javascript", - html: "markup", - htm: "markup", - xml: "markup", - svg: "markup", - css: "css", - scss: "scss", - sass: "sass", - less: "less", - py: "python", - pyw: "python", - pyi: "python", - java: "java", - kt: "kotlin", - scala: "scala", - groovy: "groovy", - c: "c", - cpp: "cpp", - cc: "cpp", - cxx: "cpp", - h: "cpp", - hpp: "cpp", - cs: "csharp", - vb: "vbnet", - fs: "fsharp", - rs: "rust", - go: "go", - rb: "ruby", - rake: "ruby", - php: "php", - phtml: "php", - sh: "bash", - bash: "bash", - zsh: "bash", - fish: "bash", - json: "json", - yml: "yaml", - yaml: "yaml", - toml: "toml", - ini: "ini", - md: "markdown", - markdown: "markdown", - tex: "latex", - swift: "swift", - m: "objectivec", - mm: "objectivec", - sql: "sql", - r: "r", - lua: "lua", - perl: "perl", - pl: "perl", - dart: "dart", - elm: "elm", - ex: "elixir", - exs: "elixir", - erl: "erlang", - clj: "clojure", - cljs: "clojure", - lisp: "lisp", - hs: "haskell", - ml: "ocaml", - graphql: "graphql", - proto: "protobuf", - vim: "vim", - zig: "zig", -}; - -function guessLang(filename?: string): string { - const ext = filename?.split(".").pop()?.toLowerCase() ?? ""; - return extToLang[ext] ?? "tsx"; -} - -// ============================================================================ -// Syntax Highlighting -// ============================================================================ - -function highlight(code: string, lang: string): string { - try { - const tree = refractor.highlight(code, lang); - return tree.children.map(hastToHtml).join(""); - } catch { - return escapeHtml(code); - } -} - -/** - * Highlight a range of lines from file content. - * Returns an array of DiffLine objects with syntax highlighting. - */ -export function highlightFileLines( - content: string, - filename: string, - startLine: number, - count: number -): DiffLine[] { - const language = guessLang(filename); - const allLines = content.split("\n"); - - // Pre-highlight the entire file for proper context - const highlightedLines = highlightFileByLines(content, language); - - const result: DiffLine[] = []; - - for (let i = 0; i < count; i++) { - const lineNum = startLine + i; - const lineContent = allLines[lineNum - 1] ?? ""; - // Use pre-highlighted HTML, fallback to individual highlighting - const highlighted = - highlightedLines[lineNum - 1] ?? highlight(lineContent, language); - - result.push({ - type: "normal", - oldLineNumber: lineNum, - newLineNumber: lineNum, - content: [{ value: lineContent, html: highlighted, type: "normal" }], - }); - } - - return result; -} - -// ============================================================================ -// Diff Parsing -// ============================================================================ - -const calculateChangeRatio = (a: string, b: string): number => { - const totalChars = a.length + b.length; - if (totalChars === 0) return 1; - const tokensA = tokenizeWords(a); - const tokensB = tokenizeWords(b); - const diffs = diffArrays(tokensA, tokensB); - const changedChars = diffs - .filter((part) => part.added || part.removed) - .reduce((sum, part) => sum + part.value.join("").length, 0); - return changedChars / totalChars; -}; - -const isSimilarEnough = ( - a: string, - b: string, - maxChangeRatio: number -): boolean => { - if (maxChangeRatio <= 0) return a === b; - if (maxChangeRatio >= 1) return true; - return calculateChangeRatio(a, b) <= maxChangeRatio; -}; - -const changeToLine = (change: _Change): Line => { - const line: Line = { - ...change, - content: [{ value: change.content, type: "normal" }], - }; - if ("lineNumber" in change && change.lineNumber != null) { - if (change.type === "insert") { - (line as any).newLineNumber = change.lineNumber; - } else { - (line as any).oldLineNumber = change.lineNumber; - } - } - return line; -}; - -const UNPAIRED = -1; - -function buildChangeIndices(changes: _Change[]) { - const insertIdxs: number[] = []; - const deleteIdxs: number[] = []; - for (let i = 0; i < changes.length; i++) { - const c = changes[i]; - if (c.type === "insert") insertIdxs.push(i); - else if (c.type === "delete") deleteIdxs.push(i); - } - return { insertIdxs, deleteIdxs }; -} - -function findBestInsertForDelete( - changes: _Change[], - delIdx: number, - insertIdxs: number[], - pairOfAdd: Int32Array, - options: ParseOptions -): number { - const del = changes[delIdx] as DeleteChange; - - let bestAddIdx = UNPAIRED; - let bestRatio = Infinity; - let bestDist = Infinity; - - for (const addIdx of insertIdxs) { - const add = changes[addIdx] as InsertChange; - if (pairOfAdd[addIdx] !== UNPAIRED) continue; - if (addIdx < delIdx) continue; - - const ratio = calculateChangeRatio(del.content, add.content); - if (ratio > options.maxChangeRatio) continue; - const dist = addIdx - delIdx; - if (ratio < bestRatio - 0.05) { - bestRatio = ratio; - bestAddIdx = addIdx; - bestDist = dist; - } else if (Math.abs(ratio - bestRatio) <= 0.05) { - if (dist < bestDist) { - bestAddIdx = addIdx; - bestRatio = ratio; - bestDist = dist; - } - } - } - - return bestAddIdx; -} - -function buildInitialPairs( - changes: _Change[], - insertIdxs: number[], - deleteIdxs: number[], - options: ParseOptions -) { - const n = changes.length; - const pairOfDel = new Int32Array(n).fill(UNPAIRED); - const pairOfAdd = new Int32Array(n).fill(UNPAIRED); - - for (const di of deleteIdxs) { - const bestAddIdx = findBestInsertForDelete( - changes, - di, - insertIdxs, - pairOfAdd, - options - ); - if (bestAddIdx !== UNPAIRED) { - pairOfDel[di] = bestAddIdx; - pairOfAdd[bestAddIdx] = di; - } - } - - return { pairOfDel, pairOfAdd }; -} - -function detectAndUnpairCrossings( - changes: _Change[], - pairOfDel: Int32Array, - pairOfAdd: Int32Array, - deleteIdxs: number[] -) { - const pairs: { delIdx: number; oldLN: number; newLN: number }[] = []; - for (const di of deleteIdxs) { - const ai = pairOfDel[di]; - if (ai === UNPAIRED) continue; - const del = changes[di] as DeleteChange; - const add = changes[ai] as InsertChange; - pairs.push({ delIdx: di, oldLN: del.lineNumber, newLN: add.lineNumber }); - } - - pairs.sort((a, b) => a.newLN - b.newLN); - - for (let i = 1; i < pairs.length; i++) { - if (pairs[i].oldLN < pairs[i - 1].oldLN) { - const d1 = Math.abs(pairs[i - 1].oldLN - pairs[i - 1].newLN); - const d2 = Math.abs(pairs[i].oldLN - pairs[i].newLN); - if (d1 >= d2) { - const di = pairs[i - 1].delIdx; - const ai = pairOfDel[di]; - pairOfDel[di] = UNPAIRED; - pairOfAdd[ai] = UNPAIRED; - } else { - const di = pairs[i].delIdx; - const ai = pairOfDel[di]; - pairOfDel[di] = UNPAIRED; - pairOfAdd[ai] = UNPAIRED; - } - return detectAndUnpairCrossings( - changes, - pairOfDel, - pairOfAdd, - deleteIdxs - ); - } - } -} - -function buildUnpairedDeletePrefix(changes: _Change[], pairOfDel: Int32Array) { - const n = changes.length; - const prefix = new Int32Array(n + 1); - for (let i = 0; i < n; i++) { - const c = changes[i]; - const isInitiallyUnpairedDelete = - c.type === "delete" && pairOfDel[i] === UNPAIRED; - prefix[i + 1] = prefix[i] + (isInitiallyUnpairedDelete ? 1 : 0); - } - return prefix; -} - -function hasUnpairedDeleteBetween( - unpairedDelPrefix: Int32Array, - deleteIdx: number, - insertIdx: number -) { - const lower = Math.max(0, deleteIdx); - const upper = Math.max(lower, insertIdx); - return unpairedDelPrefix[upper] - unpairedDelPrefix[lower] > 0; -} - -function emitNormal(out: Line[], c: _Change) { - out.push(changeToLine(c)); -} - -function emitModified( - out: Line[], - del: DeleteChange, - add: InsertChange, - options: ParseOptions -) { - out.push({ - oldLineNumber: del.lineNumber, - newLineNumber: add.lineNumber, - type: "normal", - isNormal: true, - content: buildInlineDiffSegments( - del.content, - add.content, - options.inlineMaxCharEdits - ), - }); -} - -function emitLines( - changes: _Change[], - pairOfDel: Int32Array, - pairOfAdd: Int32Array, - unpairedDelPrefix: Int32Array, - options: ParseOptions -): Line[] { - const out: Line[] = []; - const unpairedInserts: Line[] = []; - const processed = new Uint8Array(changes.length); - - for (let i = 0; i < changes.length; i++) { - if (processed[i]) continue; - const c = changes[i]; - - if (c.type === "normal") { - processed[i] = 1; - emitNormal(out, c); - } else if (c.type === "delete") { - const pairedAddIdx = pairOfDel[i]; - if (pairedAddIdx === UNPAIRED) { - processed[i] = 1; - emitNormal(out, c); - } else if (pairedAddIdx > i) { - const shouldUnpair = hasUnpairedDeleteBetween( - unpairedDelPrefix, - i + 1, - pairedAddIdx - ); - if (shouldUnpair) { - pairOfAdd[pairedAddIdx] = UNPAIRED; - processed[i] = 1; - emitNormal(out, c); - } else { - processed[i] = 1; - } - } else { - const add = changes[pairedAddIdx] as InsertChange; - emitModified(out, c, add, options); - processed[i] = 1; - processed[pairedAddIdx] = 1; - } - } else { - const pairedDelIdx = pairOfAdd[i]; - if (pairedDelIdx === UNPAIRED) { - processed[i] = 1; - unpairedInserts.push(changeToLine(c)); - } else { - const del = changes[pairedDelIdx] as DeleteChange; - emitModified(out, del, c, options); - processed[i] = 1; - processed[pairedDelIdx] = 1; - } - } - } - - const deletePosition = new Map(); - for (let i = 0; i < out.length; i++) { - const line = out[i]; - if ((line as any).newLineNumber != null) continue; - if (line.type !== "delete") continue; - for (let j = i + 1; j < out.length; j++) { - const next = out[j]; - const n = (next as any).newLineNumber; - if (n != null) { - deletePosition.set(line, n); - break; - } - } - } - - const result = [...out, ...unpairedInserts]; - result.sort((a, b) => { - const aPos = - (a as any).newLineNumber ?? - deletePosition.get(a) ?? - (a as any).lineNumber ?? - -Infinity; - const bPos = - (b as any).newLineNumber ?? - deletePosition.get(b) ?? - (b as any).lineNumber ?? - -Infinity; - if (aPos !== bPos) return aPos - bPos; - return 0; - }); - - for (let i = 0; i < result.length - 1; i++) { - const a = result[i]; - const b = result[i + 1]; - if ( - a.type === "delete" && - b.type === "insert" && - a.content.length === 1 && - b.content.length === 1 && - a.content[0].value.trim() === "" && - b.content[0].value.trim() === "" - ) { - result[i] = { - type: "normal", - isNormal: true, - oldLineNumber: (a as any).lineNumber, - newLineNumber: (b as any).lineNumber, - content: b.content, - } as Line; - result.splice(i + 1, 1); - i--; - } - } - - return result; -} - -function mergeModifiedLines(changes: _Change[], options: ParseOptions): Line[] { - const { insertIdxs, deleteIdxs } = buildChangeIndices(changes); - const { pairOfDel, pairOfAdd } = buildInitialPairs( - changes, - insertIdxs, - deleteIdxs, - options - ); - - detectAndUnpairCrossings(changes, pairOfDel, pairOfAdd, deleteIdxs); - - // Count unpaired deletes and inserts to detect complete permutations - // (e.g. rotation of identical lines) where crossings are expected. - let unpairedDelCount = 0; - for (const di of deleteIdxs) { - if (pairOfDel[di] === UNPAIRED) unpairedDelCount++; - } - let unpairedInsCount = 0; - for (const ai of insertIdxs) { - if (pairOfAdd[ai] === UNPAIRED) unpairedInsCount++; - } - const isCompletePermutation = - unpairedDelCount > 0 && unpairedDelCount === unpairedInsCount; - - for (const di of deleteIdxs) { - if (pairOfDel[di] !== UNPAIRED) continue; - const del = changes[di] as DeleteChange; - for (const ai of insertIdxs) { - if (pairOfAdd[ai] !== UNPAIRED) continue; - if (ai < di) continue; - const add = changes[ai] as InsertChange; - if (del.content.trim() !== add.content.trim()) continue; - if (del.content.trim() === "") continue; - - // Prevent re-pairing that would create non-monotonic crossings, - // unless this is a complete permutation (rotation) of identical lines. - if (!isCompletePermutation) { - const candOld = del.lineNumber; - const candNew = add.lineNumber; - let createsCrossing = false; - for (const ddi of deleteIdxs) { - if (ddi === di) continue; - const aai = pairOfDel[ddi]; - if (aai === UNPAIRED) continue; - const d = changes[ddi] as DeleteChange; - const a = changes[aai] as InsertChange; - if ( - (candOld < d.lineNumber && candNew > a.lineNumber) || - (candOld > d.lineNumber && candNew < a.lineNumber) - ) { - createsCrossing = true; - break; - } - } - if (createsCrossing) continue; - } - - pairOfDel[di] = ai; - pairOfAdd[ai] = di; - break; - } - } - - const unpairedDelPrefix = buildUnpairedDeletePrefix(changes, pairOfDel); - return emitLines(changes, pairOfDel, pairOfAdd, unpairedDelPrefix, options); -} - -const parseHunk = (hunk: _Hunk, options: ParseOptions): Hunk => { - return { - ...hunk, - type: "hunk", - lines: options.mergeModifiedLines - ? mergeModifiedLines(hunk.changes, options) - : hunk.changes.map(changeToLine), - }; -}; - -const HUNK_HEADER_REGEX = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)/; - -const extractHunkContext = (header: string): string => - HUNK_HEADER_REGEX.exec(header)?.[5]?.trim() ?? ""; - -const insertSkipBlocks = (hunks: Hunk[]): (Hunk | SkipBlock)[] => { - const result: (Hunk | SkipBlock)[] = []; - let lastHunkLine = 1; - - for (const hunk of hunks) { - const distanceToLastHunk = hunk.oldStart - lastHunkLine; - const context = extractHunkContext(hunk.content); - if (distanceToLastHunk > 0) { - result.push({ - count: distanceToLastHunk, - type: "skip", - content: context ?? hunk.content, - }); - } - lastHunkLine = Math.max(hunk.oldStart + hunk.oldLines, lastHunkLine); - result.push(hunk); - } - - return result; -}; - -const defaultOptions: ParseOptions = { - maxDiffDistance: 30, - maxChangeRatio: 0.45, - mergeModifiedLines: true, - inlineMaxCharEdits: INLINE_MAX_CHAR_EDITS, -}; - -// ============================================================================ -// Main Export -// ============================================================================ - -// Cache for parsed diffs (keyed by SHA) -const diffCache = new Map(); -const MAX_CACHE_SIZE = 500; - -export function parseDiffWithHighlighting( - patch: string, - filename: string, - previousFilename?: string, - cacheKey?: string, - oldContent?: string, - newContent?: string -): ParsedDiff { - // Check cache - if (cacheKey && diffCache.has(cacheKey)) { - return diffCache.get(cacheKey)!; - } - - const diffHeader = `diff --git a/${filename} b/${filename} ---- a/${previousFilename || filename} -+++ b/${filename} -${patch}`; - - const opts = defaultOptions; - const files = gitDiffParser.parse(diffHeader); - const file = files[0]; - - if (!file) { - return { hunks: [] }; - } - - const language = guessLang(filename); - const prevLanguage = previousFilename - ? guessLang(previousFilename) - : language; - - // Pre-highlight full files if content is provided - // This ensures proper highlighting for multi-line constructs (strings, comments, etc.) - const oldHighlightedLines = oldContent - ? highlightFileByLines(oldContent, prevLanguage) - : null; - const newHighlightedLines = newContent - ? highlightFileByLines(newContent, language) - : null; - - const rawHunks = insertSkipBlocks( - file.hunks.map((hunk) => parseHunk(hunk, opts)) - ); - - // Convert to output format with highlighting - const hunks: (DiffHunk | DiffSkipBlock)[] = rawHunks.map((hunk) => { - if (hunk.type === "skip") { - return hunk as DiffSkipBlock; - } - - return { - type: "hunk" as const, - oldStart: hunk.oldStart, - newStart: hunk.newStart, - lines: hunk.lines.map((line): DiffLine => { - let oldNum: number | undefined; - let newNum: number | undefined; - if (line.type === "normal") { - oldNum = line.oldLineNumber; - newNum = line.newLineNumber; - } else if (line.type === "delete") { - oldNum = line.lineNumber; - } else { - newNum = line.lineNumber; - } - - // For lines with a single segment (no inline diff), use pre-highlighted content - // For lines with multiple segments (inline diff), highlight each segment - const hasSingleSegment = line.content.length === 1; - const singleSegmentIsNormal = - hasSingleSegment && line.content[0].type === "normal"; - - return { - type: line.type, - oldLineNumber: oldNum, - newLineNumber: newNum, - content: line.content.map((seg) => { - let html: string; - - // Try to use pre-highlighted content for better context - if (singleSegmentIsNormal) { - // Use pre-highlighted line if available - if ( - line.type === "delete" && - oldHighlightedLines && - oldNum !== undefined - ) { - html = - oldHighlightedLines[oldNum - 1] ?? - highlight(seg.value, prevLanguage); - } else if ( - line.type === "insert" && - newHighlightedLines && - newNum !== undefined - ) { - html = - newHighlightedLines[newNum - 1] ?? - highlight(seg.value, language); - } else if ( - line.type === "normal" && - newHighlightedLines && - newNum !== undefined - ) { - // For normal lines, prefer new file highlighting (same content in both) - html = - newHighlightedLines[newNum - 1] ?? - highlight(seg.value, language); - } else { - html = highlight(seg.value, language); - } - } else { - // Multiple segments (inline diff) - highlight each segment individually - // This is acceptable since inline diffs are usually small - const segLang = seg.type === "delete" ? prevLanguage : language; - html = highlight(seg.value, segLang); - } - - return { - value: seg.value, - html, - type: seg.type, - }; - }), - }; - }), - }; - }); - - const result: ParsedDiff = { hunks }; - - // Cache result - if (cacheKey) { - if (diffCache.size >= MAX_CACHE_SIZE) { - const keysToDelete = Array.from(diffCache.keys()).slice(0, 100); - keysToDelete.forEach((k) => diffCache.delete(k)); - } - diffCache.set(cacheKey, result); - } - - return result; -} diff --git a/src/browser/lib/diff-worker.test.ts b/src/browser/lib/diff-worker.test.ts index 78d87d8..d768394 100644 --- a/src/browser/lib/diff-worker.test.ts +++ b/src/browser/lib/diff-worker.test.ts @@ -327,6 +327,80 @@ describe("parse-diff message", () => { expect(posted[0].result.hunks.length).toBeGreaterThan(0); }); + test("no skip block when hunks are contiguous from line 1", () => { + const patch = [ + "@@ -1,3 +1,3 @@", + " context", + "-old", + "+new", + " context", + ].join("\n"); + + handler({ + data: { type: "parse-diff", id: "no-skip", patch, filename: "test.ts" }, + }); + + const hunks = posted[0].result.hunks; + expect(hunks.some((h: any) => h.type === "skip")).toBe(false); + }); + + test("skip block count equals the gap between hunks", () => { + // First hunk ends at line 3, second starts at line 10 → gap of 6 + const patch = [ + "@@ -1,3 +1,3 @@", + " line1", + "-line2", + "+line2x", + " line3", + "@@ -10,3 +10,3 @@", + " line10", + "-line11", + "+line11x", + " line12", + ].join("\n"); + + handler({ + data: { + type: "parse-diff", + id: "skip-gap", + patch, + filename: "test.ts", + }, + }); + + const hunks = posted[0].result.hunks; + expect(hunks).toHaveLength(3); + expect(hunks[1].type).toBe("skip"); + expect(hunks[1].count).toBe(6); // 10 - 4 = 6 (lastHunkLine = oldStart(1) + oldLines(3) = 4) + }); + + test("skip block uses hunk context from header", () => { + const patch = [ + "@@ -1,2 +1,2 @@", + "-a", + "+A", + " b", + "@@ -20,2 +20,2 @@ function foo() {", + "-x", + "+X", + " y", + ].join("\n"); + + handler({ + data: { + type: "parse-diff", + id: "skip-ctx", + patch, + filename: "test.ts", + }, + }); + + const hunks = posted[0].result.hunks; + const skip = hunks.find((h: any) => h.type === "skip"); + expect(skip).toBeDefined(); + expect(skip.content).toBe("function foo() {"); + }); + test("uses full-file context so a hunk after a closing raw string highlights code as code", () => { // Reproduces the reported bug: a Rust hunk that begins after a raw-string // terminator (`"#);`) was fed to the highlighter as a fragment starting @@ -501,6 +575,41 @@ describe("highlight-lines message", () => { expect(posted[0].result[0].newLineNumber).toBe(4); expect(posted[0].result[1].newLineNumber).toBe(5); }); + + test("each line has a single content segment with html", () => { + handler({ + data: { + type: "highlight-lines", + id: "segment-1", + content: "const x = 1;", + filename: "test.ts", + startLine: 1, + oldStartLine: 1, + count: 1, + }, + }); + + expect(posted[0].result).toHaveLength(1); + expect(posted[0].result[0].content).toHaveLength(1); + expect(typeof posted[0].result[0].content[0].html).toBe("string"); + }); + + test("guesses language from extension for syntax highlighting", () => { + handler({ + data: { + type: "highlight-lines", + id: "lang-1", + content: "function foo() { return 1; }", + filename: "script.js", + startLine: 1, + oldStartLine: 1, + count: 1, + }, + }); + + // With JS syntax highlighting, html will contain span tags + expect(posted[0].result[0].content[0].html).toContain("function"); + }); }); // ============================================================================ @@ -799,6 +908,69 @@ describe("error propagation", () => { expect(pair31).toBeDefined(); }); + test("does not pair delete with insert from a different section that happens to have identical content", () => { + // A new function is inserted with param `x`, then an existing function + // has param `x` removed and `y` added. The delete of `x` must NOT pair + // with the insert of `x` in the new function — only the delete of the + // existing `x` (old=4) with the insert of `y` (new=9). + const hunkBody = [ + "@@ -1,5 +1,11 @@", + " context1", + " context2", + "+def new_func(", + "+ x: str,", + "+):", + "+ pass", + "+", + " def existing_func(", + "- x: str,", + "+ y: str,", + " other: str,", + ].join("\n"); + const patch = [ + "diff --git a/file.py b/file.py", + "--- a/file.py", + "+++ b/file.py", + hunkBody, + ].join("\n"); + + const files = gitDiffParser.parse(patch); + expect(files.length).toBeGreaterThanOrEqual(1); + const hunk = files[0].hunks[0]; + expect(hunk).toBeDefined(); + + const opts = { + maxDiffDistance: 30, + maxChangeRatio: 0.45, + mergeModifiedLines: true, + inlineMaxCharEdits: 30, + }; + const lines = mergeModifiedLines(hunk.changes, opts); + + // The `x: str` of the new function must remain a separate insert. + const insertX = lines.find( + (l: any) => l.type === "insert" && l.newLineNumber === 4 + ); + expect(insertX).toBeDefined(); + + // The delete of the existing `x` (old=4) merges with the insert of + // `y` (new=9) into a single modified line. + const mergedLine = lines.find( + (l: any) => + l.type === "normal" && l.oldLineNumber === 4 && l.newLineNumber === 9 + ); + expect(mergedLine).toBeDefined(); + if (mergedLine) { + const segments = mergedLine.content; + expect( + segments.some((s: any) => s.type === "delete" && s.value === "x") + ).toBe(true); + expect( + segments.some((s: any) => s.type === "insert" && s.value === "y") + ).toBe(true); + } + }); + test("indentation-only try lines merge in the full import_srpm diff", () => { const diffContent = `diff --git a/scripts/import_srpm.py b/scripts/import_srpm.py\n--- a/scripts/import_srpm.py\n+++ b/scripts/import_srpm.py\n${patchForImportSrpm}`; const files = gitDiffParser.parse(diffContent); @@ -812,7 +984,11 @@ describe("error propagation", () => { mergeModifiedLines: true, inlineMaxCharEdits: 30, }; - const lines = mergeModifiedLines(thirdHunk.changes, opts); + const lines = mergeModifiedLines( + thirdHunk.changes, + opts, + thirdHunk.newStart - thirdHunk.oldStart + ); // Find old=139 in any form const try139norm = lines.find( @@ -954,8 +1130,10 @@ describe("error propagation", () => { test("standalone del/ins lines produce monotonic new-line numbers in all-deletes-first blocks", () => { // A block replacement where all deletes precede all inserts. // With maxChangeRatio=0.45, different-content lines won't pair, - // so they remain as standalone deletes and inserts. Sort-by-index - // keeps lines in original changes order (deletes then inserts). + // so they remain as standalone deletes and inserts. Deletes are + // positioned right after the preceding new-side row, so the order + // reads context1, apple, banana, cherry, date, context2 — GitHub's + // deletes-then-inserts order — with both gutters monotonic. const patch = [ "@@ -1,4 +1,4 @@", " context1", @@ -989,9 +1167,19 @@ describe("error propagation", () => { } } - // Lines are sorted by their original index in the changes array. - // Unpaired deletes and inserts appear in the order they were in the - // original diff (deletes then inserts, within each group in file order). + // All old-line-number values must be non-decreasing as well. + let prevOld: number | null = null; + for (const l of lines) { + if (l.type === "insert") continue; + const o = (l as any).oldLineNumber ?? (l as any).lineNumber; + if (o != null) { + if (prevOld != null) { + expect(o).toBeGreaterThanOrEqual(prevOld); + } + prevOld = o; + } + } + const appleDel = lines.find( (l: any) => l.type === "delete" && l.content[0]?.value === "apple" ); @@ -1009,19 +1197,16 @@ describe("error propagation", () => { expect(bananaDel).toBeDefined(); expect(dateIns).toBeDefined(); - // apple (idx=1) sorts before cherry (idx=3) because it appeared - // first in the original changes array. + // Deletes share the key of the preceding context row (plus a half-step), + // and ties keep original diff order, so apple and banana sort before + // cherry and date, in GitHub's deletes-then-inserts order. const appleIdx = lines.indexOf(appleDel!); const cherryIdx = lines.indexOf(cherryIns!); expect(appleIdx).toBeLessThan(cherryIdx); - // banana (idx=2) sorts before cherry (idx=3) because deletes - // precede inserts in original diff order. const bananaIdx = lines.indexOf(bananaDel!); expect(bananaIdx).toBeLessThan(cherryIdx); - // banana (idx=2) sorts before date (idx=4) — both are in - // original changes order. const dateIdx = lines.indexOf(dateIns!); expect(bananaIdx).toBeLessThan(dateIdx); }); @@ -1029,7 +1214,7 @@ describe("error propagation", () => { test("delete-only line positioned after its surrounding paired lines", () => { // A standalone delete (old=3) surrounded by paired lines above and below // must be placed between them — its estimated position comes from the - // next paired line's new-line number. + // preceding paired line's new-line number. // "hello world" → "hello_world" pairs (ratio ≈ 0.17 < 0.45). // "standalone" is unpaired. // "foo bar" → "foo baz" pairs via calculateChangeRatio @@ -1079,13 +1264,14 @@ describe("error propagation", () => { expect(delIdx).toBeLessThan(afterIdx); }); - test("old-line numbers are monotonic when modified line shifts past a following context line", () => { - // A modified line (old=2, new=3) and a context line (old=3, new=2) - // cause the left gutter to show 3 then 2 when sorted by new-line - // number. Sort-by-index preserves original diff order so old-line - // numbers remain monotonic even when new-line numbers are not. + test("delete and insert separated by a context line stay separate rows", () => { + // A context line between a delete and an insert means they belong to + // different change groups: git treats them as independent edits. Merging + // them across the context line mispositions the merged row and makes one + // gutter column non-monotonic, so they must remain separate rows: // old: A(1), "foo bar"(2), C(3), D(4) // new: A(1), C(2), "foo baz"(3), D(4) + // displayed as A, -foo bar, C, +foo baz, D (both gutters monotonic). const patch = [ "@@ -1,4 +1,4 @@", " A", @@ -1107,7 +1293,33 @@ describe("error propagation", () => { }; const lines = mergeModifiedLines(changes, opts); - // Old-line numbers must be non-decreasing (source column ordering) + // No merge: the delete and the insert remain separate rows. + const merged = lines.find( + (l: any) => l.type === "normal" && l.content.length > 1 + ); + expect(merged).toBeUndefined(); + + const deleteRow = lines.find( + (l: any) => l.type === "delete" && l.oldLineNumber === 2 + ); + const insertRow = lines.find( + (l: any) => l.type === "insert" && l.newLineNumber === 3 + ); + const contextRow = lines.find( + (l: any) => l.type === "normal" && l.oldLineNumber === 3 + ); + expect(deleteRow).toBeDefined(); + expect(insertRow).toBeDefined(); + expect(contextRow).toBeDefined(); + + // Order: A, -foo bar, C(3/2), +foo baz, D + const deleteIdx = lines.indexOf(deleteRow!); + const contextIdx = lines.indexOf(contextRow!); + const insertIdx = lines.indexOf(insertRow!); + expect(deleteIdx).toBeLessThan(contextIdx); + expect(contextIdx).toBeLessThan(insertIdx); + + // Both gutters must be monotonic. let prevOld: number | null = null; for (const l of lines) { if (l.type === "insert") continue; @@ -1119,14 +1331,220 @@ describe("error propagation", () => { prevOld = o; } } + let prevNew: number | null = null; + for (const l of lines) { + const n = (l as any).newLineNumber; + if (n != null) { + if (prevNew != null) { + expect(n).toBeGreaterThanOrEqual(prevNew); + } + prevNew = n; + } + } + }); - // Verify the merged line has correct old/new pair (old=2, new=3) - const merged = lines.find( + test("pure insert above a modified line keeps new-side line numbers monotonic", () => { + // Regression: xcp-ng/xcp-ng-tests PR 658, commit 519ece39. A decorator + // added directly above a modified function signature used to render AFTER + // the merged modified row, so the new-file gutter read 79, 78 (the merged + // row was anchored at its delete's position in the changes array). + const patch = [ + "@@ -75,13 +75,14 @@", + " def test_drivers_detected(self, vm_install_test_tools_per_test_class: VM) -> None:", + " pass", + " ", + "- def test_vif_replug(self, vm_install_test_tools_per_test_class: VM) -> None:", + '+ @pytest.mark.parametrize("force", (False, True))', + "+ def test_vif_replug(self, vm_install_test_tools_per_test_class: VM, force: bool) -> None:", + " vm = vm_install_test_tools_per_test_class", + " for _iter in range(3):", + " vifs = vm.vifs()", + " for vif in vifs:", + ' assert strtobool(vif.param_get("currently-attached"))', + "- vif.unplug()", + "+ vif.unplug(force=force)", + " # HACK: Allow some time for the unplug to settle. If not, Windows guests have a tendency to explode.", + " # TODO reference: XCPNG-1395", + ' assert not strtobool(vif.param_get("currently-attached"))', + ].join("\n"); + + const diffContent = `diff --git a/tests/guest_tools/win/test_guest_tools_win.py b/tests/guest_tools/win/test_guest_tools_win.py\n--- a/tests/guest_tools/win/test_guest_tools_win.py\n+++ b/tests/guest_tools/win/test_guest_tools_win.py\n${patch}`; + const files = gitDiffParser.parse(diffContent); + const changes = files[0].hunks[0].changes; + + const opts = { + maxDiffDistance: 30, + maxChangeRatio: 0.45, + mergeModifiedLines: true, + inlineMaxCharEdits: 30, + }; + const lines = mergeModifiedLines(changes, opts); + + // The decorator (new 78) must render BEFORE the merged def (old 78/new 79). + const decorator = lines.find( + (l: any) => l.type === "insert" && l.newLineNumber === 78 + ); + const mergedDef = lines.find( (l: any) => - l.type === "normal" && l.oldLineNumber === 2 && l.newLineNumber === 3 + l.type === "normal" && l.oldLineNumber === 78 && l.newLineNumber === 79 ); - expect(merged).toBeDefined(); - expect(merged!.content.length).toBeGreaterThan(1); + expect(decorator).toBeDefined(); + expect(mergedDef).toBeDefined(); + expect(lines.indexOf(decorator!)).toBeLessThan(lines.indexOf(mergedDef!)); + + // The modified vif.unplug line is still merged as a word-diff. + const mergedUnplug = lines.find( + (l: any) => + l.type === "normal" && + l.oldLineNumber === 84 && + l.newLineNumber === 85 && + l.content.length > 1 + ); + expect(mergedUnplug).toBeDefined(); + + // Both gutters must be monotonic for the whole hunk. + let prevOld: number | null = null; + for (const l of lines) { + if (l.type === "insert") continue; + const o = (l as any).oldLineNumber ?? (l as any).lineNumber; + if (o != null) { + if (prevOld != null) { + expect(o).toBeGreaterThanOrEqual(prevOld); + } + prevOld = o; + } + } + let prevNew: number | null = null; + for (const l of lines) { + const n = (l as any).newLineNumber; + if (n != null) { + if (prevNew != null) { + expect(n).toBeGreaterThanOrEqual(prevNew); + } + prevNew = n; + } + } + }); + + test("new-side line numbers stay monotonic across a corpus of real hunks", () => { + // The new-file gutter is the primary reading column: whatever the diff + // shape (pure inserts above modified lines, re-indents, standalone + // deletes, rotations), the new-side line numbers of rendered rows must + // never go backwards. Old-side numbers must stay monotonic too, except + // for complete permutations (rotations of identical lines), where the + // two orders are inherently incompatible. + const opts = { + maxDiffDistance: 30, + maxChangeRatio: 0.45, + mergeModifiedLines: true, + inlineMaxCharEdits: 30, + }; + + const corpus: { name: string; patch: string; rotation?: boolean }[] = [ + { + name: "decorator above modified signature (xcp-ng PR 658)", + patch: [ + "@@ -75,13 +75,14 @@", + " def test_drivers_detected(self, vm_install_test_tools_per_test_class: VM) -> None:", + " pass", + " ", + "- def test_vif_replug(self, vm_install_test_tools_per_test_class: VM) -> None:", + '+ @pytest.mark.parametrize("force", (False, True))', + "+ def test_vif_replug(self, vm_install_test_tools_per_test_class: VM, force: bool) -> None:", + " vm = vm_install_test_tools_per_test_class", + " for _iter in range(3):", + " vifs = vm.vifs()", + " for vif in vifs:", + ' assert strtobool(vif.param_get("currently-attached"))', + "- vif.unplug()", + "+ vif.unplug(force=force)", + " # HACK: Allow some time for the unplug to settle. If not, Windows guests have a tendency to explode.", + " # TODO reference: XCPNG-1395", + ' assert not strtobool(vif.param_get("currently-attached"))', + ].join("\n"), + }, + { + name: "delete and insert split by context", + patch: [ + "@@ -1,4 +1,4 @@", + " A", + "-foo bar", + " C", + "+foo baz", + " D", + ].join("\n"), + }, + { + name: "block replacement, nothing pairs", + patch: [ + "@@ -1,4 +1,4 @@", + " context1", + "-apple", + "-banana", + "+cherry", + "+date", + " context2", + ].join("\n"), + }, + { + name: "standalone delete between merged pairs", + patch: [ + "@@ -1,6 +1,5 @@", + " context1", + "-hello world", + "+hello_world", + "-standalone", + "-foo bar", + "+foo baz", + " context2", + ].join("\n"), + }, + { + name: "rotation of identical lines", + patch: [ + "@@ -1,3 +1,3 @@", + "-foo", + "-bar", + "-baz", + "+bar", + "+baz", + "+foo", + ].join("\n"), + rotation: true, + }, + ]; + + for (const { patch, rotation } of corpus) { + const diffContent = `diff --git a/file b/file\n--- a/file\n+++ b/file\n${patch}`; + const files = gitDiffParser.parse(diffContent); + const changes = files[0].hunks[0].changes; + const lines = mergeModifiedLines(changes, opts); + + let prevNew: number | null = null; + for (const l of lines) { + const n = (l as any).newLineNumber; + if (n != null) { + if (prevNew != null) { + expect(n).toBeGreaterThanOrEqual(prevNew); + } + prevNew = n; + } + } + + if (!rotation) { + let prevOld: number | null = null; + for (const l of lines) { + if (l.type === "insert") continue; + const o = (l as any).oldLineNumber ?? (l as any).lineNumber; + if (o != null) { + if (prevOld != null) { + expect(o).toBeGreaterThanOrEqual(prevOld); + } + prevOld = o; + } + } + } + } }); test("adjacent empty delete+insert lines merge into one normal line", () => { @@ -1164,6 +1582,80 @@ describe("error propagation", () => { expect(lines[2].content[0]?.value).toBe("two"); }); + test("calculateChangeRatio boundaries: identical merges at ratio 0, dissimilar does not at a tight threshold", () => { + const opts = { + maxDiffDistance: 30, + maxChangeRatio: 0.45, + mergeModifiedLines: true, + inlineMaxCharEdits: 30, + }; + + // Identical content has ratio 0 → merges even at maxChangeRatio=0. + const identical = mergeModifiedLines( + [ + { type: "delete", lineNumber: 1, content: "identical" }, + { type: "insert", lineNumber: 1, content: "identical" }, + ] as any[], + { ...opts, maxChangeRatio: 0 } + ); + expect(identical).toHaveLength(1); + expect(identical[0].type).toBe("normal"); + + // Completely different content has ratio ~1 → stays separate even at + // a tight threshold (0.01). + const dissimilar = mergeModifiedLines( + [ + { type: "delete", lineNumber: 1, content: "aaaaaa" }, + { type: "insert", lineNumber: 1, content: "bbbbbb" }, + ] as any[], + { ...opts, maxChangeRatio: 0.01 } + ); + expect(dissimilar).toHaveLength(2); + expect(dissimilar[0].type).toBe("delete"); + expect(dissimilar[1].type).toBe("insert"); + }); + + test("inlineMaxCharEdits boundary: char-level segments within the limit, word-level beyond", () => { + const opts = { + maxDiffDistance: 30, + maxChangeRatio: 0.45, + mergeModifiedLines: true, + inlineMaxCharEdits: 4, + }; + + // "baz" → "bar": 2 edits ≤ limit → char-level inline diff. + const withinLimit = mergeModifiedLines( + [ + { type: "delete", lineNumber: 1, content: "foo bar baz" }, + { type: "insert", lineNumber: 1, content: "foo bar bar" }, + ] as any[], + opts + ); + expect(withinLimit).toHaveLength(1); + expect(withinLimit[0].type).toBe("normal"); + const withinTypes = withinLimit[0].content.map((s) => s.type); + expect(withinTypes).toContain("delete"); + expect(withinTypes).toContain("insert"); + + // "baz" → "ZZZZZZ": 3+6 = 9 edits > limit → word-level segments. + const beyondLimit = mergeModifiedLines( + [ + { type: "delete", lineNumber: 1, content: "foo bar baz qux" }, + { type: "insert", lineNumber: 1, content: "foo bar ZZZZZZ qux" }, + ] as any[], + opts + ); + expect(beyondLimit).toHaveLength(1); + expect(beyondLimit[0].type).toBe("normal"); + const segments = beyondLimit[0].content; + expect(segments.some((s) => s.type === "delete" && s.value === "baz")).toBe( + true + ); + expect( + segments.some((s) => s.type === "insert" && s.value === "ZZZZZZ") + ).toBe(true); + }); + test("crossing content-identical pairs prevented when not a complete permutation", () => { // Regression test: when a multi-line install block is restructured, // content-identical lines (install.sh, xe-linux-distribution) that diff --git a/src/browser/lib/diff-worker.ts b/src/browser/lib/diff-worker.ts index 37e4379..17e3544 100644 --- a/src/browser/lib/diff-worker.ts +++ b/src/browser/lib/diff-worker.ts @@ -282,6 +282,17 @@ function buildChangeIndices(changes: _Change[]) { return { insertIdxs, deleteIdxs }; } +// A delete and an insert separated by a context (normal) line belong to +// different change groups in the diff: git treats them as independent edits. +// Merging them across the context line mispositions the merged row and makes +// one of the gutter columns non-monotonic, so such pairs are never formed. +function hasContextBetween(changes: _Change[], delIdx: number, addIdx: number) { + for (let i = delIdx + 1; i < addIdx; i++) { + if (changes[i].type === "normal") return true; + } + return false; +} + function findBestInsertForDelete( changes: _Change[], delIdx: number, @@ -299,6 +310,7 @@ function findBestInsertForDelete( const add = changes[addIdx] as InsertChange; if (pairOfAdd[addIdx] !== UNPAIRED) continue; if (addIdx < delIdx) continue; + if (hasContextBetween(changes, delIdx, addIdx)) continue; const ratio = calculateChangeRatio(del.content, add.content); if (ratio > options.maxChangeRatio) continue; @@ -388,56 +400,50 @@ function detectAndUnpairCrossings( } } -function unpairCrossingContextLines( - changes: _Change[], - pairOfDel: Int32Array, - pairOfAdd: Int32Array, - deleteIdxs: number[] -) { - for (const di of deleteIdxs) { - const ai = pairOfDel[di]; - if (ai === UNPAIRED) continue; - const del = changes[di] as DeleteChange; - const add = changes[ai] as InsertChange; - const delOld = del.lineNumber; - const addNew = add.lineNumber; - const lo = Math.min(delOld, addNew); - const hi = Math.max(delOld, addNew); - - for (let i = di + 1; i < ai; i++) { - const c = changes[i]; - if (c.type !== "normal") continue; - const ctxOld = (c as any).oldLineNumber; - const ctxNew = (c as any).newLineNumber; - if (ctxOld == null || ctxNew == null) continue; - const oldBetween = ctxOld > lo && ctxOld < hi; - const newBetween = ctxNew > lo && ctxNew < hi; - if (oldBetween && !newBetween) { - pairOfDel[di] = UNPAIRED; - pairOfAdd[ai] = UNPAIRED; - return unpairCrossingContextLines( - changes, - pairOfDel, - pairOfAdd, - deleteIdxs - ); +// Rows are ordered by their position in the NEW file so the new-side gutter +// always reads monotonically: context, insert and merged rows are keyed by +// their new line number. Unpaired deletes (which have no new line number) are +// keyed just past the nearest row that has a new line number and precedes them +// in the OLD file, so both gutters stay monotonic; when no such row exists +// (delete before the hunk's first new-side line) the hunk's old→new delta is +// used as a fallback. The sort is stable, so rows that share a key keep their +// original diff order (deletes before inserts within a change group). +function computeSortKeys(rows: Line[], delta: number) { + const newSide: { old: number; new: number }[] = []; + for (const line of rows) { + const n = (line as any).newLineNumber; + const o = (line as any).oldLineNumber; + if (n != null && o != null) newSide.push({ old: o, new: n }); + } + newSide.sort((a, b) => a.old - b.old); + + for (const line of rows) { + const n = (line as any).newLineNumber; + if (n != null) { + (line as any)._sortKey = n; + continue; + } + const old = (line as any).oldLineNumber ?? (line as any).lineNumber ?? 0; + let key: number | null = null; + for (let i = newSide.length - 1; i >= 0; i--) { + if (newSide[i].old <= old) { + key = newSide[i].new + 0.5; + break; } } + (line as any)._sortKey = key ?? old + delta; } } -function emitNormal(out: Line[], c: _Change, sortIdx: number) { - const line = changeToLine(c); - (line as any)._sortIdx = sortIdx; - out.push(line); +function emitNormal(out: Line[], c: _Change) { + out.push(changeToLine(c)); } function emitModified( out: Line[], del: DeleteChange, add: InsertChange, - options: ParseOptions, - sortIdx: number + options: ParseOptions ) { out.push({ oldLineNumber: del.lineNumber, @@ -450,14 +456,14 @@ function emitModified( options.inlineMaxCharEdits ), }); - (out[out.length - 1] as any)._sortIdx = sortIdx; } function emitLines( changes: _Change[], pairOfDel: Int32Array, pairOfAdd: Int32Array, - options: ParseOptions + options: ParseOptions, + delta: number ): Line[] { const out: Line[] = []; const unpairedInserts: Line[] = []; @@ -469,15 +475,15 @@ function emitLines( if (c.type === "normal") { processed[i] = 1; - emitNormal(out, c, i); + emitNormal(out, c); } else if (c.type === "delete") { const pairedAddIdx = pairOfDel[i]; if (pairedAddIdx === UNPAIRED) { processed[i] = 1; - emitNormal(out, c, i); + emitNormal(out, c); } else { const add = changes[pairedAddIdx] as InsertChange; - emitModified(out, c, add, options, i); + emitModified(out, c, add, options); processed[i] = 1; processed[pairedAddIdx] = 1; } @@ -485,12 +491,10 @@ function emitLines( const pairedDelIdx = pairOfAdd[i]; if (pairedDelIdx === UNPAIRED) { processed[i] = 1; - const line = changeToLine(c); - (line as any)._sortIdx = i; - unpairedInserts.push(line); + unpairedInserts.push(changeToLine(c)); } else { const del = changes[pairedDelIdx] as DeleteChange; - emitModified(out, del, c, options, pairedDelIdx); + emitModified(out, del, c, options); processed[i] = 1; processed[pairedDelIdx] = 1; } @@ -498,10 +502,11 @@ function emitLines( } const result = [...out, ...unpairedInserts]; + computeSortKeys(result, delta); result.sort((a, b) => { - const aIdx = (a as any)._sortIdx ?? -Infinity; - const bIdx = (b as any)._sortIdx ?? -Infinity; - return aIdx - bIdx; + const aKey = (a as any)._sortKey ?? -Infinity; + const bKey = (b as any)._sortKey ?? -Infinity; + return aKey - bKey; }); for (let i = 0; i < result.length - 1; i++) { @@ -538,7 +543,8 @@ function emitLines( export function mergeModifiedLines( changes: _Change[], - options: ParseOptions + options: ParseOptions, + delta = 0 ): Line[] { const { insertIdxs, deleteIdxs } = buildChangeIndices(changes); const { pairOfDel, pairOfAdd } = buildInitialPairs( @@ -568,6 +574,7 @@ export function mergeModifiedLines( for (const ai of insertIdxs) { if (pairOfAdd[ai] !== UNPAIRED) continue; if (ai < di) continue; + if (hasContextBetween(changes, di, ai)) continue; const add = changes[ai] as InsertChange; if (del.content.trim() !== add.content.trim()) continue; if (del.content.trim() === "") continue; @@ -587,9 +594,7 @@ export function mergeModifiedLines( } } - unpairCrossingContextLines(changes, pairOfDel, pairOfAdd, deleteIdxs); - - return emitLines(changes, pairOfDel, pairOfAdd, options); + return emitLines(changes, pairOfDel, pairOfAdd, options, delta); } function wouldCreateCrossing( @@ -629,7 +634,7 @@ const parseHunk = (hunk: _Hunk, options: ParseOptions): Hunk => { ...hunk, type: "hunk", lines: options.mergeModifiedLines - ? mergeModifiedLines(hunk.changes, options) + ? mergeModifiedLines(hunk.changes, options, hunk.newStart - hunk.oldStart) : hunk.changes.map(changeToLine), }; }; diff --git a/src/browser/ui/diff/utils/guess-lang.ts b/src/browser/ui/diff/utils/guess-lang.ts deleted file mode 100644 index 908f9f5..0000000 --- a/src/browser/ui/diff/utils/guess-lang.ts +++ /dev/null @@ -1,122 +0,0 @@ -const extToLang: Record = { - // JavaScript/TypeScript - js: "javascript", - jsx: "jsx", - ts: "typescript", - tsx: "tsx", - mjs: "javascript", - cjs: "javascript", - - // Web - html: "markup", - htm: "markup", - xml: "markup", - svg: "markup", - css: "css", - scss: "scss", - sass: "sass", - less: "less", - stylus: "stylus", - - // Python - py: "python", - pyw: "python", - pyi: "python", - - // Java/JVM - java: "java", - kt: "kotlin", - kts: "kotlin", - scala: "scala", - groovy: "groovy", - - // C/C++ - c: "c", - cpp: "cpp", - cc: "cpp", - cxx: "cpp", - h: "cpp", - hpp: "cpp", - hh: "cpp", - hxx: "cpp", - - // C#/.NET - cs: "csharp", - vb: "vbnet", - fs: "fsharp", - - // Rust - rs: "rust", - - // Go - go: "go", - - // Ruby - rb: "ruby", - rake: "ruby", - - // PHP - php: "php", - phtml: "php", - - // Shell - sh: "bash", - bash: "bash", - zsh: "bash", - fish: "bash", - - // Data formats - json: "json", - json5: "json5", - yml: "yaml", - yaml: "yaml", - toml: "toml", - ini: "ini", - csv: "csv", - - // Markdown/Docs - md: "markdown", - markdown: "markdown", - tex: "latex", - - // Swift/Objective-C - swift: "swift", - m: "objectivec", - mm: "objectivec", - - // SQL - sql: "sql", - - // Other languages - r: "r", - lua: "lua", - perl: "perl", - pl: "perl", - dart: "dart", - elm: "elm", - ex: "elixir", - exs: "elixir", - erl: "erlang", - clj: "clojure", - cljs: "clojure", - lisp: "lisp", - hs: "haskell", - ml: "ocaml", - - // Config files - dockerfile: "docker", - gitignore: "ignore", - - // Other - graphql: "graphql", - proto: "protobuf", - wasm: "wasm", - vim: "vim", - zig: "zig", - mermaid: "mermaid", -}; - -export const guessLang = (filename?: string): string => { - const ext = filename?.split(".").pop()?.toLowerCase() ?? ""; - return extToLang[ext] ?? "tsx"; -}; diff --git a/src/browser/ui/diff/utils/index.ts b/src/browser/ui/diff/utils/index.ts deleted file mode 100644 index 8d7f972..0000000 --- a/src/browser/ui/diff/utils/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./parse"; -export * from "./guess-lang"; diff --git a/src/browser/ui/diff/utils/parse.test.ts b/src/browser/ui/diff/utils/parse.test.ts deleted file mode 100644 index 1c374e1..0000000 --- a/src/browser/ui/diff/utils/parse.test.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { test, expect, describe } from "bun:test"; -import { parseDiff, mergeModifiedLines } from "./parse"; -import { INLINE_MAX_CHAR_EDITS } from "../../../../diff-parse-constants"; -import type { ParseOptions } from "./parse"; -import type { Change } from "gitdiff-parser"; - -const defaultOpts: ParseOptions = { - maxDiffDistance: 30, - maxChangeRatio: 0.45, - mergeModifiedLines: true, - inlineMaxCharEdits: INLINE_MAX_CHAR_EDITS, -}; - -// ============================================================================ -// Helpers -// ============================================================================ - -function makeDiff(body: string): string { - return `diff --git a/test.ts b/test.ts ---- a/test.ts -+++ b/test.ts -${body}`; -} - -// ============================================================================ -// mergeModifiedLines -// ============================================================================ - -describe("mergeModifiedLines", () => { - function makeDelete(lineNumber: number, content: string): Change { - return { type: "delete", lineNumber, content } as Change; - } - function makeInsert(lineNumber: number, content: string): Change { - return { type: "insert", lineNumber, content } as Change; - } - function makeNormal(lineNumber: number, content: string): Change { - return { - type: "normal", - lineNumber, - oldLineNumber: lineNumber, - newLineNumber: lineNumber, - content, - } as any; - } - - test("returns empty array for empty changes", () => { - expect(mergeModifiedLines([], defaultOpts)).toEqual([]); - }); - - test("normal lines pass through unchanged", () => { - const changes = [makeNormal(1, "same line")]; - const result = mergeModifiedLines(changes, defaultOpts); - expect(result).toHaveLength(1); - expect(result[0].type).toBe("normal"); - }); - - test("merges similar delete+insert into a single normal line with inline diff", () => { - const changes = [makeDelete(1, "foo bar"), makeInsert(1, "foo baz")]; - const result = mergeModifiedLines(changes, defaultOpts); - expect(result).toHaveLength(1); - expect(result[0].type).toBe("normal"); - const merged = result[0] as any; - expect(merged.oldLineNumber).toBe(1); - expect(merged.newLineNumber).toBe(1); - // Content should have multiple segments (inline diff) - expect(result[0].content.length).toBeGreaterThan(1); - }); - - test("does not merge delete+insert pairs that exceed maxChangeRatio", () => { - const opts: ParseOptions = { ...defaultOpts, maxChangeRatio: 0.1 }; - const changes = [ - makeDelete(1, "hello world"), - makeInsert(1, "completely different text"), - ]; - const result = mergeModifiedLines(changes, opts); - // Too different to merge — each line emitted separately - expect(result).toHaveLength(2); - }); - - test("does not merge lines beyond maxDiffDistance", () => { - const opts: ParseOptions = { ...defaultOpts, maxDiffDistance: 1 }; - const changes = [ - makeDelete(1, "foo"), - makeNormal(2, "context"), - makeNormal(3, "context"), - makeNormal(4, "context"), - makeInsert(10, "foo"), - ]; - const result = mergeModifiedLines(changes, opts); - // 5 changes all pass through unmerged - expect(result).toHaveLength(5); - // Delete at line 1 and insert at line 10 are NOT merged into a single normal line - const merged = (result as any[]).find( - (l) => l.oldLineNumber === 1 && l.newLineNumber === 10 - ); - expect(merged).toBeUndefined(); - // They come out as their original types - const types = result.map((l) => l.type); - expect(types[0]).toBe("delete"); - expect(types[4]).toBe("insert"); - }); - - test("unpaired delete emits as its original type", () => { - const changes = [makeDelete(1, "orphan delete")]; - const result = mergeModifiedLines(changes, defaultOpts); - expect(result).toHaveLength(1); - // emitNormal wraps it as-is, keeping delete type - expect(result[0].type).toBe("delete"); - }); - - test("unpaired insert emits as its original type", () => { - const changes = [makeInsert(1, "orphan insert")]; - const result = mergeModifiedLines(changes, defaultOpts); - expect(result).toHaveLength(1); - expect(result[0].type).toBe("insert"); - }); - - test("unpairs when visual order would invert old line numbers", () => { - // Two pairs: delete(30)→insert(10) and delete(10)→insert(20) - // Sorted by newLN: pair(30→10) at pos 10, pair(10→20) at pos 20 - // Old LN sequence: 30, 10 ← inverted in visual order! - const changes = [ - makeDelete(30, "foo bar"), - makeDelete(10, "foo bar"), - makeInsert(10, "foo baz"), - makeInsert(20, "foo baz"), - ]; - const result = mergeModifiedLines(changes, defaultOpts); - // Should be unpaired: no modified line with old=30, new=10 - const badPair = (result as any[]).find( - (l) => l.oldLineNumber === 30 && l.newLineNumber === 10 - ); - expect(badPair).toBeUndefined(); - // Should have separate delete(30) and insert(10) - const del30 = result.find( - (l) => l.type === "delete" && (l as any).lineNumber === 30 - ); - const ins10 = result.find( - (l) => l.type === "insert" && (l as any).lineNumber === 10 - ); - expect(del30).toBeDefined(); - expect(ins10).toBeDefined(); - }); -}); - -// ============================================================================ -// parseHunk (tested via parseDiff) -// ============================================================================ - -describe("parseHunk (via parseDiff)", () => { - test("parses a hunk with mixed change types", () => { - const diff = makeDiff(`@@ -1,3 +1,3 @@ - context --old -+new - context`); - const files = parseDiff(diff); - expect(files).toHaveLength(1); - const hunk = files[0].hunks.find((h) => h.type === "hunk"); - expect(hunk?.type).toBe("hunk"); - if (hunk?.type === "hunk") { - expect(hunk.lines.length).toBeGreaterThan(0); - } - }); - - test("mergeModifiedLines=false leaves delete and insert separate", () => { - const diff = makeDiff(`@@ -1,2 +1,2 @@ --foo -+foo bar`); - const files = parseDiff(diff, { mergeModifiedLines: false }); - const hunk = files[0].hunks.find((h) => h.type === "hunk"); - if (hunk?.type === "hunk") { - const types = hunk.lines.map((l) => l.type); - expect(types).toContain("delete"); - expect(types).toContain("insert"); - } - }); - - test("mergeModifiedLines=true merges similar adjacent lines", () => { - const diff = makeDiff(`@@ -1,2 +1,2 @@ --foo bar -+foo baz`); - const files = parseDiff(diff, { mergeModifiedLines: true }); - const hunk = files[0].hunks.find((h) => h.type === "hunk"); - if (hunk?.type === "hunk") { - const merged = (hunk.lines as any[]).find( - (l) => - l.type === "normal" && - l.oldLineNumber !== undefined && - l.newLineNumber !== undefined - ); - expect(merged).toBeDefined(); - } - }); -}); - -// ============================================================================ -// insertSkipBlocks (tested via parseDiff) -// ============================================================================ - -describe("insertSkipBlocks (via parseDiff)", () => { - test("no skip block when hunks are contiguous from line 1", () => { - const diff = makeDiff(`@@ -1,3 +1,3 @@ - context --old -+new - context`); - const files = parseDiff(diff); - const skip = files[0].hunks.find((h) => h.type === "skip"); - expect(skip).toBeUndefined(); - }); - - test("inserts a skip block between non-adjacent hunks", () => { - const diff = makeDiff(`@@ -1,3 +1,3 @@ - a --b -+B - c -@@ -10,3 +10,3 @@ - x --y -+Y - z`); - const files = parseDiff(diff); - const skip = files[0].hunks.find((h) => h.type === "skip"); - expect(skip).toBeDefined(); - if (skip?.type === "skip") { - expect(skip.count).toBeGreaterThan(0); - } - }); - - test("skip block count equals the gap between hunks", () => { - // First hunk ends at line 3, second starts at line 10 → gap of 7 - const diff = makeDiff(`@@ -1,3 +1,3 @@ - line1 --line2 -+line2x - line3 -@@ -10,3 +10,3 @@ - line10 --line11 -+line11x - line12`); - const files = parseDiff(diff); - const skip = files[0].hunks.find((h) => h.type === "skip"); - expect(skip?.type).toBe("skip"); - if (skip?.type === "skip") { - expect(skip.count).toBe(6); // 10 - 4 = 6 (lastHunkLine = oldStart(1) + oldLines(3) = 4) - } - }); - - test("skip block uses hunk context from header", () => { - const diff = makeDiff(`@@ -1,2 +1,2 @@ --a -+A - b -@@ -20,2 +20,2 @@ function foo() { --x -+X - y`); - const files = parseDiff(diff); - const skip = files[0].hunks.find((h) => h.type === "skip"); - if (skip?.type === "skip") { - expect(skip.content).toBe("function foo() {"); - } - }); -}); - -// ============================================================================ -// calculateChangeRatio (tested via mergeModifiedLines behavior) -// ============================================================================ - -describe("calculateChangeRatio (via mergeModifiedLines)", () => { - function makeDelete(lineNumber: number, content: string): Change { - return { type: "delete", lineNumber, content } as Change; - } - function makeInsert(lineNumber: number, content: string): Change { - return { type: "insert", lineNumber, content } as Change; - } - - test("identical strings have ratio 0 (always merge)", () => { - const changes = [makeDelete(1, "identical"), makeInsert(1, "identical")]; - const result = mergeModifiedLines(changes, { - ...defaultOpts, - maxChangeRatio: 0, - }); - // ratio 0 means identical → merges - expect(result).toHaveLength(1); - expect(result[0].type).toBe("normal"); - }); - - test("completely different strings have ratio 1 (never merge at tight threshold)", () => { - const changes = [makeDelete(1, "aaaaaa"), makeInsert(1, "bbbbbb")]; - const result = mergeModifiedLines(changes, { - ...defaultOpts, - maxChangeRatio: 0.01, - }); - // ratio ~1 → won't merge - expect(result).toHaveLength(2); - }); -}); - -// ============================================================================ -// diffCharsIfWithinEditLimit (tested via parseDiff inline diff behavior) -// ============================================================================ - -describe("diffCharsIfWithinEditLimit (via parseDiff inline diff)", () => { - test(`char-level diff applied when edits ≤ INLINE_MAX_CHAR_EDITS (${INLINE_MAX_CHAR_EDITS})`, () => { - // "baz" → "bar": diffChars edits = 1 removed ("z") + 1 added ("r") = 2 ≤ 4 - // Lines share enough common words (foo bar) for ratio ≤ 0.45 - const diff = makeDiff(`@@ -1,1 +1,1 @@ --foo bar baz -+foo bar bar`); - const files = parseDiff(diff, { - mergeModifiedLines: true, - inlineMaxCharEdits: INLINE_MAX_CHAR_EDITS, - }); - const hunk = files[0].hunks.find((h) => h.type === "hunk"); - if (hunk?.type === "hunk") { - const merged = hunk.lines[0]; - // Should be merged into a normal line with char-level inline diff - expect(merged.type).toBe("normal"); - // Char-level segments: common prefix "ba", deleted "z", inserted "r" - const deleteSegs = merged.content.filter((s) => s.type === "delete"); - const insertSegs = merged.content.filter((s) => s.type === "insert"); - expect(deleteSegs.length).toBeGreaterThan(0); - expect(insertSegs.length).toBeGreaterThan(0); - } - }); - - test("char-level diff NOT applied when edits exceed limit", () => { - const longOld = "x".repeat(INLINE_MAX_CHAR_EDITS + 5); - const longNew = "y".repeat(INLINE_MAX_CHAR_EDITS + 5); - const diff = makeDiff(`@@ -1,1 +1,1 @@ --${longOld} -+${longNew}`); - const files = parseDiff(diff, { - mergeModifiedLines: true, - inlineMaxCharEdits: INLINE_MAX_CHAR_EDITS, - }); - const hunk = files[0].hunks.find((h) => h.type === "hunk"); - if (hunk?.type === "hunk") { - const line = hunk.lines[0]; - if (line.type === "normal") { - // Each segment value should be at least INLINE_MAX_CHAR_EDITS+5 chars long - const bigSeg = line.content.find( - (s) => s.value.length >= INLINE_MAX_CHAR_EDITS + 5 - ); - expect(bigSeg).toBeDefined(); - } - } - }); -}); - -// ============================================================================ -// parseDiff (top-level integration) -// ============================================================================ - -describe("parseDiff", () => { - test("returns empty array for empty diff string", () => { - expect(parseDiff("")).toHaveLength(0); - }); - - test("processes multiple files in one diff", () => { - const diff = `diff --git a/a.ts b/a.ts ---- a/a.ts -+++ b/a.ts -@@ -1,1 +1,1 @@ --old -+new -diff --git a/b.ts b/b.ts ---- a/b.ts -+++ b/b.ts -@@ -1,1 +1,1 @@ --x -+y`; - const files = parseDiff(diff); - expect(files).toHaveLength(2); - }); - - test("partial options override defaults", () => { - const diff = makeDiff(`@@ -1,2 +1,2 @@ --foo -+foo bar`); - // Should not throw with partial options - const files = parseDiff(diff, { mergeModifiedLines: false }); - expect(files).toHaveLength(1); - }); - - test("inlineMaxCharEdits default matches INLINE_MAX_CHAR_EDITS constant", () => { - // Parsing with default opts and with explicit INLINE_MAX_CHAR_EDITS should produce same result - const diff = makeDiff(`@@ -1,1 +1,1 @@ --fooX -+fooy`); - const withDefault = parseDiff(diff); - const withExplicit = parseDiff(diff, { - inlineMaxCharEdits: INLINE_MAX_CHAR_EDITS, - }); - // Structural equality (content values should match) - const defaultHunk = withDefault[0].hunks[0]; - const explicitHunk = withExplicit[0].hunks[0]; - expect(defaultHunk).toEqual(explicitHunk); - }); -}); diff --git a/src/browser/ui/diff/utils/parse.ts b/src/browser/ui/diff/utils/parse.ts deleted file mode 100644 index e30947a..0000000 --- a/src/browser/ui/diff/utils/parse.ts +++ /dev/null @@ -1,398 +0,0 @@ -import gitDiffParser, { - Hunk as _Hunk, - File as _File, - Change as _Change, - DeleteChange, - InsertChange, -} from "gitdiff-parser"; -import { diffArrays } from "diff"; -import { INLINE_MAX_CHAR_EDITS } from "../../../../diff-parse-constants"; -import { - buildInlineDiffSegments, - tokenizeWords, -} from "../../../../shared/diff-utils"; - -export interface LineSegment { - value: string; - type: "insert" | "delete" | "normal"; -} - -type ReplaceKey = T extends unknown - ? Omit & Record - : never; - -export type Line = ReplaceKey<_Change, "content", LineSegment[]>; - -export interface Hunk extends Omit<_Hunk, "changes"> { - type: "hunk"; - lines: Line[]; -} - -export interface SkipBlock { - count: number; - type: "skip"; - content: string; -} - -export interface File extends Omit<_File, "hunks"> { - hunks: (Hunk | SkipBlock)[]; -} - -export interface ParseOptions { - maxDiffDistance: number; - maxChangeRatio: number; - mergeModifiedLines: boolean; - inlineMaxCharEdits: number; -} - -const calculateChangeRatio = (a: string, b: string): number => { - const totalChars = a.length + b.length; - if (totalChars === 0) return 1; - const tokensA = tokenizeWords(a); - const tokensB = tokenizeWords(b); - const diffs = diffArrays(tokensA, tokensB); - const changedChars = diffs - .filter((part) => part.added || part.removed) - .reduce((sum, part) => sum + part.value.join("").length, 0); - return changedChars / totalChars; -}; - -const isSimilarEnough = ( - a: string, - b: string, - maxChangeRatio: number -): boolean => { - if (maxChangeRatio <= 0) return a === b; - if (maxChangeRatio >= 1) return true; - return calculateChangeRatio(a, b) <= maxChangeRatio; -}; - -const changeToLine = (change: _Change): Line => ({ - ...change, - content: [ - { - value: change.content, - type: "normal", - }, - ], -}); - -const mergeAdjacentLines = ( - changes: _Change[], - options: ParseOptions -): Line[] => { - const out: Line[] = []; - for (let i = 0; i < changes.length; i++) { - const current = changes[i]; - const next = changes[i + 1]; - if ( - next && - current.type === "delete" && - next.type === "insert" && - isSimilarEnough(current.content, next.content, options.maxChangeRatio) - ) { - out.push({ - ...current, - type: "normal", - isNormal: true, - oldLineNumber: current.lineNumber, - newLineNumber: next.lineNumber, - content: buildInlineDiffSegments( - current.content, - next.content, - options.inlineMaxCharEdits - ), - }); - i++; - } else { - out.push(changeToLine(current)); - } - } - - return out; -}; - -const UNPAIRED = -1; - -function buildChangeIndices(changes: _Change[]) { - const insertIdxs: number[] = []; - const deleteIdxs: number[] = []; - - for (let i = 0; i < changes.length; i++) { - const c = changes[i]; - if (c.type === "insert") insertIdxs.push(i); - else if (c.type === "delete") deleteIdxs.push(i); - } - return { insertIdxs, deleteIdxs }; -} - -// TODO: slight penalty for distance? -// TODO: improve performance w binary search? -function findBestInsertForDelete( - changes: _Change[], - delIdx: number, - insertIdxs: number[], - pairOfAdd: Int32Array, - options: ParseOptions -): number { - const del = changes[delIdx] as DeleteChange; - - const lower = del.lineNumber - options.maxDiffDistance; - const upper = del.lineNumber + options.maxDiffDistance; - - let bestAddIdx = UNPAIRED; - let bestRatio = Infinity; - let bestDist = Infinity; - - for (const addIdx of insertIdxs) { - const add = changes[addIdx] as InsertChange; - - if (pairOfAdd[addIdx] !== UNPAIRED) continue; - if (addIdx <= delIdx) continue; - - if (add.lineNumber < lower) continue; - if (add.lineNumber > upper) break; - - const ratio = calculateChangeRatio(del.content, add.content); - if (ratio > options.maxChangeRatio) continue; - const dist = addIdx - delIdx; - if (ratio < bestRatio - 0.05) { - bestRatio = ratio; - bestAddIdx = addIdx; - bestDist = dist; - } else if (Math.abs(ratio - bestRatio) <= 0.05) { - if (dist < bestDist) { - bestAddIdx = addIdx; - bestRatio = ratio; - bestDist = dist; - } - } - } - - return bestAddIdx; -} - -function buildInitialPairs( - changes: _Change[], - insertIdxs: number[], - deleteIdxs: number[], - options: ParseOptions -) { - const n = changes.length; - const pairOfDel = new Int32Array(n).fill(UNPAIRED); - const pairOfAdd = new Int32Array(n).fill(UNPAIRED); - - for (const di of deleteIdxs) { - const bestAddIdx = findBestInsertForDelete( - changes, - di, - insertIdxs, - pairOfAdd, - options - ); - if (bestAddIdx !== UNPAIRED) { - pairOfDel[di] = bestAddIdx; - pairOfAdd[bestAddIdx] = di; - } - } - - return { pairOfDel, pairOfAdd }; -} - -function detectAndUnpairCrossings( - changes: _Change[], - pairOfDel: Int32Array, - pairOfAdd: Int32Array, - deleteIdxs: number[] -) { - const pairs: { delIdx: number; oldLN: number; newLN: number }[] = []; - for (const di of deleteIdxs) { - const ai = pairOfDel[di]; - if (ai === UNPAIRED) continue; - const del = changes[di] as DeleteChange; - const add = changes[ai] as InsertChange; - pairs.push({ delIdx: di, oldLN: del.lineNumber, newLN: add.lineNumber }); - } - - pairs.sort((a, b) => a.newLN - b.newLN); - - for (let i = 1; i < pairs.length; i++) { - if (pairs[i].oldLN < pairs[i - 1].oldLN) { - const d1 = Math.abs(pairs[i - 1].oldLN - pairs[i - 1].newLN); - const d2 = Math.abs(pairs[i].oldLN - pairs[i].newLN); - if (d1 >= d2) { - const di = pairs[i - 1].delIdx; - const ai = pairOfDel[di]; - pairOfDel[di] = UNPAIRED; - pairOfAdd[ai] = UNPAIRED; - } else { - const di = pairs[i].delIdx; - const ai = pairOfDel[di]; - pairOfDel[di] = UNPAIRED; - pairOfAdd[ai] = UNPAIRED; - } - return detectAndUnpairCrossings( - changes, - pairOfDel, - pairOfAdd, - deleteIdxs - ); - } - } -} - -function emitNormal(out: Line[], c: _Change) { - out.push(changeToLine(c)); -} - -function emitModified( - out: Line[], - del: DeleteChange, - add: InsertChange, - options: ParseOptions -) { - out.push({ - oldLineNumber: del.lineNumber, - newLineNumber: add.lineNumber, - type: "normal", - isNormal: true, - content: buildInlineDiffSegments( - del.content, - add.content, - options.inlineMaxCharEdits - ), - }); -} - -function emitLines( - changes: _Change[], - pairOfDel: Int32Array, - pairOfAdd: Int32Array, - options: ParseOptions -): Line[] { - const out: Line[] = []; - const processed = new Uint8Array(changes.length); - - for (let i = 0; i < changes.length; i++) { - if (processed[i]) continue; - const c = changes[i]; - - if (c.type === "normal") { - processed[i] = 1; - emitNormal(out, c); - } else if (c.type === "delete") { - const pairedAddIdx = pairOfDel[i]; - - if (pairedAddIdx === UNPAIRED) { - processed[i] = 1; - emitNormal(out, c); - } else { - const add = changes[pairedAddIdx] as InsertChange; - emitModified(out, c, add, options); - processed[i] = 1; - processed[pairedAddIdx] = 1; - } - } else { - const pairedDelIdx = pairOfAdd[i]; - - if (pairedDelIdx === UNPAIRED) { - processed[i] = 1; - emitNormal(out, c); - } else { - const del = changes[pairedDelIdx] as DeleteChange; - emitModified(out, del, c, options); - processed[i] = 1; - processed[pairedDelIdx] = 1; - } - } - } - - return out; -} - -export function mergeModifiedLines( - changes: _Change[], - options: ParseOptions -): Line[] { - const { insertIdxs, deleteIdxs } = buildChangeIndices(changes); - - const { pairOfDel, pairOfAdd } = buildInitialPairs( - changes, - insertIdxs, - deleteIdxs, - options - ); - - detectAndUnpairCrossings(changes, pairOfDel, pairOfAdd, deleteIdxs); - - return emitLines(changes, pairOfDel, pairOfAdd, options); -} - -const parseHunk = (hunk: _Hunk, options: ParseOptions): Hunk => { - if (options.mergeModifiedLines) { - return { - ...hunk, - type: "hunk", - lines: - options.maxDiffDistance === 1 - ? mergeAdjacentLines(hunk.changes, options) - : mergeModifiedLines(hunk.changes, options), - }; - } - - return { - ...hunk, - type: "hunk", - lines: hunk.changes.map(changeToLine), - }; -}; - -const HUNK_HEADER_REGEX = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)/; - -const extractHunkContext = (header: string): string => - HUNK_HEADER_REGEX.exec(header)?.[5]?.trim() ?? ""; - -const insertSkipBlocks = (hunks: Hunk[]): (Hunk | SkipBlock)[] => { - const result: (Hunk | SkipBlock)[] = []; - let lastHunkLine = 1; - - for (const hunk of hunks) { - const distanceToLastHunk = hunk.oldStart - lastHunkLine; - - const context = extractHunkContext(hunk.content); - if (distanceToLastHunk > 0) { - result.push({ - count: distanceToLastHunk, - type: "skip", - content: - context && context.length >= 5 - ? context - : `${distanceToLastHunk} lines hidden`, - }); - } - lastHunkLine = Math.max(hunk.oldStart + hunk.oldLines, lastHunkLine); - result.push(hunk); - } - - return result; -}; - -const defaultOptions: ParseOptions = { - maxDiffDistance: 30, - maxChangeRatio: 0.45, - mergeModifiedLines: true, - inlineMaxCharEdits: INLINE_MAX_CHAR_EDITS, -}; - -export const parseDiff = ( - diff: string, - options?: Partial -): File[] => { - const opts = { ...defaultOptions, ...options }; - const files = gitDiffParser.parse(diff); - - return files.map((file) => ({ - ...file, - hunks: insertSkipBlocks(file.hunks.map((hunk) => parseHunk(hunk, opts))), - })); -};