Skip to content

Commit 9c87b98

Browse files
pyrmontclaude
andcommitted
Use Hirschberg's algorithm in diffing
This commit implements Hirschberg's divide-and-conquer algorithm as an alternative to the LCS table approach. This algorithm uses O(n) space instead of O(m×n) by computing LCS lengths in both directions and recursively splitting the problem. More tests are added to cover multi-character changes, prefix/suffix optimization, and data structure comparisons. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent c9fb4e2 commit 9c87b98

2 files changed

Lines changed: 194 additions & 89 deletions

File tree

lib/testament.janet

Lines changed: 113 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -152,75 +152,25 @@
152152
(string/slice s2 0 (- len2 i))
153153
(string/slice s1 (- len1 i))]))
154154

155-
156-
(defn- lcs-table
155+
(defn- merge-consecutives
157156
```
158-
Builds LCS table for two strings
157+
Merges consecutive segments of the same type
159158
```
160-
[s1 s2]
161-
(def m (length s1))
162-
(def n (length s2))
163-
(def tbl (array/new-filled (+ m 1) nil))
164-
(each i (range 0 (+ m 1))
165-
(put tbl i (array/new-filled (+ n 1) 0)))
166-
(each i (range 1 (+ m 1))
167-
(each j (range 1 (+ n 1))
168-
(if (= (get s1 (- i 1)) (get s2 (- j 1)))
169-
(put (tbl i) j (+ (get-in tbl [(- i 1) (- j 1)]) 1))
170-
(put (tbl i) j (max (get-in tbl [(- i 1) j])
171-
(get-in tbl [i (- j 1)]))))))
172-
tbl)
173-
174-
175-
(defn- traceback-diff
176-
```
177-
Traces back through LCS table to build diff segments
178-
```
179-
[tbl s1 s2]
180-
(def result @[])
181-
(var i (length s1))
182-
(var j (length s2))
183-
(var stype nil)
184-
(var buf @"")
185-
(defn flush-segment []
186-
(when stype
187-
(array/push result @{:type stype
188-
:text (string/reverse buf)})
189-
(set buf @"")))
190-
(while (or (> i 0) (> j 0))
191-
(cond
192-
# characters match: record as equal
193-
(and (> i 0) (> j 0) (= (get s1 (- i 1)) (get s2 (- j 1))))
194-
(do
195-
(unless (= :equal stype)
196-
(flush-segment)
197-
(set stype :equal))
198-
(buffer/push-byte buf (get s1 (- i 1)))
199-
(-- i)
200-
(-- j))
201-
# characters only in s2: record as insertion
202-
(and (> j 0) (or (zero? i) (>= (get-in tbl [i (- j 1)])
203-
(get-in tbl [(- i 1) j]))))
204-
(do
205-
(unless (= :insert stype)
206-
(flush-segment)
207-
(set stype :insert))
208-
(buffer/push-byte buf (get s2 (- j 1)))
209-
(-- j))
210-
# characters only in s1: record as deletion
159+
[segments]
160+
(var i 1)
161+
(while (def nxt (get segments i))
162+
(def cur (get segments (- i 1)))
163+
(if (= (cur :type) (nxt :type))
211164
(do
212-
(unless (= stype :delete)
213-
(flush-segment)
214-
(set stype :delete))
215-
(buffer/push-byte buf (get s1 (- i 1)))
216-
(-- i))))
217-
(flush-segment)
218-
(reverse result))
165+
(put cur :text (string (cur :text) (nxt :text)))
166+
(array/remove segments i))
167+
(++ i)))
168+
segments)
219169

220170

