From ee27011275765797655d59d9dd2bc5045ff7f1a1 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Fri, 10 Jul 2026 07:42:37 +0200 Subject: [PATCH] Add Diff.patch: apply a unified diff to a document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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). --- diff.carp | 70 ++++++++++++++++++++++++++- tests/diff.carp | 122 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 190 insertions(+), 2 deletions(-) diff --git a/diff.carp b/diff.carp index 3d57ed8..6a39049 100644 --- a/diff.carp +++ b/diff.carp @@ -263,4 +263,72 @@ Uses the given number of context lines around changes.") (doc unified "Renders a line diff in unified diff format with hunk headers. Expects the output of `line-diff` or a diff of string arrays. Uses 3 lines of context around changes.") - (defn unified [d] (unified-with-context d 3))) + (defn unified [d] (unified-with-context d 3)) + + ; parses the 1-based old-file start line from a unified hunk header such as + ; `@@ -3,4 +3,5 @@`; the count is optional (`@@ -3 +3 @@` means one line). + (defn- parse-hunk-start [header] + (let-do [tokens (String.split-by header &[\space]) + res (the (Maybe Int) (Maybe.Nothing))] + (for [i 0 (Array.length &tokens)] + (let [tok (Array.unsafe-nth &tokens i)] + (when (and (Maybe.nothing? &res) + (String.starts-with? tok "-") + (> (String.length tok) 1)) + (let [range (String.suffix tok 1)] + (set! res + (Int.from-string + (Array.unsafe-nth &(String.split-by &range &[\,]) 0))))))) + res)) + + (doc patch + "Applies a unified diff `patch-text` (as produced by `unified`) to the +original document `original`, returning the patched document. Context and +deletion lines are checked against `original`, so this is a safe applier: +it returns `Nothing` if the patch does not apply — a context or deletion +line that does not match, or a hunk header that is malformed, out of range, +or out of order. Both documents are split and rejoined on newlines, so +`(patch old (unified (line-diff old new)))` reproduces `new`.") + (defn patch [original patch-text] + (let-do [orig (String.split-by original &[\newline]) + lines (String.split-by patch-text &[\newline]) + out (the (Array String) []) + olen (Array.length &orig) + cursor 0 + failed false] + (for [i 0 (Array.length &lines)] + (unless failed + (let [line (Array.unsafe-nth &lines i)] + (cond + (String.starts-with? line "@@") + (match (parse-hunk-start line) + (Maybe.Nothing) (set! failed true) + (Maybe.Just start) + (let [target (if (< start 1) 0 (Int.dec start))] + (if (or (< target cursor) (> target olen)) + (set! failed true) + (do + (for [k cursor target] + (Array.push-back! &out @(Array.unsafe-nth &orig k))) + (set! cursor target))))) + (String.starts-with? line " ") + (let [content (String.suffix line 1)] + (if (or (>= cursor olen) + (/= (Array.unsafe-nth &orig cursor) &content)) + (set! failed true) + (do + (Array.push-back! &out content) + (set! cursor (Int.inc cursor))))) + (String.starts-with? line "-") + (let [content (String.suffix line 1)] + (if (or (>= cursor olen) + (/= (Array.unsafe-nth &orig cursor) &content)) + (set! failed true) + (set! cursor (Int.inc cursor)))) + (String.starts-with? line "+") + (Array.push-back! &out (String.suffix line 1)) + ())))) + (unless failed + (for [k cursor olen] (Array.push-back! &out @(Array.unsafe-nth &orig k)))) + (if failed (Maybe.Nothing) (Maybe.Just (String.join " +" &out)))))) diff --git a/tests/diff.carp b/tests/diff.carp index 2272137..8f42bc2 100644 --- a/tests/diff.carp +++ b/tests/diff.carp @@ -378,4 +378,124 @@ c d e FF -g") 1) "unified-with-context 1 splits hunks with enough gap"))) +g") 1) "unified-with-context 1 splits hunks with enough gap") + + ; patch — apply a unified diff to an original document + (assert-equal test &(Maybe.Just @"line1 +changed +line3") &(patch "line1 +line2 +line3" &(unified &(line-diff "line1 +line2 +line3" "line1 +changed +line3"))) "patch round-trips a single-hunk change in the middle") + (assert-equal test &(Maybe.Just @"new +a +b +c") &(patch "old +a +b +c" &(unified &(line-diff "old +a +b +c" "new +a +b +c"))) "patch round-trips a change at the start") + (assert-equal test &(Maybe.Just @"a +b +c +new") &(patch "a +b +c +old" &(unified &(line-diff "a +b +c +old" "a +b +c +new"))) "patch round-trips a change at the end") + (assert-equal test &(Maybe.Just @"a +BB +c +d +e +f +g +h +i +j +KK") &(patch "a +b +c +d +e +f +g +h +i +j +k" &(unified &(line-diff "a +b +c +d +e +f +g +h +i +j +k" "a +BB +c +d +e +f +g +h +i +j +KK"))) "patch round-trips multiple hunks, copying the untouched gap") + (assert-equal test &(Maybe.Just @"a +X +Y +b") &(patch "a +b" &(unified &(line-diff "a +b" "a +X +Y +b"))) "patch round-trips a pure insertion") + (assert-equal test &(Maybe.Just @"a +b") &(patch "a +X +Y +b" &(unified &(line-diff "a +X +Y +b" "a +b"))) "patch round-trips a pure deletion") + (assert-equal test &(Maybe.Just @"same +text") &(patch "same +text" &(unified &(line-diff "same +text" "same +text"))) "patch on an empty diff returns the original unchanged") + (assert-equal test &(Maybe.Just @"a +B +c") &(patch "a +b +c" "@@ -2 +2 @@ +-b ++B +") "patch accepts a count-omitted hunk header (@@ -2 +2 @@)") + (assert-equal test &(the (Maybe String) (Maybe.Nothing)) &(patch "x +y +z" &(unified &(line-diff "a +b +c" "a +B +c"))) "patch returns Nothing when a context line does not match the original") + (assert-equal test &(the (Maybe String) (Maybe.Nothing)) &(patch "a +b" "@@ garbage @@ + a +") "patch returns Nothing on a malformed hunk header")))