Skip to content

Commit c9fb4e2

Browse files
pyrmontclaude
andcommitted
Use colour-based diffs in text comparisons
This commit implements a LCS-based diffing algorithm with prefix/suffix optimization and heuristics to merge small equal segments into insertions or deletions for better readability. Diff highlighting uses background colours (dark red/green) and is automatically enabled when the dynamic binding :test/color? is true and the strings being compared add up to fewer than 2000 bytes. Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 7ca0bea commit c9fb4e2

2 files changed

Lines changed: 272 additions & 3 deletions

File tree

lib/testament.janet

Lines changed: 206 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,205 @@
121121
text))
122122

123123

124+
### Diff functions
125+
126+
(defn- strip-common-prefix
127+
```
128+
Returns [common-prefix s1-remainder s2-remainder]
129+
```
130+
[s1 s2]
131+
(def len (min (length s1) (length s2)))
132+
(var i 0)
133+
(while (and (< i len) (= (get s1 i) (get s2 i)))
134+
(++ i))
135+
[(string/slice s1 0 i) (string/slice s1 i) (string/slice s2 i)])
136+
137+
138+
(defn- strip-common-suffix
139+
```
140+
Returns [s1-remainder s2-remainder common-suffix]
141+
```
142+
[s1 s2]
143+
(def len1 (length s1))
144+
(def len2 (length s2))
145+
(def len (min len1 len2))
146+
(var i 0)
147+
(while (and (< i len) (= (get s1 (- len1 1 i)) (get s2 (- len2 1 i))))
148+
(++ i))
149+
(if (zero? i)
150+
[s1 s2 ""]
151+
[(string/slice s1 0 (- len1 i))
152+
(string/slice s2 0 (- len2 i))
153+
(string/slice s1 (- len1 i))]))
154+
155+
156+
(defn- lcs-table
157+
```
158+
Builds LCS table for two strings
159+
```
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
211+
(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))
219+
220+
221+
(defn- merge-small-changes
222+
```
223+
Merges small changes for readability
224+
```
225+
[segments]
226+
(var i 2)
227+
(while (def nxt (get segments i))
228+
(def prv (get segments (- i 2)))
229+
(def cur (get segments (- i 1)))
230+
(cond
231+
# case 1: skip
232+
(or (= :equal (prv :type))
233+
(= :equal (nxt :type))
234+
(not= :equal (cur :type))
235+
(string/has-prefix? " " (cur :text))
236+
(string/has-suffix? " " (cur :text)))
237+
(set i (+ i 1))
238+
# case 2: prv and nxt different types
239+
(not= (prv :type) (nxt :type))
240+
(do
241+
(put prv :text (string (prv :text) (cur :text)))
242+
(put nxt :text (string (cur :text) (nxt :text)))
243+
(array/remove segments (- i 1))
244+
(set i (+ i 1)))
245+
# case 3: prv and nxt same types
246+
(def nxt-nxt (get segments (+ i 1)))
247+
(do
248+
(put nxt :text (string (cur :text) (nxt :text)))
249+
(put nxt-nxt :text (string (cur :text) (nxt-nxt :text)))
250+
(array/remove segments (- i 1))
251+
(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)
261+
262+
263+
(defn- compute-diff
264+
```
265+
Computes byte-level diff between two strings with prefix/suffix optimization
266+
```
267+
[s1 s2]
268+
(def [prefix s1-mid s2-mid] (strip-common-prefix s1 s2))
269+
(def [s1-core s2-core suffix] (strip-common-suffix s1-mid s2-mid))
270+
271+
(def diff-core
272+
(if (and (empty? s1-core) (empty? s2-core))
273+
@[]
274+
(let [tbl (lcs-table s1-core s2-core)]
275+
(traceback-diff tbl s1-core s2-core))))
276+
277+
(def result @[])
278+
(unless (empty? prefix)
279+
(array/push result {:type :equal :text prefix}))
280+
(each segment diff-core
281+
(array/push result segment))
282+
(unless (empty? suffix)
283+
(array/push result {:type :equal :text suffix}))
284+
285+
(merge-small-changes result))
286+
287+
288+
(defn- render-diff-line
289+
```
290+
Renders a single line from diff data, showing either deletes or inserts
291+
```
292+
[diff-data show-type]
293+
(def parts @[])
294+
(each segment diff-data
295+
(case (segment :type)
296+
:equal
297+
(array/push parts (segment :text))
298+
299+
:delete
300+
(when (= show-type :delete)
301+
(array/push parts
302+
(string "\e[48;5;52m" (segment :text) "\e[0m")))
303+
304+
:insert
305+
(when (= show-type :insert)
306+
(array/push parts
307+
(string "\e[48;5;22m" (segment :text) "\e[0m")))))
308+
(string/join parts))
309+
310+
311+
(defn- format-with-diff
312+
```
313+
Formats expect/actual with diff highlighting
314+
```
315+
[expect-str actual-str]
316+
(def diff (compute-diff expect-str actual-str))
317+
(def expect-line (render-diff-line diff :delete))
318+
(def actual-line (render-diff-line diff :insert))
319+
(string "Expect (L): " expect-line "\n"
320+
"Actual (R): " actual-line))
321+
322+
124323
(defn- ruler
125324
```
126325
Prints a dashed line as long as the longest line
@@ -149,8 +348,13 @@
149348
[result]
150349
(case (result :kind)
151350
:equal
152-
(string "Expect (L): " (string/format "%q" (result :expect)) "\n"
153-
"Actual (R): " (string/format "%q" (result :actual)))
351+
(let [expect-str (string/format "%q" (result :expect))
352+
actual-str (string/format "%q" (result :actual))]
353+
(if (and (dyn :test/color?)
354+
(< (+ (length expect-str) (length actual-str)) 2000))
355+
(format-with-diff expect-str actual-str)
356+
(string "Expect (L): " expect-str "\n"
357+
"Actual (R): " actual-str)))
154358

155359
:matches
156360
(string "Expect (L): Structure " (string/format "%q" (result :expect)) "\n"

test/testament.janet

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@
369369
(defn test-call-test []
370370
(t/deftest testname (t/assert-equal 1 2))
371371
(let [output @""]
372-
(with-dyns [:out output]
372+
(with-dyns [:out output :testament/diff? false]
373373
(testname))
374374
(def expect
375375
```
@@ -425,3 +425,68 @@
425425
(error "Test failed"))))
426426

427427
(test-skip-tests)
428+
429+
430+
(t/reset-all!)
431+
432+
# Test diff functionality (integration tests)
433+
434+
(defn test-diff-with-color []
435+
(t/deftest test-with-diff
436+
(t/assert-equal "hello" "hallo"))
437+
(let [output @""]
438+
(with-dyns [:out output :test/color? true]
439+
(test-with-diff))
440+
(def expect (string "-----------------------------------\n"
441+
"> \e[31mFailed\e[0m: test-with-diff\n"
442+
"Assertion: (= \"hello\" \"hallo\")\n"
443+
"Expect (L): \"h\e[48;5;52me\e[0mllo\"\n"
444+
"Actual (R): \"h\e[48;5;22ma\e[0mllo\"\n"))
445+
(unless (= expect (string output))
446+
(error "Test failed"))))
447+
448+
(test-diff-with-color)
449+
450+
451+
(t/reset-all!)
452+
453+
(defn test-diff-without-color []
454+
(t/deftest test-no-color
455+
(t/assert-equal "hello" "hallo"))
456+
(let [output @""]
457+
(with-dyns [:out output :test/color? false]
458+
(test-no-color))
459+
(def expect (string "-----------------------------------\n"
460+
"> Failed: test-no-color\n"
461+
"Assertion: (= \"hello\" \"hallo\")\n"
462+
"Expect (L): \"hello\"\n"
463+
"Actual (R): \"hallo\"\n"))
464+
(unless (= expect (string output))
465+
(error "Test failed"))))
466+
467+
(test-diff-without-color)
468+
469+
470+
(t/reset-all!)
471+
472+
(defn test-diff-long-string-fallback []
473+
(def long-str1 (string/repeat "x" 1001))
474+
(def long-str2 (string/repeat "y" 1001))
475+
(t/deftest test-long-diff
476+
(t/assert-equal long-str1 long-str2))
477+
(let [output @""]
478+
(with-dyns [:out output :test/color? true]
479+
(test-long-diff))
480+
(def expect
481+
(string "-----------------------------------\n"
482+
"> \e[31mFailed\e[0m: test-long-diff\n"
483+
"Assertion: (= long-str1 long-str2)\n"
484+
"Expect (L): " (string/format "%q" long-str1) "\n"
485+
"Actual (R): " (string/format "%q" long-str2) "\n"))
486+
(unless (= expect (string output))
487+
(error "Test failed"))))
488+
489+
(test-diff-long-string-fallback)
490+
491+
492+
(t/reset-all!)

0 commit comments

Comments
 (0)