web/repo: build the patch of the hunks pierre draws, checked against 258 real files
Signed-off-by: dawn <90008@klbr.net>
web/src/fixtures /diffPatchRoundtrip.json.br this is a binary file and will not be displayed.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134import { readFileSync } from "node:fs";import { brotliDecompressSync } from "node:zlib";import { parseDiffFromFile, type FileDiffMetadata, type Hunk } from "@pierre/diffs";import { describe, expect, it } from "vitest";import { hunkPatch, patchDiff } from "#lib/api/diffPatch.ts";
// every drawable file of wisp pull 96, untangled pull 23 and jacquard 1e480de:// [old name, new name, old text, new text]const fixture = JSON.parse( brotliDecompressSync( readFileSync(new URL("../../fixtures/diffPatchRoundtrip.json.br", import.meta.url)) ).toString()) as [string, string, string, string][];
const linesOf = (diff: FileDiffMetadata, hunk: Hunk) => hunk.hunkContent.flatMap((content) => content.type === "context" ? [ diff.additionLines.slice( content.additionLineIndex, content.additionLineIndex + content.lines ) ] : [ diff.deletionLines.slice( content.deletionLineIndex, content.deletionLineIndex + content.deletions ), diff.additionLines.slice( content.additionLineIndex, content.additionLineIndex + content.additions ) ] );
const shapeOf = (diff: FileDiffMetadata) => diff.hunks.map((hunk) => ({ collapsedBefore: hunk.collapsedBefore, additionStart: hunk.additionStart, additionCount: hunk.additionCount, additionLines: hunk.additionLines, deletionStart: hunk.deletionStart, deletionCount: hunk.deletionCount, deletionLines: hunk.deletionLines, splitLineCount: hunk.splitLineCount, unifiedLineCount: hunk.unifiedLineCount, noEOFCRAdditions: hunk.noEOFCRAdditions, noEOFCRDeletions: hunk.noEOFCRDeletions, groups: hunk.hunkContent.map((content) => content.type === "context" ? ["context", content.lines] : ["change", content.deletions, content.additions] ), lines: linesOf(diff, hunk) }));
const trailingOf = (diff: FileDiffMetadata) => { const last = diff.hunks.at(-1); if (!last || !diff.additionLines.length || !diff.deletionLines.length) return 0; return diff.additionLines.length - (last.additionStart - 1 + last.additionCount);};
const roundTrip = (oldName: string, name: string, oldText: string, newText: string) => { const whole = parseDiffFromFile( { name: oldName, contents: oldText }, { name, contents: newText } ); const patch = hunkPatch({ name: oldName, contents: oldText }, { name, contents: newText }); const partial = patchDiff( patch, { name, oldName: oldName === name ? undefined : oldName }, "k" ); return { whole, patch, partial };};
describe("hunkPatch", () => { it(`draws the hunks pierre diffs from the whole sides, for ${fixture.length} real files`, () => { expect(fixture.length).toBe(258); for (const [oldName, name, oldText, newText] of fixture) { const { whole, patch, partial } = roundTrip(oldName, name, oldText, newText); expect(shapeOf(partial), name).toEqual(shapeOf(whole)); expect(partial.type, name).toBe(whole.type); expect(patch.trailing, name).toBe(trailingOf(whole)); expect(partial.isPartial).toBe(true); } }, 30_000);
it.each([ ["the old side lacks a final newline", "a\nb\nc", "a\nb\nc\n"], ["the new side lacks a final newline", "a\nb\nc\n", "a\nb\nd"], ["neither side has a final newline", "a\nb\nc", "a\nx\nc"], ["an unchanged last line has none", "a\nb\nc", "x\nb\nc"], ["a file is added", "", "one\ntwo\n"], ["a file is deleted", "one\ntwo\n", ""], [ "the sides are long with a change in the middle", "l\n".repeat(40) + "x\n" + "l\n".repeat(40), "l\n".repeat(40) + "y\n" + "l\n".repeat(40) ] ])("matches pierre when %s", (_, oldText, newText) => { const { whole, patch, partial } = roundTrip("a.ts", "a.ts", oldText, newText); expect(shapeOf(partial)).toEqual(shapeOf(whole)); expect(partial.type).toBe(whole.type); expect(patch.trailing).toBe(trailingOf(whole)); });
it("counts the unchanged lines after the last hunk", () => { const { patch, partial } = roundTrip( "a.ts", "a.ts", "x\n" + "l\n".repeat(20), "y\n" + "l\n".repeat(20) ); expect(patch.trailing).toBe(16); expect(partial.trailingContextLines).toBe(16); });
it("keeps a rename's names and an empty diff's type", () => { const renamed = roundTrip("old.ts", "new.ts", "a\n", "b\n").partial; expect([renamed.name, renamed.prevName, renamed.type]).toEqual([ "new.ts", "old.ts", "rename-changed" ]); const moved = roundTrip("old.ts", "new.ts", "a\n", "a\n").partial; expect([moved.hunks, moved.prevName, moved.type, moved.isPartial]).toEqual([ [], "old.ts", "rename-pure", true ]); });});123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081import { parseDiffFromFile, processFile, type ChangeTypes, type FileContents, type FileDiffMetadata} from "@pierre/diffs";
// the hunks pierre draws for a file, as a unified patch, plus what a patch can't sayexport interface HunkPatch { text: string; // unchanged lines after the last hunk, which the final separator counts trailing: number; type: ChangeTypes;}
const hunkLine = (prefix: string, line: string): string => line.endsWith("\n") ? prefix + line : `${prefix}${line}\n\\ No newline at end of file\n`;
export const hunkPatch = (oldFile: FileContents, newFile: FileContents): HunkPatch => { const diff = parseDiffFromFile(oldFile, newFile); let text = ""; for (const hunk of diff.hunks) { text += `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@\n`; for (const content of hunk.hunkContent) { if (content.type === "context") { for (let i = 0; i < content.lines; i++) text += hunkLine(" ", diff.additionLines[content.additionLineIndex + i] ?? ""); continue; } for (let i = 0; i < content.deletions; i++) text += hunkLine("-", diff.deletionLines[content.deletionLineIndex + i] ?? ""); for (let i = 0; i < content.additions; i++) text += hunkLine("+", diff.additionLines[content.additionLineIndex + i] ?? ""); } } const last = diff.hunks.at(-1); const empty = diff.additionLines.length === 0 || diff.deletionLines.length === 0; const trailing = !last || empty ? 0 : diff.additionLines.length - (last.additionCount === 0 ? last.additionStart : last.additionStart - 1 + last.additionCount); return { text, trailing, type: diff.type };};
/** the partial metadata pierre draws a patch with, which loads the whole sides to expand */export const patchDiff = ( patch: HunkPatch, names: { name: string; oldName?: string }, cacheKey: string): FileDiffMetadata => { const parsed = patch.text ? processFile(`--- a\n+++ b\n${patch.text}`, { cacheKey, isGitDiff: false, throwOnError: true }) : undefined; const diff: FileDiffMetadata = parsed ?? { name: names.name, type: patch.type, hunks: [], splitLineCount: 0, unifiedLineCount: 0, isPartial: true, deletionLines: [], additionLines: [], deletionCharacterCount: 0, additionCharacterCount: 0, cacheKey }; diff.name = names.name; diff.type = patch.type; if (names.oldName === undefined) delete diff.prevName; else diff.prevName = names.oldName; diff.trailingContextLines = patch.trailing; return diff;};