|
152 | 152 | (string/slice s2 0 (- len2 i)) |
153 | 153 | (string/slice s1 (- len1 i))])) |
154 | 154 |
|
155 | | - |
156 | | -(defn- lcs-table |
| 155 | +(defn- merge-consecutives |
157 | 156 | ``` |
158 | | - Builds LCS table for two strings |
| 157 | + Merges consecutive segments of the same type |
159 | 158 | ``` |
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)) |
211 | 164 | (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) |
219 | 169 |
|
220 | 170 |
|
221 | | -(defn- merge-small-changes |
| 171 | +(defn- merge-islands |
222 | 172 | ``` |
223 | | - Merges small changes for readability |
| 173 | + Merges isolated equal segments (i.e. islands) for readability |
224 | 174 | ``` |
225 | 175 | [segments] |
226 | 176 | (var i 2) |
|
232 | 182 | (or (= :equal (prv :type)) |
233 | 183 | (= :equal (nxt :type)) |
234 | 184 | (not= :equal (cur :type)) |
235 | | - (string/has-prefix? " " (cur :text)) |
236 | | - (string/has-suffix? " " (cur :text))) |
| 185 | + (string/find " " (cur :text))) |
237 | 186 | (set i (+ i 1)) |
238 | 187 | # case 2: prv and nxt different types |
239 | 188 | (not= (prv :type) (nxt :type)) |
|
249 | 198 | (put nxt-nxt :text (string (cur :text) (nxt-nxt :text))) |
250 | 199 | (array/remove segments (- i 1)) |
251 | 200 | (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))))) |
261 | 292 |
|
262 | 293 |
|
263 | 294 | (defn- compute-diff |
264 | 295 | ``` |
265 | 296 | Computes byte-level diff between two strings with prefix/suffix optimization |
266 | 297 | ``` |
267 | 298 | [s1 s2] |
| 299 | + # separate prefix and suffix as optimisation |
268 | 300 | (def [prefix s1-mid s2-mid] (strip-common-prefix s1 s2)) |
269 | 301 | (def [s1-core s2-core suffix] (strip-common-suffix s1-mid s2-mid)) |
270 | | - |
| 302 | + # compare core difference |
271 | 303 | (def diff-core |
272 | 304 | (if (and (empty? s1-core) (empty? s2-core)) |
273 | 305 | @[] |
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 |
277 | 308 | (def result @[]) |
278 | 309 | (unless (empty? prefix) |
279 | 310 | (array/push result {:type :equal :text prefix})) |
280 | | - (each segment diff-core |
281 | | - (array/push result segment)) |
| 311 | + (array/concat result diff-core) |
282 | 312 | (unless (empty? suffix) |
283 | 313 | (array/push result {:type :equal :text suffix})) |
284 | | - |
285 | | - (merge-small-changes result)) |
| 314 | + # merge small, isolated equal segments |
| 315 | + (merge-islands result)) |
286 | 316 |
|
287 | 317 |
|
288 | 318 | (defn- render-diff-line |
|
350 | 380 | :equal |
351 | 381 | (let [expect-str (string/format "%q" (result :expect)) |
352 | 382 | 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?) |
355 | 384 | (format-with-diff expect-str actual-str) |
356 | 385 | (string "Expect (L): " expect-str "\n" |
357 | 386 | "Actual (R): " actual-str))) |
|
0 commit comments