From 75fbffebbbc3eac7a55af82e2f5adee9a2ebd323 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Tue, 2 Jun 2026 23:25:43 +0200 Subject: [PATCH] Add line-diff, char-diff, and unified diff rendering Add line-level and character-level diffing functions that split strings before comparing. Add unified diff format renderer with proper hunk headers and automatic multi-hunk splitting using 3 lines of context. --- diff.carp | 138 +++++++++++++++++++++++++++++++++++++++++++++++- tests/diff.carp | 100 ++++++++++++++++++++++++++++++++++- 2 files changed, 236 insertions(+), 2 deletions(-) diff --git a/diff.carp b/diff.carp index 1996ff5..f0f8685 100644 --- a/diff.carp +++ b/diff.carp @@ -50,6 +50,13 @@ (defn string-diff [old new] (diff &(String.words old) &(String.words new))) + (doc line-diff "Diffs two strings line by line, splitting on newlines.") + (defn line-diff [old new] + (diff &(String.split-by old &[\newline]) &(String.split-by new &[\newline]))) + + (doc char-diff "Diffs two strings character by character.") + (defn char-diff [old new] (diff &(String.chars old) &(String.chars new))) + (defn eq? [d] (match @d (Eq _) true _ false)) (defn inserted? [d] (match @d (Insertion _) true _ false)) @@ -58,4 +65,133 @@ (defn eq [d] (Array.copy-filter &eq? d)) (defn insertions [d] (Array.copy-filter &inserted? d)) - (defn deletions [d] (Array.copy-filter &deleted? d))) + (defn deletions [d] (Array.copy-filter &deleted? d)) + + (defn- emit-hunk [output old-start old-count new-start new-count hunk-lines] + (let [header (String.concat + &[@"@@ -" + (Int.str old-start) + @"," + (Int.str old-count) + @" +" + (Int.str new-start) + @"," + (Int.str new-count) + @" @@"]) + body (String.join " +" hunk-lines)] + (String.concat &[output header @" +" body @" +"]))) + + (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] + (let-do [ctx 3 + old-pos 1 + new-pos 1 + hunk-lines (the (Array String) []) + hunk-old-start 1 + hunk-new-start 1 + hunk-old-count 0 + hunk-new-count 0 + in-hunk false + lead-lines (the (Array String) []) + lead-old-start 1 + lead-new-start 1 + lead-count 0 + result @""] + (for [i 0 (Array.length d)] + (match-ref (Array.unsafe-nth d i) + (Eq lines) + (let-do [n (Array.length lines)] + (if in-hunk + (if (<= n (* 2 ctx)) + (for [j 0 n] + (let-do [line (Array.unsafe-nth lines j)] + (Array.push-back! &hunk-lines + (String.concat &[@" " @line])) + (set! hunk-old-count (+ hunk-old-count 1)) + (set! hunk-new-count (+ hunk-new-count 1)))) + (let-do [trailing (Int.min ctx n)] + (for [j 0 trailing] + (let-do [line (Array.unsafe-nth lines j)] + (Array.push-back! &hunk-lines + (String.concat &[@" " @line])) + (set! hunk-old-count (+ hunk-old-count 1)) + (set! hunk-new-count (+ hunk-new-count 1)))) + (set! result + (emit-hunk result + hunk-old-start + hunk-old-count + hunk-new-start + hunk-new-count + &hunk-lines)) + (set! in-hunk false) + (set! hunk-lines []) + (let-do [leading (Int.min ctx (- n trailing)) + lead-start (- n leading)] + (set! lead-lines []) + (set! lead-count 0) + (set! lead-old-start (+ old-pos lead-start)) + (set! lead-new-start (+ new-pos lead-start)) + (for [j lead-start n] + (let-do [line (Array.unsafe-nth lines j)] + (Array.push-back! &lead-lines + (String.concat &[@" " @line])) + (set! lead-count (+ lead-count 1))))))) + (let-do [leading (Int.min ctx n) + lead-start (- n leading)] + (set! lead-lines []) + (set! lead-count 0) + (set! lead-old-start (+ old-pos lead-start)) + (set! lead-new-start (+ new-pos lead-start)) + (for [j lead-start n] + (let-do [line (Array.unsafe-nth lines j)] + (Array.push-back! &lead-lines + (String.concat &[@" " @line])) + (set! lead-count (+ lead-count 1)))))) + (set! old-pos (+ old-pos n)) + (set! new-pos (+ new-pos n))) + (Deletion lines) + (let-do [n (Array.length lines)] + (when-do (not in-hunk) + (set! in-hunk true) + (set! hunk-lines (Array.copy &lead-lines)) + (set! hunk-old-start lead-old-start) + (set! hunk-new-start lead-new-start) + (set! hunk-old-count lead-count) + (set! hunk-new-count lead-count) + (set! lead-lines []) + (set! lead-count 0)) + (for [j 0 n] + (let-do [line (Array.unsafe-nth lines j)] + (Array.push-back! &hunk-lines (String.concat &[@"-" @line])) + (set! hunk-old-count (+ hunk-old-count 1)))) + (set! old-pos (+ old-pos n))) + (Insertion lines) + (let-do [n (Array.length lines)] + (when-do (not in-hunk) + (set! in-hunk true) + (set! hunk-lines (Array.copy &lead-lines)) + (set! hunk-old-start lead-old-start) + (set! hunk-new-start lead-new-start) + (set! hunk-old-count lead-count) + (set! hunk-new-count lead-count) + (set! lead-lines []) + (set! lead-count 0)) + (for [j 0 n] + (let-do [line (Array.unsafe-nth lines j)] + (Array.push-back! &hunk-lines (String.concat &[@"+" @line])) + (set! hunk-new-count (+ hunk-new-count 1)))) + (set! new-pos (+ new-pos n))))) + (when in-hunk + (set! result + (emit-hunk result + hunk-old-start + hunk-old-count + hunk-new-start + hunk-new-count + &hunk-lines))) + result))) diff --git a/tests/diff.carp b/tests/diff.carp index b80899a..872137e 100644 --- a/tests/diff.carp +++ b/tests/diff.carp @@ -57,4 +57,102 @@ (assert-equal test &[(Deletion [1 2]) (Eq [3 4])] &(diff &[1 2 3 4] &[3 4]) - "new is a suffix of old"))) + "new is a suffix of old") + + ; line-diff tests + (assert-equal test + &[(Eq [@"a"]) (Insertion [@"new"]) (Deletion [@"old"]) (Eq [@"b"])] + &(line-diff "a +old +b" "a +new +b") + "line-diff splits on newlines and diffs") + (assert-equal test &[(Eq [@"same" @"lines"])] &(Diff.eq &(line-diff "same +lines" "same +lines")) "line-diff of identical strings is all equal") + + ; char-diff tests + (assert-equal test + &[(Eq [\a]) (Insertion [\x]) (Deletion [\b]) (Eq [\c])] + &(char-diff "abc" "axc") + "char-diff diffs individual characters") + (assert-equal test + &[(Eq [\h \e \l \l \o])] + &(Diff.eq &(char-diff "hello" "hello")) + "char-diff of identical strings is all equal") + + ; unified tests + (assert-equal test &(String.concat &[@"@@ -1,3 +1,3 @@ +" @" line1 +" @"+changed +" @"-line2 +" @" line3 +"]) &(unified &(line-diff "line1 +line2 +line3" "line1 +changed +line3")) "unified renders single-hunk diff with correct header") + (assert-equal test "" &(unified &(line-diff "same +text" "same +text")) "unified of identical strings is empty") + (assert-equal test &(String.concat &[@"@@ -1,4 +1,4 @@ +" @"+new +" @"-old +" @" a +" @" b +" @" c +"]) &(unified &(line-diff "old +a +b +c" "new +a +b +c")) "unified handles change at start") + (assert-equal test &(String.concat &[@"@@ -1,4 +1,4 @@ +" @" a +" @" b +" @" c +" @"+new +" @"-old +"]) &(unified &(line-diff "a +b +c +old" "a +b +c +new")) "unified handles change at end") + (assert-equal test &(String.concat &[@"@@ -1,5 +1,5 @@ +" @" a +" @"+BB +" @"-b +" @" c +" @" d +" @" e +" @"@@ -8,4 +8,4 @@ +" @" h +" @" i +" @" j +" @"+KK +" @"-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")) "unified generates multiple hunks for distant changes")))