Add Diff.patch: apply a unified diff to a document - #10
Conversation
`unified` renders a diff to unified-diff text, but nothing could apply one — the serialize/apply loop was write-only. `patch` closes it: it applies a unified diff to an original document, returning `(Maybe String)`. It is a safe applier — every context and deletion line is checked against the original, and it returns `Nothing` when the patch does not apply (a mismatch, or a malformed / out-of-range / out-of-order hunk header). This makes `patch` the inverse of `unified`: `(patch old (unified (line-diff old new)))` reproduces `new`. Parses standard `@@ -os,oc +ns,nc @@` headers (count optional, so git-style patches apply too), copying untouched lines in the gaps between hunks. Adds 10 tests (round-trips, count-omitted header, mismatch and malformed-header rejection).
There was a problem hiding this comment.
Build & Tests
Checked out claude/patch-unified-diff (HEAD ee27011) and ran it locally.
- Build: clean (
carp -x). - Tests:
carp -x tests/diff.carp→ 72/72 green, including the 10 newpatchassertions. - Lint/format:
anglerandcarp-fmt --checkboth clean ondiff.carpandtests/diff.carp(ran exactly the CI find-globs).
Findings
No bugs. I read the whole patch/parse-hunk-start implementation against unified/unified-with-context, then tried hard to break it with 24 adversarial cases beyond the PR's suite (carp -x) — all correct:
- Round-trips (
patch old (unified (line-diff old new))⇒new): change at start/middle/end, pure append at EOF, delete first/last line, single-line whole-content change, blank lines in the middle, empty-line↔space-line changes, a 20-line file with two distant changes (multi-hunk gap copy), insertion before the first line, and trailing-newline documents (split/join stays symmetric at EOF). - Foreign patch formats: a hand-written git-style patch (
-before+) and a multi-line replacement both apply — confirming the applier is order-agnostic (this lib'sunifiedemits+before-; git emits the reverse;patchhandles both because each+/-/context line advances independently). - Safe rejection (returns
Nothing, never a partial/garbage result): re-applying an already-applied patch (context mismatch), out-of-order hunk headers, an out-of-range start line, a deletion whose content doesn't match, and a context mismatch mid-hunk after lines were already emitted. - No OOB crash: deletion and context lines past end-of-file are caught by the
(>= cursor olen)guard rather than faultingunsafe-nth. - Robustness: document lines that themselves start with
@@/+/-/space survive (the+/-/space prefix disambiguates them); a git\ No newline at end of filemarker line is safely ignored; an inflated or zero hunk count in the header is ignored (only the start line is consulted, and context/deletion lines are the source of truth).
The code fits the library — it reuses the same let-do/for/cond idiom and Maybe return style, and builds only on String.split-by/suffix/join and the existing hunk-header format. No README/CHANGELOG exists in this repo and the README doesn't enumerate functions, so there's nothing to update (consistent with how unified landed in #9).
Two out-of-scope notes, neither blocking this PR:
- The applier is exact-match by design (no GNU-patch-style fuzz/offset search): if a header's line number is wrong it fails rather than relocating the hunk. That's the documented "safe applier" contract — worth knowing, not a defect.
- The repo's CI runs the test step with
continue-on-error: true(ci.yml), so a test failure would not turn CI red — the lint/format steps are the only hard gate. Green CI here doesn't by itself prove tests pass; I ran them locally, and they do. Worth tightening sometime, unrelated to this change.
Verdict: merge
A correct, genuinely robust unified-diff applier that closes the serialize→apply loop and is the verified inverse of unified. Builds, 72/72 tests plus 24 adversarial cases pass, lint/format clean.
Summary
unifiedrenders a diff to unified-diff text, but until now nothing could apply one — the serialize→apply loop was write-only.Diff.patchcloses it: it applies a unified diff to an original document and returns the patched result.It is a safe applier: every context and deletion line is checked against
original, and it returnsNothingwhen the patch does not apply — a context/deletion line that does not match, or a hunk header that is malformed, out of range, or out of order. That makespatchthe inverse ofunified:Details
@@ -oldStart,oldCount +newStart,newCount @@headers, copying the untouched lines in the gaps between hunks. The count is optional (@@ -3 +3 @@), so hand-written and git-style patches apply too.Tests
10 new assertions (62 → 72, all green): round-trips for a mid-file change, change-at-start, change-at-end, multiple hunks with a copied gap, pure insertion, pure deletion, and an empty diff; plus a count-omitted header, a context mismatch →
Nothing, and a malformed header →Nothing.carp -x tests/diff.carp,carp-fmt --check,angler, andgendocsare all clean locally.