221-
(defn- merge-small-changes
171+
(defn- merge-islands
222172
```
223-
Merges small changes for readability
173+
Merges isolated equal segments (i.e. islands) for readability
224174
```
225175
[segments]
226176
(var i 2)
@@ -232,8 +182,7 @@
232182
(or (= :equal (prv :type))
233183
(= :equal (nxt :type))
234184
(not= :equal (cur :type))
235-
(string/has-prefix? " " (cur :text))
236-
(string/has-suffix? " " (cur :text)))
185+
(string/find " " (cur :text)))
237186
(set i (+ i 1))
238187
# case 2: prv and nxt different types
239188
(not= (prv :type) (nxt :type))
@@ -249,40 +198,121 @@
249198
(put nxt-nxt :text (string (cur :text) (nxt-nxt :text)))
250199
(array/remove segments (- i 1))
251200
(set i (+ i 2)))))
252-
(set i 1)
253-
(while (def nxt (get segments i))
254-
(def cur (get segments (- i 1)))
255-
(if (= (cur :type) (nxt :type))
256-
(do
257-
(put cur :text (string (cur :text) (nxt :text)))
258-
(array/remove segments i))
259-
(++ i)))
260-
segments)
201+
(merge-consecutives segments))
202+
203+
204+
(defn- lcs-lengths
205+
```
206+
Computes LCS lengths for last row only (linear space)
207+
```
208+
[s1 s2]
209+
(def m (length s1))
210+
(def n (length s2))
211+
(var prv (array/new-filled (+ n 1) 0))
212+
(var cur (array/new-filled (+ n 1) 0))
213+
(loop [i :range [1 (+ m 1)]]
214+
(loop [j :range [1 (+ n 1)]]
215+
(if (= (get s1 (- i 1)) (get s2 (- j 1)))
216+
(put cur j (+ (get prv (- j 1)) 1))
217+
(put cur j (max (get prv j) (get cur (- j 1))))))
218+
# swap rows
219+
(def tmp prv)
220+
(set prv cur)
221+
(set cur tmp)
222+
(array/fill cur 0))
223+
prv)
224+
225+
226+
(defn- hirschberg-diff
227+
```
228+
Computes diff using Hirschberg's algorithm (linear space)
229+
```
230+
[s1 s2]
231+
(def m (length s1))
232+
(def n (length s2))
233+
(cond
234+
# Base case: s1 is empty
235+
(zero? m)
236+
(if (zero? n)
237+
@[]
238+
@[@{:type :insert :text s2}])
239+
# Base case: s2 is empty
240+
(zero? n)
241+
@[@{:type :delete :text s1}]
242+
# Base case: single character in s1
243+
(= m 1)
244+
(do
245+
(def c (get s1 0))
246+
(var found nil)
247+
(var i 0)
248+
(while (and (< i n) (nil? found))
249+
(when (= c (get s2 i))
250+
(set found i))
251+
(++ i))
252+
(if found
253+
# character found, split s2
254+
(let [result @[]]
255+
(unless (zero? found)
256+
(array/push result @{:type :insert :text (string/slice s2 0 found)}))
257+
(array/push result @{:type :equal :text (string/from-bytes c)})
258+
(unless (= found (- n 1))
259+
(array/push result @{:type :insert :text (string/slice s2 (+ found 1))}))
260+
result)
261+
# not found, delete s1 and insert s2
262+
@[@{:type :delete :text s1}
263+
@{:type :insert :text s2}]))
264+
# Recursive case: divide and conquer
265+
(let [mid (math/floor (/ m 2))
266+
s1-left (string/slice s1 0 mid)
267+
s1-right (string/slice s1 mid)
268+
# compute LCS lengths from left
269+
left-lens (lcs-lengths s1-left s2)
270+
# compute LCS lengths from right (reversed)
271+
right-lens (lcs-lengths (string/reverse s1-right)
272+
(string/reverse s2))
273+
# find optimal split point in s2
274+
split-point (do
275+
(var best-j 0)
276+
(var best-len (+ (get left-lens 0)
277+
(get right-lens n)))
278+
(loop [j :range [1 (+ n 1)]]
279+
(def total (+ (get left-lens j)
280+
(get right-lens (- n j))))
281+
(when (> total best-len)
282+
(set best-len total)
283+
(set best-j j)))
284+
best-j)
285+
s2-left (string/slice s2 0 split-point)
286+
s2-right (string/slice s2 split-point)
287+
# Recursively solve left and right halves
288+
left-diff (hirschberg-diff s1-left s2-left)
289+
right-diff (hirschberg-diff s1-right s2-right)]
290+
# Merge results and consolidate consecutive same-type segments
291+
(merge-consecutives (array/concat left-diff right-diff)))))
261292

262293

263294
(defn- compute-diff
264295
```
265296
Computes byte-level diff between two strings with prefix/suffix optimization
266297
```
267298
[s1 s2]
299+
# separate prefix and suffix as optimisation
268300
(def [prefix s1-mid s2-mid] (strip-common-prefix s1 s2))
269301
(def [s1-core s2-core suffix] (strip-common-suffix s1-mid s2-mid))
270-
302+
# compare core difference
271303
(def diff-core
272304
(if (and (empty? s1-core) (empty? s2-core))
273305
@[]
274-
(let [tbl (lcs-table s1-core s2-core)]
275-
(traceback-diff tbl s1-core s2-core))))
276-
306+
(hirschberg-diff s1-core s2-core)))
307+
# glue back together
277308
(def result @[])
278309
(unless (empty? prefix)
279310
(array/push result {:type :equal :text prefix}))
280-
(each segment diff-core
281-
(array/push result segment))
311+
(array/concat result diff-core)
282312
(unless (empty? suffix)
283313
(array/push result {:type :equal :text suffix}))
284-
285-
(merge-small-changes result))
314+
# merge small, isolated equal segments
315+
(merge-islands result))
286316

287317

