Skip to content

Add Diff.patch: apply a unified diff to a document - #10

Merged
hellerve merged 1 commit into
masterfrom
claude/patch-unified-diff
Jul 10, 2026
Merged

Add Diff.patch: apply a unified diff to a document#10
hellerve merged 1 commit into
masterfrom
claude/patch-unified-diff

Conversation

@carpentry-agent

Copy link
Copy Markdown

Summary

unified renders a diff to unified-diff text, but until now nothing could apply one — the serialize→apply loop was write-only. Diff.patch closes it: it applies a unified diff to an original document and returns the patched result.

(Diff.patch original patch-text)  ; => (Maybe String)

It is a safe applier: every context and deletion line is checked against original, and it returns Nothing when 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 makes patch the inverse of unified:

(patch old (unified (line-diff old new)))  ; => (Just new)

Details

  • Parses standard @@ -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.
  • Documents are split and rejoined on newlines, so round-trips are exact.
  • Insertions are emitted in new-side order and deletions only advance the old-file cursor, so the library's insertion-before-deletion hunk ordering applies correctly.

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, and gendocs are all clean locally.

`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).

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build & Tests

Checked out claude/patch-unified-diff (HEAD ee27011) and ran it locally.

  • Build: clean (carp -x).
  • Tests: carp -x tests/diff.carp72/72 green, including the 10 new patch assertions.
  • Lint/format: angler and carp-fmt --check both clean on diff.carp and tests/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's unified emits + before -; git emits the reverse; patch handles 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 faulting unsafe-nth.
  • Robustness: document lines that themselves start with @@/+/-/space survive (the +/-/space prefix disambiguates them); a git \ No newline at end of file marker 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.

@hellerve
hellerve merged commit 63b5e65 into master Jul 10, 2026
2 checks passed
@hellerve
hellerve deleted the claude/patch-unified-diff branch July 10, 2026 12:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant