···1616 KindEqual LineKind = iota
1717 KindAdded // present in "new"
1818 KindRemoved // present in "old"
1919+ KindChanged // paired removal+addition rendered on one row
1920)
20212122type DiffLine struct {
···2627}
27282829func ComputeDiff(oldText, newText string) []DiffLine {
3030+ return zipChanges(computeRawDiff(oldText, newText))
3131+}
3232+3333+// computeRawDiff returns the flat per-line diff without pairing removed/added
3434+// entries. Callers that run further post-processing (e.g. semantic promotion)
3535+// need the unpaired form; the public ComputeDiff wraps this with zipChanges.
3636+func computeRawDiff(oldText, newText string) []DiffLine {
2937 dmp := diffmatchpatch.New()
3038 // Use line-level diff so every segment is line-aligned. Character-level
3139 // DiffMain can split lines in the middle, causing the line-counter to drift
···6270 return lines
6371}
64727373+// zipChanges pairs consecutive KindRemoved+KindAdded lines into KindChanged rows
7474+// so a side-by-side renderer can show the removal and its replacement on the
7575+// same visual row, instead of staggering them across two rows with blank space
7676+// opposite each side.
7777+func zipChanges(lines []DiffLine) []DiffLine {
7878+ out := make([]DiffLine, 0, len(lines))
7979+ i := 0
8080+ for i < len(lines) {
8181+ if lines[i].Kind != KindRemoved {
8282+ out = append(out, lines[i])
8383+ i++
8484+ continue
8585+ }
8686+ remStart := i
8787+ for i < len(lines) && lines[i].Kind == KindRemoved {
8888+ i++
8989+ }
9090+ addStart := i
9191+ for i < len(lines) && lines[i].Kind == KindAdded {
9292+ i++
9393+ }
9494+ removed := lines[remStart:addStart]
9595+ added := lines[addStart:i]
9696+9797+ n := len(removed)
9898+ if len(added) < n {
9999+ n = len(added)
100100+ }
101101+ for k := 0; k < n; k++ {
102102+ out = append(out, DiffLine{KindChanged, removed[k].OldLine, added[k].NewLine, removed[k].Text})
103103+ }
104104+ for k := n; k < len(removed); k++ {
105105+ out = append(out, removed[k])
106106+ }
107107+ for k := n; k < len(added); k++ {
108108+ out = append(out, added[k])
109109+ }
110110+ }
111111+ return out
112112+}
113113+65114// ComputeSemanticDiff attempts a tree-sitter token-level diff, falling back to
66115// text diff if the language is unrecognised or the file fails to parse.
67116// Returns the diff lines and the mode used ("semantic" or "text").
···142191143192 // Start from the text diff (for correct alignment), then promote
144193 // adjacent (removed, added) pairs that are both semantically unchanged
145145- // into equal lines.
146146- textLines := ComputeDiff(string(oldSrc), string(newSrc))
147147- return promoteStyleEqual(textLines, changedOldLines, changedNewLines), "semantic"
194194+ // into equal lines. Use the unpaired form so promoteStyleEqual can match
195195+ // removed/added runs; pair them afterwards for side-by-side display.
196196+ textLines := computeRawDiff(string(oldSrc), string(newSrc))
197197+ promoted := promoteStyleEqual(textLines, changedOldLines, changedNewLines)
198198+ return zipChanges(promoted), "semantic"
148199}
149200150201// promoteStyleEqual collapses blocks of (KindRemoved*, KindAdded*) lines where