288318
(defn- render-diff-line
@@ -350,8 +380,7 @@
350380
:equal
351381
(let [expect-str (string/format "%q" (result :expect))
352382
actual-str (string/format "%q" (result :actual))]
353-
(if (and (dyn :test/color?)
354-
(< (+ (length expect-str) (length actual-str)) 2000))
383+
(if (dyn :test/color?)
355384
(format-with-diff expect-str actual-str)
356385
(string "Expect (L): " expect-str "\n"
357386
"Actual (R): " actual-str)))

test/testament.janet

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@
429429

430430
(t/reset-all!)
431431

432-
# Test diff functionality (integration tests)
432+
# Test diff functionality
433433

434434
(defn test-diff-with-color []
435435
(t/deftest test-with-diff
@@ -469,7 +469,7 @@
469469

470470
(t/reset-all!)
471471

472-
(defn test-diff-long-string-fallback []
472+
(defn test-diff-long-string []
473473
(def long-str1 (string/repeat "x" 1001))
474474
(def long-str2 (string/repeat "y" 1001))
475475
(t/deftest test-long-diff
@@ -481,12 +481,88 @@
481481
(string "-----------------------------------\n"
482482
"> \e[31mFailed\e[0m: test-long-diff\n"
483483
"Assertion: (= long-str1 long-str2)\n"
484-
"Expect (L): " (string/format "%q" long-str1) "\n"
485-
"Actual (R): " (string/format "%q" long-str2) "\n"))
484+
"Expect (L): \"\e[48;5;52m" long-str1 "\e[0m\"\n"
485+
"Actual (R): \"\e[48;5;22m" long-str2 "\e[0m\"\n"))
486486
(unless (= expect (string output))
487487
(error "Test failed"))))
488488

489-
(test-diff-long-string-fallback)
489+
(test-diff-long-string)
490+
491+
492+
(t/reset-all!)
493+
494+
(defn test-diff-multi-char []
495+
(t/deftest test-multi-diff
496+
(t/assert-equal "The quick brown fox" "The quirk brawn fox"))
497+
(let [output @""]
498+
(with-dyns [:out output :test/color? true]
499+
(test-multi-diff))
500+
(def expect (string "-----------------------------------\n"
501+
"> \e[31mFailed\e[0m: test-multi-diff\n"
502+
"Assertion: (= \"The quick brown fox\" \"The quirk brawn fox\")\n"
503+
"Expect (L): \"The qui\e[48;5;52mc\e[0mk br\e[48;5;52mo\e[0mwn fox\"\n"
504+
"Actual (R): \"The qui\e[48;5;22mr\e[0mk br\e[48;5;22ma\e[0mwn fox\"\n"))
505+
(unless (= expect (string output))
506+
(error "Test failed"))))
507+
508+
(test-diff-multi-char)
509+
510+
511+
(t/reset-all!)
512+
513+
(defn test-diff-prefix-suffix []
514+
(t/deftest test-prefix-suffix
515+
(t/assert-equal "xxxxxxxxAyyyyyyyy" "xxxxxxxxByyyyyyyy"))
516+
(let [output @""]
517+
(with-dyns [:out output :test/color? true]
518+
(test-prefix-suffix))
519+
(def expect (string "-----------------------------------\n"
520+
"> \e[31mFailed\e[0m: test-prefix-suffix\n"
521+
"Assertion: (= \"xxxxxxxxAyyyyyyyy\" \"xxxxxxxxByyyyyyyy\")\n"
522+
"Expect (L): \"xxxxxxxx\e[48;5;52mA\e[0myyyyyyyy\"\n"
523+
"Actual (R): \"xxxxxxxx\e[48;5;22mB\e[0myyyyyyyy\"\n"))
524+
(unless (= expect (string output))
525+
(error "Test failed"))))
526+
527+
(test-diff-prefix-suffix)
528+
529+
530+
(t/reset-all!)
531+
532+
(defn test-diff-multiple-changes []
533+
(t/deftest test-multiple
534+
(t/assert-equal "Julia is great" "Janet is awesome"))
535+
(let [output @""]
536+
(with-dyns [:out output :test/color? true]
537+
(test-multiple))
538+
(def expect (string "-----------------------------------\n"
539+
"> \e[31mFailed\e[0m: test-multiple\n"
540+
"Assertion: (= \"Julia is great\" \"Janet is awesome\")\n"
541+
"Expect (L): \"J\e[48;5;52mulia\e[0m is \e[48;5;52mgreat\e[0m\"\n"
542+
"Actual (R): \"J\e[48;5;22manet\e[0m is \e[48;5;22mawesome\e[0m\"\n"))
543+
(unless (= expect (string output))
544+
(error "Test failed"))))
545+
546+
(test-diff-multiple-changes)
547+
548+
549+
(t/reset-all!)
550+
551+
(defn test-diff-structures []
552+
(t/deftest test-structures
553+
(t/assert-equal [1 2 3] [1 3 3]))
554+
(let [output @""]
555+
(with-dyns [:out output :test/color? true]
556+
(test-structures))
557+
(def expect (string "-----------------------------------\n"
558+
"> \e[31mFailed\e[0m: test-structures\n"
559+
"Assertion: (= [1 2 3] [1 3 3])\n"
560+
"Expect (L): (1 \e[48;5;52m2\e[0m 3)\n"
561+
"Actual (R): (1 \e[48;5;22m3\e[0m 3)\n"))
562+
(unless (= expect (string output))
563+
(error "Test failed"))))
564+
565+
(test-diff-structures)
490566

491567

492568
(t/reset-all!)

0 commit comments

Comments
 (0)