-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdiff.go
More file actions
560 lines (505 loc) · 18.1 KB
/
Copy pathdiff.go
File metadata and controls
560 lines (505 loc) · 18.1 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
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
package reviewer
import (
"fmt"
"html"
"regexp"
"strconv"
"strings"
"unicode"
)
// Kind is what a review target turns out to be once its content is read.
//
// The kind is decided from the content, never from the file name or a flag: the agent writes
// its diff to a temp file whose name it chooses, and `reviewer serve <file>` is the only entry
// point either way.
type Kind string
const (
KindMarkdown Kind = "markdown"
KindDiff Kind = "diff"
)
// LineKind is a diff line's role. Rendering and re-anchoring both branch on it, but matching
// across rounds deliberately does not (see reanchor.go).
type LineKind string
const (
LineContext LineKind = "context"
LineAdd LineKind = "add"
LineDelete LineKind = "delete"
// LineMeta is diff bookkeeping shown verbatim, e.g. "\ No newline at end of file".
LineMeta LineKind = "meta"
)
// Line is one rendered row of a diff. Content has the leading +/-/space marker stripped, which
// is also the form comments match against, so a line that turns from added to context between
// rounds still matches itself.
//
// OldNo and NewNo are 0 when the line does not exist on that side.
type Line struct {
Kind LineKind
Content string
OldNo int
NewNo int
}
// Hunk is one @@ section. Header is the raw @@ line, kept whole because its trailing section
// heading (a function signature, usually) is the most useful context a diff carries.
type Hunk struct {
Header string
Lines []Line
}
// File is one file's worth of diff. Paths have their a/ and b/ prefixes stripped; a side the
// file does not exist on is "/dev/null", exactly as the diff spells it.
type File struct {
OldPath string
NewPath string
Hunks []Hunk
}
// DisplayPath is the single path a file is known by on the page and inside comment anchors.
//
// It is NewPath except for a deleted file, where NewPath is /dev/null and OldPath is the only
// name the file has. Using /dev/null as-is would make every deleted file share one anchor
// namespace, so two deletions in one diff would collide.
func (f File) DisplayPath() string {
if f.NewPath == "" || f.NewPath == devNull {
return f.OldPath
}
return f.NewPath
}
// FileStatus is what happened to a file in this diff.
type FileStatus string
const (
FileAdded FileStatus = "added"
FileDeleted FileStatus = "deleted"
FileRenamed FileStatus = "renamed"
FileModified FileStatus = "modified"
)
// Status reports the file's fate, which the page shows as a mark beside its name.
func (f File) Status() FileStatus {
switch {
case f.OldPath == devNull:
return FileAdded
case f.NewPath == devNull:
return FileDeleted
case f.OldPath != "" && f.NewPath != "" && f.OldPath != f.NewPath:
return FileRenamed
default:
return FileModified
}
}
// Lines returns the file's lines in rendering order, which is also the order the 1-based
// indices in a comment anchor count.
func (f File) Lines() []Line {
var out []Line
for _, h := range f.Hunks {
out = append(out, h.Lines...)
}
return out
}
const devNull = "/dev/null"
// Pre-compiled at package scope per AGENTS.md section 2.
var (
hunkHeaderRegex = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$`)
combinedHunkRegex = regexp.MustCompile(`^@{3,} `)
fencedCodeRegex = regexp.MustCompile("^\\s{0,3}(```|~~~)")
diffGitHeaderRegex = regexp.MustCompile(`^diff --git (.+)$`)
diffGitPathsRegex = regexp.MustCompile(`^a/(.*) b/(.*)$`)
diffGitQuotedRegexp = regexp.MustCompile(`^"a/(.*)" "b/(.*)"$`)
)
// DetectKind decides whether content is a unified diff or a Markdown document.
//
// Fenced code blocks are skipped, because a Markdown spec that quotes a diff inside a fence is
// still a Markdown spec — misreading one as a diff would strip its formatting and lose every
// existing comment anchor.
func DetectKind(content []byte) Kind {
lines := strings.Split(string(content), "\n")
inFence := false
for i, line := range lines {
if fencedCodeRegex.MatchString(line) {
inFence = !inFence
continue
}
if inFence {
continue
}
switch {
case strings.HasPrefix(line, "diff --git "):
return KindDiff
case hunkHeaderRegex.MatchString(line), combinedHunkRegex.MatchString(line):
return KindDiff
case strings.HasPrefix(line, "--- ") && i+1 < len(lines) && strings.HasPrefix(lines[i+1], "+++ "):
return KindDiff
}
}
return KindMarkdown
}
// ParseUnifiedDiff turns a unified diff into per-file hunks and lines.
//
// Unrecognised header lines (index, mode, similarity, "Binary files differ") are skipped: they
// carry nothing the review page shows, and failing on them would reject perfectly ordinary
// `git diff` output.
func ParseUnifiedDiff(content []byte) ([]File, error) {
var (
files []File
cur *File
hunk *Hunk
oldNo int
newNo int
)
// startFile appends and re-points cur, so later paths land on the file being parsed.
startFile := func(f File) {
files = append(files, f)
cur = &files[len(files)-1]
hunk = nil
}
for line := range strings.SplitSeq(string(content), "\n") {
switch {
case combinedHunkRegex.MatchString(line):
// A combined diff (git diff --cc, merge commits) has one column per parent, so a
// line has no single before/after. Rejecting it is honest; guessing is not.
return nil, fmt.Errorf("combined diffs are not supported: %s", line)
case strings.HasPrefix(line, "diff --git "):
oldPath, newPath := parseDiffGitPaths(line)
startFile(File{OldPath: oldPath, NewPath: newPath})
case strings.HasPrefix(line, "--- "):
path := trimDiffPath(strings.TrimPrefix(line, "--- "))
// A plain `diff -u` has no "diff --git" line, so the ---/+++ pair opens the file.
if cur == nil || len(cur.Hunks) > 0 {
startFile(File{})
}
cur.OldPath = path
case strings.HasPrefix(line, "+++ "):
if cur == nil {
startFile(File{})
}
cur.NewPath = trimDiffPath(strings.TrimPrefix(line, "+++ "))
case hunkHeaderRegex.MatchString(line):
if cur == nil {
startFile(File{})
}
m := hunkHeaderRegex.FindStringSubmatch(line)
oldNo, _ = strconv.Atoi(m[1])
newNo, _ = strconv.Atoi(m[3])
cur.Hunks = append(cur.Hunks, Hunk{Header: line})
hunk = &cur.Hunks[len(cur.Hunks)-1]
case hunk == nil:
// File header noise (index, old/new mode, similarity, binary notices) — skipped.
case strings.HasPrefix(line, "+"):
hunk.Lines = append(hunk.Lines, Line{Kind: LineAdd, Content: line[1:], NewNo: newNo})
newNo++
case strings.HasPrefix(line, "-"):
hunk.Lines = append(hunk.Lines, Line{Kind: LineDelete, Content: line[1:], OldNo: oldNo})
oldNo++
case strings.HasPrefix(line, `\`):
hunk.Lines = append(hunk.Lines, Line{Kind: LineMeta, Content: strings.TrimSpace(line[1:])})
case strings.HasPrefix(line, " "):
hunk.Lines = append(hunk.Lines, Line{Kind: LineContext, Content: line[1:], OldNo: oldNo, NewNo: newNo})
oldNo++
newNo++
case line == "":
// A context line whose single leading space was stripped in transit, or the trailing
// newline of the file. Either way it ends the hunk's run of lines only if nothing
// follows; treating it as an empty context line keeps line numbering aligned.
hunk.Lines = append(hunk.Lines, Line{Kind: LineContext, OldNo: oldNo, NewNo: newNo})
oldNo++
newNo++
default:
// Anything else ends the hunk: git's trailing "-- \n<version>" signature, or prose
// wrapped around a pasted diff.
hunk = nil
}
}
// A file's trailing empty context line is an artefact of splitting on "\n", not a line of
// the diff. Only the very last line of the input can be one.
trimTrailingArtifact(files)
return files, nil
}
// trimTrailingArtifact drops the phantom context line produced by a diff that ends with a
// newline, which is every well-formed diff.
func trimTrailingArtifact(files []File) {
if len(files) == 0 {
return
}
f := &files[len(files)-1]
if len(f.Hunks) == 0 {
return
}
h := &f.Hunks[len(f.Hunks)-1]
if n := len(h.Lines); n > 0 && h.Lines[n-1].Kind == LineContext && h.Lines[n-1].Content == "" {
h.Lines = h.Lines[:n-1]
}
}
// parseDiffGitPaths reads the a/… b/… pair from a "diff --git" line. Paths containing spaces
// make this ambiguous in general; the ---/+++ lines that follow overwrite whatever is guessed
// here, so this only has to serve the header-only cases (a pure mode or rename change).
func parseDiffGitPaths(line string) (string, string) {
rest := strings.TrimPrefix(line, "diff --git ")
if m := diffGitQuotedRegexp.FindStringSubmatch(rest); m != nil {
return m[1], m[2]
}
if m := diffGitPathsRegex.FindStringSubmatch(rest); m != nil {
return m[1], m[2]
}
return rest, rest
}
// trimDiffPath strips the a/ or b/ prefix and the tab-separated timestamp that `diff -u`
// appends, leaving the path the file is known by.
func trimDiffPath(s string) string {
if i := strings.IndexByte(s, '\t'); i >= 0 {
s = s[:i]
}
s = strings.TrimSpace(s)
if s == devNull {
return s
}
if strings.HasPrefix(s, "a/") || strings.HasPrefix(s, "b/") {
return s[2:]
}
return s
}
// diffAnchorRangeRegex matches the "<start>-<end>" tail of a diff anchor.
var diffAnchorRangeRegex = regexp.MustCompile(`^(\d+)-(\d+)$`)
// diffFileAnchorTail marks an anchor that means the whole file rather than a range of its
// lines. It is a word rather than a range like "0-0" so the two forms cannot be confused by a
// reader — or by an agent — and so a file comment survives every edit inside the file.
const diffFileAnchorTail = "file"
// FormatDiffAnchor builds the anchor for a commented line range: "<display path>#<start>-<end>".
//
// start and end are 1-based indices into the file's rendered diff lines — added, removed and
// context lines all counted, @@ headers not — and NOT source line numbers. A removed line has no
// number on the new side, so source numbering could not express "do not delete this line", nor a
// selection spanning a removal and its replacement, which is exactly what a suggestion is for.
func FormatDiffAnchor(path string, start, end int) string {
return fmt.Sprintf("%s#%d-%d", path, start, end)
}
// ParseDiffAnchor reads an anchor back. ok is false for anything that is not a diff anchor —
// a Markdown "spec-element-7", say — so callers can pass those through untouched.
//
// The split is on the LAST '#', because a path may contain one: FormatDiffAnchor always ends in
// "#<digits>-<digits>", so an anchor into a file literally named "a#1-2" reads back correctly
// from "a#1-2#3-4". Splitting on the first '#' would not.
func ParseDiffAnchor(anchor string) (path string, start, end int, ok bool) {
i := strings.LastIndexByte(anchor, '#')
if i < 0 {
return "", 0, 0, false
}
m := diffAnchorRangeRegex.FindStringSubmatch(anchor[i+1:])
if m == nil {
return "", 0, 0, false
}
start, _ = strconv.Atoi(m[1])
end, _ = strconv.Atoi(m[2])
if start < 1 || end < start {
return "", 0, 0, false
}
return anchor[:i], start, end, true
}
// FormatDiffFileAnchor builds the anchor for a comment on a file as a whole: "<path>#file".
//
// Some review comments are about the change to a file rather than about any line in it — "this
// belongs in the other package", "where are the tests" — and pinning those to an arbitrary line
// both misplaces them and sends them outdated the moment that line is edited.
func FormatDiffFileAnchor(path string) string {
return path + "#" + diffFileAnchorTail
}
// ParseDiffFileAnchor reads a whole-file anchor back. Like ParseDiffAnchor it splits on the last
// '#', so a path containing one still round-trips.
func ParseDiffFileAnchor(anchor string) (path string, ok bool) {
i := strings.LastIndexByte(anchor, '#')
if i < 0 || anchor[i+1:] != diffFileAnchorTail {
return "", false
}
return anchor[:i], true
}
// RenderDiff compiles parsed diff files into the same interactive review page RenderSpec
// produces for Markdown.
//
// postProcessHTML is deliberately not run here: its badge and callout rewriting would corrupt
// code, and there is no Markdown to enhance. Every diff-derived string is escaped instead —
// nothing on this path has been through a HTML-producing renderer.
func RenderDiff(files []File) ([]byte, error) {
meta := SpecMetadata{
Mode: string(KindDiff),
Title: diffTitle(files),
Stats: diffStats(files),
Body: renderDiffBody(files),
}
return executeTemplate(meta)
}
func diffTitle(files []File) string {
if len(files) == 1 {
return html.EscapeString(files[0].DisplayPath())
}
return "Diff review"
}
func diffStats(files []File) string {
added, deleted := 0, 0
for _, f := range files {
for _, l := range f.Lines() {
switch l.Kind {
case LineAdd:
added++
case LineDelete:
deleted++
}
}
}
return fmt.Sprintf("%s · +%d −%d", pluralFiles(len(files)), added, deleted)
}
func pluralFiles(n int) string {
if n == 1 {
return "1 file"
}
return fmt.Sprintf("%d files", n)
}
// renderDiffBody builds the document body: one section per file, one row per diff line.
//
// Each row is emitted on a single output line because .diff-line is white-space: pre-wrap — a
// newline between its spans would render as a line break inside the row.
func renderDiffBody(files []File) string {
var b strings.Builder
b.WriteString(`<div class="diff-view">` + "\n")
for _, f := range files {
path := f.DisplayPath()
b.WriteString(`<section class="diff-file">` + "\n")
// data-file makes the header a comment target in its own right: a comment about the
// file as a whole anchors here rather than to a line that happens to be in it.
// data-status carries what happened to the file, so the contents rail can show it as a mark
// beside the name instead of repeating the words after it.
b.WriteString(`<h2 class="diff-file-header" data-file="` + html.EscapeString(path) +
`" data-status="` + string(f.Status()) + `">` +
html.EscapeString(path) + renderRenameNote(f) + "</h2>\n")
if len(f.Hunks) == 0 {
b.WriteString(`<p class="diff-empty">No textual changes.</p>` + "\n")
}
index := 0
for _, h := range f.Hunks {
b.WriteString(`<div class="diff-hunk">` + "\n")
b.WriteString(`<div class="diff-hunk-header">` + html.EscapeString(h.Header) + "</div>\n")
wsOnly := whitespaceOnlyMask(h.Lines)
for i, l := range h.Lines {
index++
b.WriteString(renderDiffLine(path, index, l, wsOnly[i]) + "\n")
}
b.WriteString("</div>\n")
}
b.WriteString("</section>\n")
}
b.WriteString("</div>\n")
return b.String()
}
// renderRenameNote spells out a rename, which the display path alone cannot show.
func renderRenameNote(f File) string {
switch f.Status() {
case FileAdded:
return ` <span class="diff-file-note">added</span>`
case FileDeleted:
return ` <span class="diff-file-note">deleted</span>`
case FileRenamed:
// The one case the display path cannot show on its own: where the file came from.
return ` <span class="diff-file-note">renamed from ` + html.EscapeString(f.OldPath) + `</span>`
default:
return ""
}
}
var lineKindClass = map[LineKind]string{
LineAdd: "diff-add",
LineDelete: "diff-del",
LineContext: "diff-ctx",
LineMeta: "diff-meta",
}
var lineKindMarker = map[LineKind]string{
LineAdd: "+",
LineDelete: "-",
LineContext: " ",
LineMeta: `\`,
}
// renderDiffLine emits one row.
//
// data-file and data-line-index are the coordinates a comment anchor is built from: the index
// is 1-based within the file and counts every rendered line, hunk headers excluded.
//
// The single line-number column shows each line's number on its own side — the old file's for a
// deletion, the new file's otherwise — because a deletion has no number on the new side and a
// blank there would hide which line the comment is about.
func renderDiffLine(path string, index int, l Line, wsOnly bool) string {
no := ""
noClass := "diff-no"
switch {
case l.Kind == LineDelete && l.OldNo > 0:
no = strconv.Itoa(l.OldNo)
noClass = "diff-no diff-no-old"
case l.NewNo > 0:
no = strconv.Itoa(l.NewNo)
}
// data-ws-only is emitted on both halves of a whitespace-only pair. The toggle then hides
// the deletion and restyles the addition purely in CSS, so every line keeps its
// data-line-index and comments anchored to it survive the switch.
ws := ""
if wsOnly {
ws = " data-ws-only"
}
return fmt.Sprintf(
`<div class="diff-line %s" data-file="%s" data-line-index="%d"%s><span class="%s">%s</span><span class="diff-marker">%s</span><span class="diff-code">%s</span></div>`,
lineKindClass[l.Kind],
html.EscapeString(path),
index,
ws,
noClass,
no,
lineKindMarker[l.Kind],
html.EscapeString(l.Content),
)
}
// whitespaceOnlyMask marks, for each line of one hunk, whether its change is whitespace-only —
// the pairs the "Hide whitespace changes" toggle folds away.
//
// reviewer never runs git, so `git diff -w` cannot be re-run against the sources: the judgement
// has to come out of the already-parsed hunk. The approximation is deliberately conservative —
// a run of deletions is paired 1:1 with the run of additions that follows it, and the pair only
// counts when both runs are the same length and every row matches once whitespace is stripped.
// A block whose lengths disagree folds nothing, because the 1:1 pairing that a longer or
// shorter counterpart implies would be guesswork, and a wrong fold hides a real edit.
func whitespaceOnlyMask(lines []Line) []bool {
if len(lines) == 0 {
return nil
}
mask := make([]bool, len(lines))
for i := 0; i < len(lines); {
if lines[i].Kind != LineDelete {
i++
continue
}
delStart := i
for i < len(lines) && lines[i].Kind == LineDelete {
i++
}
addStart := i
for i < len(lines) && lines[i].Kind == LineAdd {
i++
}
if addStart-delStart != i-addStart {
continue
}
if pairsDifferOnlyInWhitespace(lines[delStart:addStart], lines[addStart:i]) {
for j := delStart; j < i; j++ {
mask[j] = true
}
}
}
return mask
}
// pairsDifferOnlyInWhitespace reports whether two equal-length runs match row for row once all
// whitespace is removed, which is `git diff -w` (ignore-all-space) applied to a fixed pairing.
func pairsDifferOnlyInWhitespace(deleted, added []Line) bool {
for i := range deleted {
if stripWhitespace(deleted[i].Content) != stripWhitespace(added[i].Content) {
return false
}
}
return true
}
func stripWhitespace(s string) string {
return strings.Map(func(r rune) rune {
if unicode.IsSpace(r) {
return -1
}
return r
}, s)
}