-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathshift.go
More file actions
360 lines (304 loc) · 8.82 KB
/
shift.go
File metadata and controls
360 lines (304 loc) · 8.82 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
package diffx
import (
"strings"
)
// Boundary shifting preferences (higher = more preferred)
const (
// blankLineBonus is the score bonus for keeping a blank line as a separator
blankLineBonus = 10
// startOfLineBonus is added when a change starts at the beginning of content
startOfLineBonus = 3
// endOfLineBonus is added when a change ends at the end of content
endOfLineBonus = 3
// punctuationBonus is added when boundary is at punctuation
punctuationBonus = 2
)
// shiftBoundaries adjusts diff boundaries for better readability.
// When matching elements appear at boundaries, there may be multiple
// valid placements. This function shifts boundaries to prefer:
// - Keeping blank lines as separators (not part of changes)
// - Aligning with logical block boundaries
// - Grouping related changes together
func shiftBoundaries(ops []DiffOp, a, b []Element) []DiffOp {
if len(ops) == 0 {
return ops
}
// First pass: shift individual operations
result := make([]DiffOp, 0, len(ops))
for i, op := range ops {
if op.Type == Equal {
result = append(result, op)
continue
}
shifted := shiftOp(op, ops, i, a, b)
result = append(result, shifted)
}
// Second pass: merge adjacent operations of the same type
result = mergeAdjacentOps(result)
// Third pass: try to improve boundaries between change regions
result = optimizeBoundaries(result, a, b)
return result
}
// shiftOp attempts to shift a single operation's boundaries for readability.
func shiftOp(op DiffOp, ops []DiffOp, idx int, a, b []Element) DiffOp {
switch op.Type {
case Delete:
return shiftDelete(op, ops, idx, a, b)
case Insert:
return shiftInsert(op, ops, idx, a, b)
default:
return op
}
}
// shiftDelete tries to shift deletion boundaries for better readability.
func shiftDelete(op DiffOp, ops []DiffOp, idx int, a, b []Element) DiffOp {
if op.AEnd-op.AStart == 0 {
return op
}
// Calculate how far we can shift in each direction
maxShiftForward := 0
maxShiftBackward := 0
// Check forward shifting potential
for i := 0; op.AEnd+i < len(a); i++ {
if !a[op.AStart+i].Equal(a[op.AEnd+i]) {
break
}
maxShiftForward = i + 1
}
// Check backward shifting potential
for i := 0; op.AStart-i-1 >= 0; i++ {
if !a[op.AEnd-i-1].Equal(a[op.AStart-i-1]) {
break
}
maxShiftBackward = i + 1
}
if maxShiftForward == 0 && maxShiftBackward == 0 {
return op
}
// Score each possible position
bestShift := 0
bestScore := scoreBoundary(op.AStart, op.AEnd, a)
// Try forward shifts
for shift := 1; shift <= maxShiftForward; shift++ {
score := scoreBoundary(op.AStart+shift, op.AEnd+shift, a)
if score > bestScore {
bestScore = score
bestShift = shift
}
}
// Try backward shifts
for shift := 1; shift <= maxShiftBackward; shift++ {
score := scoreBoundary(op.AStart-shift, op.AEnd-shift, a)
if score > bestScore {
bestScore = score
bestShift = -shift
}
}
if bestShift == 0 {
return op
}
return DiffOp{
Type: Delete,
AStart: op.AStart + bestShift,
AEnd: op.AEnd + bestShift,
BStart: op.BStart,
BEnd: op.BEnd,
}
}
// shiftInsert tries to shift insertion boundaries for better readability.
func shiftInsert(op DiffOp, ops []DiffOp, idx int, a, b []Element) DiffOp {
if op.BEnd-op.BStart == 0 {
return op
}
// Calculate how far we can shift in each direction
maxShiftForward := 0
maxShiftBackward := 0
// Check forward shifting potential
for i := 0; op.BEnd+i < len(b); i++ {
if !b[op.BStart+i].Equal(b[op.BEnd+i]) {
break
}
maxShiftForward = i + 1
}
// Check backward shifting potential
for i := 0; op.BStart-i-1 >= 0; i++ {
if !b[op.BEnd-i-1].Equal(b[op.BStart-i-1]) {
break
}
maxShiftBackward = i + 1
}
if maxShiftForward == 0 && maxShiftBackward == 0 {
return op
}
// Score each possible position
bestShift := 0
bestScore := scoreBoundary(op.BStart, op.BEnd, b)
// Try forward shifts
for shift := 1; shift <= maxShiftForward; shift++ {
score := scoreBoundary(op.BStart+shift, op.BEnd+shift, b)
if score > bestScore {
bestScore = score
bestShift = shift
}
}
// Try backward shifts
for shift := 1; shift <= maxShiftBackward; shift++ {
score := scoreBoundary(op.BStart-shift, op.BEnd-shift, b)
if score > bestScore {
bestScore = score
bestShift = -shift
}
}
if bestShift == 0 {
return op
}
return DiffOp{
Type: Insert,
AStart: op.AStart,
AEnd: op.AEnd,
BStart: op.BStart + bestShift,
BEnd: op.BEnd + bestShift,
}
}
// scoreBoundary scores a boundary position based on readability heuristics.
// Higher scores indicate better boundary positions.
func scoreBoundary(start, end int, elems []Element) int {
score := 0
// Bonus for blank line before the change region
if start > 0 && isBlank(elems[start-1]) {
score += blankLineBonus
}
// Bonus for blank line after the change region
if end < len(elems) && isBlank(elems[end]) {
score += blankLineBonus
}
// Bonus for starting at beginning of sequence
if start == 0 {
score += startOfLineBonus
}
// Bonus for ending at end of sequence
if end == len(elems) {
score += endOfLineBonus
}
// Check for punctuation boundaries
if start > 0 && endsWithPunctuation(elems[start-1]) {
score += punctuationBonus
}
if end < len(elems) && startsWithPunctuation(elems[end]) {
score += punctuationBonus
}
return score
}
// isBlank checks if an element represents blank/whitespace content.
func isBlank(e Element) bool {
s, ok := e.(StringElement)
if !ok {
return false
}
return strings.TrimSpace(string(s)) == ""
}
// endsWithPunctuation checks if an element ends with sentence punctuation.
func endsWithPunctuation(e Element) bool {
s, ok := e.(StringElement)
if !ok {
return false
}
str := strings.TrimSpace(string(s))
if len(str) == 0 {
return false
}
last := str[len(str)-1]
return last == '.' || last == '!' || last == '?' || last == ':' || last == ';'
}
// startsWithPunctuation checks if an element starts with punctuation.
func startsWithPunctuation(e Element) bool {
s, ok := e.(StringElement)
if !ok {
return false
}
str := strings.TrimSpace(string(s))
if len(str) == 0 {
return false
}
first := str[0]
// Common sentence starters after punctuation
return first == '-' || first == '*' || first == '#' || first == '>'
}
// optimizeBoundaries performs a second pass to improve boundaries between
// adjacent Equal and change regions.
func optimizeBoundaries(ops []DiffOp, a, b []Element) []DiffOp {
if len(ops) < 2 {
return ops
}
result := make([]DiffOp, len(ops))
copy(result, ops)
// Look for patterns where we can improve boundaries
for i := 0; i < len(result)-1; i++ {
curr := result[i]
next := result[i+1]
// Pattern: Equal followed by Delete/Insert
// Try to move blank lines from the Equal into the change if it improves things
if curr.Type == Equal && (next.Type == Delete || next.Type == Insert) {
result[i], result[i+1] = tryShiftEqualBoundary(curr, next, a, b)
}
// Pattern: Delete/Insert followed by Equal
// Try to move blank lines from the change into the Equal
if (curr.Type == Delete || curr.Type == Insert) && next.Type == Equal {
result[i], result[i+1] = tryShiftChangeBoundary(curr, next, a, b)
}
}
return result
}
// tryShiftEqualBoundary attempts to shift the boundary between an Equal
// region and a following change region.
func tryShiftEqualBoundary(eq, change DiffOp, a, b []Element) (DiffOp, DiffOp) {
// Check if the last element of Equal is blank
if eq.AEnd-eq.AStart == 0 {
return eq, change
}
lastEqA := eq.AEnd - 1
if lastEqA < 0 || lastEqA >= len(a) {
return eq, change
}
// Don't move blank lines into changes - keep them as separators
// This is actually the opposite of what we want, so just return unchanged
return eq, change
}
// tryShiftChangeBoundary attempts to shift the boundary between a change
// region and a following Equal region.
func tryShiftChangeBoundary(change, eq DiffOp, a, b []Element) (DiffOp, DiffOp) {
// Check if the first element of Equal is blank
if eq.AEnd-eq.AStart == 0 {
return change, eq
}
// If the change ends right before a blank line in the Equal region,
// and we can shift to put the blank in Equal, that's better
// (blank lines should be separators, not part of changes)
// This is already handled by scoreBoundary, so just return unchanged
return change, eq
}
// mergeAdjacentOps merges consecutive operations of the same type.
func mergeAdjacentOps(ops []DiffOp) []DiffOp {
if len(ops) <= 1 {
return ops
}
result := make([]DiffOp, 0, len(ops))
current := ops[0]
for i := 1; i < len(ops); i++ {
op := ops[i]
// Check if we can merge
canMerge := current.Type == op.Type &&
current.AEnd == op.AStart &&
current.BEnd == op.BStart
if canMerge {
// Extend current operation
current.AEnd = op.AEnd
current.BEnd = op.BEnd
} else {
result = append(result, current)
current = op
}
}
result = append(result, current)
return result
}