import { describe, it, expect } from "vitest"; import { parseDiff, detectLanguage } from "./diffParser"; describe("parseDiff", () => { describe("empty input", () => { it("returns an empty array for an empty string", () => { expect(parseDiff("")).toEqual([]); }); }); describe("file header lines", () => { it("classifies diff --git lines as file-header", () => { const result = parseDiff("diff --git a/foo.ts b/foo.ts"); expect(result).toHaveLength(1); expect(result[0].type).toBe("file-header"); expect(result[0].oldLineNumber).toBeNull(); expect(result[0].newLineNumber).toBeNull(); }); it("classifies index lines as file-header", () => { const result = parseDiff("index abc123..def456 100644"); expect(result[0].type).toBe("file-header"); }); it("classifies --- lines as file-header", () => { const result = parseDiff("--- a/foo.ts"); expect(result[0].type).toBe("file-header"); }); it("classifies +++ lines as file-header", () => { const result = parseDiff("+++ b/foo.ts"); expect(result[0].type).toBe("file-header"); }); it("classifies new file mode lines as file-header", () => { const result = parseDiff("new file mode 100644"); expect(result[0].type).toBe("file-header"); }); it("classifies deleted file mode lines as file-header", () => { const result = parseDiff("deleted file mode 100644"); expect(result[0].type).toBe("file-header"); }); }); describe("hunk header lines", () => { it("classifies @@ lines as hunk-header", () => { const result = parseDiff("@@ -1,5 +1,7 @@"); expect(result).toHaveLength(1); expect(result[0].type).toBe("hunk-header"); expect(result[0].content).toBe("@@ -1,5 +1,7 @@"); expect(result[0].oldLineNumber).toBeNull(); expect(result[0].newLineNumber).toBeNull(); }); it("sets line counters from the hunk header", () => { const diff = "@@ -10,3 +20,3 @@\n-old line\n+new line\n unchanged"; const result = parseDiff(diff); const removed = result.find((l) => l.type === "removed"); const added = result.find((l) => l.type === "added"); const context = result.find((l) => l.type === "context"); expect(removed?.oldLineNumber).toBe(10); expect(added?.newLineNumber).toBe(20); expect(context?.oldLineNumber).toBe(11); expect(context?.newLineNumber).toBe(21); }); it("handles hunk headers with no count (single line, e.g. -1 +1)", () => { const diff = "@@ -1 +1 @@\n-old\n+new"; const result = parseDiff(diff); const removed = result.find((l) => l.type === "removed"); const added = result.find((l) => l.type === "added"); expect(removed?.oldLineNumber).toBe(1); expect(added?.newLineNumber).toBe(1); }); }); describe("added lines", () => { it("classifies + lines as added", () => { const result = parseDiff("@@ -1,1 +1,1 @@\n+new line"); const added = result.find((l) => l.type === "added"); expect(added).toBeDefined(); expect(added?.content).toBe("new line"); }); it("strips the leading + from content", () => { const result = parseDiff("@@ -1,1 +1,1 @@\n+ indented code"); const added = result.find((l) => l.type === "added"); expect(added?.content).toBe(" indented code"); }); it("has null oldLineNumber for added lines", () => { const result = parseDiff("@@ -1,1 +1,1 @@\n+line"); const added = result.find((l) => l.type === "added"); expect(added?.oldLineNumber).toBeNull(); }); it("increments newLineNumber across multiple added lines", () => { const diff = "@@ -1,1 +1,3 @@\n+first\n+second\n+third"; const result = parseDiff(diff); const added = result.filter((l) => l.type === "added"); expect(added[0].newLineNumber).toBe(1); expect(added[1].newLineNumber).toBe(2); expect(added[2].newLineNumber).toBe(3); }); }); describe("removed lines", () => { it("classifies - lines as removed", () => { const result = parseDiff("@@ -1,1 +1,1 @@\n-old line"); const removed = result.find((l) => l.type === "removed"); expect(removed).toBeDefined(); expect(removed?.content).toBe("old line"); }); it("strips the leading - from content", () => { const result = parseDiff("@@ -1,1 +1,1 @@\n- indented code"); const removed = result.find((l) => l.type === "removed"); expect(removed?.content).toBe(" indented code"); }); it("has null newLineNumber for removed lines", () => { const result = parseDiff("@@ -1,1 +1,1 @@\n-line"); const removed = result.find((l) => l.type === "removed"); expect(removed?.newLineNumber).toBeNull(); }); it("increments oldLineNumber across multiple removed lines", () => { const diff = "@@ -5,3 +5,1 @@\n-first\n-second\n-third"; const result = parseDiff(diff); const removed = result.filter((l) => l.type === "removed"); expect(removed[0].oldLineNumber).toBe(5); expect(removed[1].oldLineNumber).toBe(6); expect(removed[2].oldLineNumber).toBe(7); }); }); describe("context lines", () => { it("classifies space-prefixed lines as context", () => { const result = parseDiff("@@ -1,1 +1,1 @@\n unchanged line"); const context = result.find((l) => l.type === "context"); expect(context).toBeDefined(); expect(context?.content).toBe("unchanged line"); }); it("has both line numbers for context lines", () => { const diff = "@@ -3,1 +5,1 @@\n context"; const result = parseDiff(diff); const context = result.find((l) => l.type === "context"); expect(context?.oldLineNumber).toBe(3); expect(context?.newLineNumber).toBe(5); }); it("increments both line numbers across context lines", () => { const diff = "@@ -1,3 +1,3 @@\n line1\n line2\n line3"; const result = parseDiff(diff); const contexts = result.filter((l) => l.type === "context"); expect(contexts[0].oldLineNumber).toBe(1); expect(contexts[0].newLineNumber).toBe(1); expect(contexts[2].oldLineNumber).toBe(3); expect(contexts[2].newLineNumber).toBe(3); }); }); describe("no-newline marker", () => { it("classifies the no-newline marker correctly", () => { const result = parseDiff("\\ No newline at end of file"); expect(result[0].type).toBe("no-newline"); expect(result[0].content).toBe("\\ No newline at end of file"); }); }); describe("line number tracking across mixed lines", () => { it("tracks old and new line numbers correctly through a realistic diff", () => { const diff = [ "@@ -10,6 +10,7 @@", " context one", "-removed line", "+added line one", "+added line two", " context two", " context three", ].join("\n"); const result = parseDiff(diff); const lines = result.filter((l) => l.type !== "hunk-header"); expect(lines[0]).toMatchObject({ type: "context", oldLineNumber: 10, newLineNumber: 10 }); expect(lines[1]).toMatchObject({ type: "removed", oldLineNumber: 11, newLineNumber: null }); expect(lines[2]).toMatchObject({ type: "added", oldLineNumber: null, newLineNumber: 11 }); expect(lines[3]).toMatchObject({ type: "added", oldLineNumber: null, newLineNumber: 12 }); expect(lines[4]).toMatchObject({ type: "context", oldLineNumber: 12, newLineNumber: 13 }); expect(lines[5]).toMatchObject({ type: "context", oldLineNumber: 13, newLineNumber: 14 }); }); it("resets line counters on a second hunk header", () => { const diff = [ "@@ -1,1 +1,1 @@", " context", "@@ -50,1 +50,1 @@", " second hunk context", ].join("\n"); const result = parseDiff(diff); const secondContext = result[result.length - 1]; expect(secondContext.type).toBe("context"); expect(secondContext.oldLineNumber).toBe(50); expect(secondContext.newLineNumber).toBe(50); }); }); }); describe("detectLanguage", () => { it("detects TypeScript from .ts extension", () => { expect(detectLanguage("src/foo.ts")).toBe("typescript"); }); it("detects TypeScript from .tsx extension", () => { expect(detectLanguage("src/foo.tsx")).toBe("typescript"); }); it("detects JavaScript from .js extension", () => { expect(detectLanguage("src/foo.js")).toBe("javascript"); }); it("detects Rust from .rs extension", () => { expect(detectLanguage("src-tauri/src/lib.rs")).toBe("rust"); }); it("detects Python from .py extension", () => { expect(detectLanguage("script.py")).toBe("python"); }); it("detects CSS from .css extension", () => { expect(detectLanguage("style.css")).toBe("css"); }); it("detects JSON from .json extension", () => { expect(detectLanguage("package.json")).toBe("json"); }); it("detects YAML from .yaml extension", () => { expect(detectLanguage("config.yaml")).toBe("yaml"); }); it("detects YAML from .yml extension", () => { expect(detectLanguage(".github/workflows/ci.yml")).toBe("yaml"); }); it("detects bash from .sh extension", () => { expect(detectLanguage("check-all.sh")).toBe("bash"); }); it("uses plaintext for unknown extensions", () => { expect(detectLanguage("file.xyz")).toBe("plaintext"); }); it("uses plaintext when there is no extension", () => { expect(detectLanguage("Makefile")).toBe("plaintext"); }); it("handles paths with multiple dots correctly (uses last segment)", () => { expect(detectLanguage("my.config.ts")).toBe("typescript"); }); it("is case-insensitive for extension detection", () => { expect(detectLanguage("README.MD")).toBe("markdown"); }); });