Skip to content

Commit 8b3b7d7

Browse files
committed
PR #437
1 parent 0fe48ab commit 8b3b7d7

File tree

8 files changed

+205
-49
lines changed

8 files changed

+205
-49
lines changed

experimental/report/diff.go

Lines changed: 52 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package report
1616

1717
import (
18+
"slices"
1819
"sort"
1920
"strings"
2021

@@ -56,40 +57,44 @@ func (h hunk) bold(ss *styleSheet) string {
5657
}
5758

5859
// hunkDiff computes edit hunks for a diff.
59-
func hunkDiff(span Span, edits []Edit) []hunk {
60+
func hunkDiff(span Span, edits []Edit) (Span, []hunk) {
6061
out := make([]hunk, 0, len(edits)*3+1)
6162
var prev int
62-
src, offsets := offsetsForDiffing(span, edits)
63-
for i, edit := range edits {
64-
start := offsets[i][0]
65-
end := offsets[i][1]
66-
63+
span, edits = offsetsForDiffing(span, edits)
64+
src := span.Text()
65+
for _, edit := range edits {
6766
out = append(out,
68-
hunk{hunkUnchanged, src[prev:start]},
69-
hunk{hunkDelete, src[start:end]},
67+
hunk{hunkUnchanged, src[prev:edit.Start]},
68+
hunk{hunkDelete, src[edit.Start:edit.End]},
7069
hunk{hunkAdd, edit.Replace},
7170
)
72-
prev = end
71+
prev = edit.End
7372
}
74-
return append(out, hunk{hunkUnchanged, src[prev:]})
73+
return span, append(out, hunk{hunkUnchanged, src[prev:]})
7574
}
7675

7776
// unifiedDiff computes whole-line hunks for this diff, for producing a unified
7877
// edit.
7978
//
8079
// Each slice will contain one or more lines that should be displayed together.
81-
func unifiedDiff(span Span, edits []Edit) []hunk {
80+
func unifiedDiff(span Span, edits []Edit) (Span, []hunk) {
8281
// Sort the edits such that they are ordered by starting offset.
83-
src, offsets := offsetsForDiffing(span, edits)
82+
span, edits = offsetsForDiffing(span, edits)
83+
src := span.Text()
8484
sort.Slice(edits, func(i, j int) bool {
85-
return offsets[i][0] < offsets[j][0]
85+
return edits[i].Start < edits[j].End
8686
})
8787

8888
// Partition offsets into overlapping lines. That is, this connects together
8989
// all edit spans whose end and start are not separated by a newline.
90-
prev := &offsets[0]
91-
parts := slicesx.Partition(offsets, func(_, next *[2]int) bool {
92-
if next == prev || !strings.Contains(src[prev[1]:next[0]], "\n") {
90+
prev := &edits[0]
91+
parts := slicesx.Partition(edits, func(_, next *Edit) bool {
92+
if next == prev {
93+
return false
94+
}
95+
96+
chunk := src[prev.End:next.Start]
97+
if !strings.Contains(chunk, "\n") {
9398
return false
9499
}
95100

@@ -99,12 +104,12 @@ func unifiedDiff(span Span, edits []Edit) []hunk {
99104

100105
var out []hunk
101106
var prevHunk int
102-
parts(func(i int, offsets [][2]int) bool {
107+
parts(func(_ int, edits []Edit) bool {
103108
// First, figure out the start and end of the modified region.
104-
start, end := offsets[0][0], offsets[0][1]
105-
for _, offset := range offsets[1:] {
106-
start = min(start, offset[0])
107-
end = max(end, offset[1])
109+
start, end := edits[0].Start, edits[0].End
110+
for _, edit := range edits[1:] {
111+
start = min(start, edit.Start)
112+
end = max(end, edit.End)
108113
}
109114
// Then, snap the region to be newline delimited. This is the unedited
110115
// lines.
@@ -114,10 +119,10 @@ func unifiedDiff(span Span, edits []Edit) []hunk {
114119
// Now, apply the edits to original to produce the modified result.
115120
var buf strings.Builder
116121
prev := 0
117-
for j, offset := range offsets {
118-
buf.WriteString(original[prev:offset[0]])
119-
buf.WriteString(edits[i+j].Replace)
120-
prev = offset[1]
122+
for _, edit := range edits {
123+
buf.WriteString(original[prev : edit.Start-start])
124+
buf.WriteString(edit.Replace)
125+
prev = edit.End - start
121126
}
122127
buf.WriteString(original[prev:])
123128

@@ -131,20 +136,31 @@ func unifiedDiff(span Span, edits []Edit) []hunk {
131136
prevHunk = end
132137
return true
133138
})
134-
return append(out, hunk{hunkUnchanged, src[prevHunk:]})
139+
return span, append(out, hunk{hunkUnchanged, src[prevHunk:]})
135140
}
136141

137142
// offsetsForDiffing pre-calculates information needed for diffing:
138-
// the line-snapped span, and the offsetsForDiffing of each edit as indices into
139-
// that span.
140-
func offsetsForDiffing(span Span, edits []Edit) (string, [][2]int) {
141-
start, end := adjustLineOffsets(span.File.Text(), span.Start, span.End)
142-
delta := span.Start - start
143-
144-
offsets := make([][2]int, len(edits))
145-
for i, edit := range edits {
146-
offsets[i] = [2]int{edit.Start + delta, edit.End + delta}
143+
// the line-snapped span, and edits which are adjusted to conform to that
144+
// span.
145+
func offsetsForDiffing(span Span, edits []Edit) (Span, []Edit) {
146+
edits = slices.Clone(edits)
147+
var start, end int
148+
for i := range edits {
149+
e := &edits[i]
150+
e.Start += span.Start
151+
e.End += span.Start
152+
if i == 0 {
153+
start, end = e.Start, e.End
154+
} else {
155+
start, end = min(e.Start, start), max(e.End, end)
156+
}
157+
}
158+
159+
start, end = adjustLineOffsets(span.File.Text(), start, end)
160+
for i := range edits {
161+
edits[i].Start -= start
162+
edits[i].End -= start
147163
}
148164

149-
return span.File.Text()[start:end], offsets
165+
return span.File.Span(start, end), edits
150166
}

experimental/report/renderer.go

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"unicode"
2626

2727
"github.com/bufbuild/protocompile/internal/ext/slicesx"
28+
"github.com/bufbuild/protocompile/internal/ext/stringsx"
2829
)
2930

3031
// Renderer configures a diagnostic rendering operation.
@@ -246,7 +247,7 @@ func (r Renderer) diagnostic(report *Report, d Diagnostic) string {
246247
if i > 0 {
247248
out.WriteByte('\n')
248249
}
249-
suggestion(snippets[0], locations[i][0].Line, lineBarWidth, &ss, &out)
250+
suggestion(snippets[0], lineBarWidth, &ss, &out)
250251
return true
251252
}
252253

@@ -911,7 +912,7 @@ func renderSidebar(bars, lineno, slashAt int, ss *styleSheet, multis []*multilin
911912
}
912913

913914
// suggestion renders a single suggestion window.
914-
func suggestion(snip snippet, startLine int, lineBarWidth int, ss *styleSheet, out *strings.Builder) {
915+
func suggestion(snip snippet, lineBarWidth int, ss *styleSheet, out *strings.Builder) {
915916
out.WriteString(ss.nAccent)
916917
padBy(out, lineBarWidth)
917918
out.WriteString("help: ")
@@ -933,12 +934,29 @@ func suggestion(snip snippet, startLine int, lineBarWidth int, ss *styleSheet, o
933934
strings.Contains(snip.Span.Text(), "\n")
934935

935936
if multiline {
936-
aLine := startLine
937-
bLine := startLine
938-
for _, hunk := range unifiedDiff(snip.Span, snip.edits) {
937+
span, hunks := unifiedDiff(snip.Span, snip.edits)
938+
aLine := span.StartLoc().Line
939+
bLine := aLine
940+
for i, hunk := range hunks {
941+
// Trim a single newline before and after hunk. This helps deal with
942+
// cases where a newline gets duplicated across hunks of different
943+
// type.
944+
hunk.content, _ = strings.CutPrefix(hunk.content, "\n")
945+
hunk.content, _ = strings.CutSuffix(hunk.content, "\n")
946+
939947
if hunk.content == "" {
940948
continue
941949
}
950+
951+
// Skip addition lines that only contain whitespace, if the previous
952+
// hunk was a deletion. This helps avoid cases where a whole line
953+
// was deleted and some indentation was left over.
954+
if prev, _ := slicesx.Get(hunks, i-1); prev.kind == hunkDelete &&
955+
hunk.kind == hunkAdd &&
956+
stringsx.EveryFunc(hunk.content, unicode.IsSpace) {
957+
continue
958+
}
959+
942960
for _, line := range strings.Split(hunk.content, "\n") {
943961
lineno := aLine
944962
if hunk.kind == '+' {
@@ -954,12 +972,12 @@ func suggestion(snip snippet, startLine int, lineBarWidth int, ss *styleSheet, o
954972
)
955973

956974
switch hunk.kind {
957-
case ' ':
975+
case hunkUnchanged:
958976
aLine++
959977
bLine++
960-
case '-':
978+
case hunkDelete:
961979
aLine++
962-
case '+':
980+
case hunkAdd:
963981
bLine++
964982
}
965983
}
@@ -972,8 +990,8 @@ func suggestion(snip snippet, startLine int, lineBarWidth int, ss *styleSheet, o
972990
return
973991
}
974992

975-
fmt.Fprintf(out, "\n%s%*d | ", ss.nAccent, lineBarWidth, startLine)
976-
hunks := hunkDiff(snip.Span, snip.edits)
993+
span, hunks := hunkDiff(snip.Span, snip.edits)
994+
fmt.Fprintf(out, "\n%s%*d | ", ss.nAccent, lineBarWidth, span.StartLoc().Line)
977995
var column int
978996
for _, hunk := range hunks {
979997
if hunk.content == "" {

experimental/report/testdata/suggestions.yaml

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,26 @@ diagnostics:
104104
replace: "{\n option "
105105
- start: 10
106106
end: 12
107-
replace: ";\n }"
107+
replace: ";\n }"
108+
109+
- message: 'delete some stuff'
110+
level: LEVEL_ERROR
111+
annotations:
112+
- file: 0
113+
start: 38
114+
end: 153
115+
edits:
116+
- start: 0
117+
end: 13
118+
- start: 114
119+
end: 115
120+
121+
- message: 'delete this method'
122+
level: LEVEL_ERROR
123+
annotations:
124+
- file: 0
125+
start: 38
126+
end: 153
127+
edits:
128+
- start: 59
129+
end: 113

experimental/report/testdata/suggestions.yaml.color.txt

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,22 @@
4646
⟨blu⟩ 9 | ⟨b.grn⟩+⟨grn⟩ }
4747
⟨blu⟩ | ⟨reset⟩
4848

49-
⟨b.red⟩encountered 2 errors and 1 warning⟨reset⟩
49+
⟨b.red⟩error: delete some stuff⟨reset⟩
50+
⟨blu⟩ --> foo.proto:5:1
51+
⟨blu⟩ help:
52+
|
53+
⟨blu⟩ 5 | ⟨b.red⟩-⟨red⟩ service Foo {
54+
⟨blu⟩ 6 | ⟨reset⟩ ⟨reset⟩ rpc Get(GetRequest) returns GetResponse;
55+
⟨blu⟩ 7 | ⟨reset⟩ ⟨reset⟩ rpc Put(PutRequest) returns (PutResponse) [foo = bar];
56+
⟨blu⟩ 8 | ⟨b.red⟩-⟨red⟩ }
57+
⟨blu⟩ | ⟨reset⟩
58+
59+
⟨b.red⟩error: delete this method⟨reset⟩
60+
⟨blu⟩ --> foo.proto:5:1
61+
⟨blu⟩ help:
62+
|
63+
⟨blu⟩ 7 | ⟨b.red⟩-⟨red⟩ rpc Put(PutRequest) returns (PutResponse) [foo = bar];
64+
⟨blu⟩ | ⟨reset⟩
65+
66+
⟨b.red⟩encountered 4 errors and 1 warning⟨reset⟩
5067
⟨reset⟩

experimental/report/testdata/suggestions.yaml.fancy.txt

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,21 @@ error: method options must go in a block
4646
9 | + }
4747
|
4848

49-
encountered 2 errors and 1 warning
49+
error: delete some stuff
50+
--> foo.proto:5:1
51+
help:
52+
|
53+
5 | - service Foo {
54+
6 | rpc Get(GetRequest) returns GetResponse;
55+
7 | rpc Put(PutRequest) returns (PutResponse) [foo = bar];
56+
8 | - }
57+
|
58+
59+
error: delete this method
60+
--> foo.proto:5:1
61+
help:
62+
|
63+
7 | - rpc Put(PutRequest) returns (PutResponse) [foo = bar];
64+
|
65+
66+
encountered 4 errors and 1 warning

experimental/report/testdata/suggestions.yaml.simple.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ remark: foo.proto:1:10: let protocompile pick a syntax for you
33
warning: foo.proto:5:9: services should have a `Service` suffix
44
error: foo.proto:6:31: missing (...) around return type
55
error: foo.proto:7:45: method options must go in a block
6+
error: foo.proto:5:1: delete some stuff
7+
error: foo.proto:5:1: delete this method

internal/ext/iterx/iterx.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ func First[T any](seq iter.Seq[T]) (v T, ok bool) {
4040
return v, ok
4141
}
4242

43+
// All returns whether every element of an iterator satisfies the given
44+
// predicate. Returns true if seq yields no values.
45+
func All[T any](seq iter.Seq[T], p func(T) bool) bool {
46+
all := true
47+
seq(func(v T) bool {
48+
all = p(v)
49+
return all
50+
})
51+
return all
52+
}
53+
4354
// Map returns a new iterator applying f to each element of seq.
4455
func Map[T, U any](seq iter.Seq[T], f func(T) U) iter.Seq[U] {
4556
return func(yield func(U) bool) {

internal/ext/stringsx/stringsx.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright 2020-2025 Buf Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// package stringsx contains extensions to Go's package strings.
16+
package stringsx
17+
18+
import (
19+
"github.com/bufbuild/protocompile/internal/ext/iterx"
20+
"github.com/bufbuild/protocompile/internal/ext/unsafex"
21+
"github.com/bufbuild/protocompile/internal/iter"
22+
)
23+
24+
// EveryFunc verifies that all runes in the string satisfy the given predicate.
25+
func EveryFunc(s string, p func(rune) bool) bool {
26+
return iterx.All(Runes(s), p)
27+
}
28+
29+
// Runes returns an iterator over the runes in a string.
30+
//
31+
// Each non-UTF-8 byte in the string is yielded as a replacement character (U+FFFD).
32+
func Runes(s string) iter.Seq[rune] {
33+
return func(yield func(r rune) bool) {
34+
for _, r := range s {
35+
if !yield(r) {
36+
return
37+
}
38+
}
39+
}
40+
}
41+
42+
// Bytes returns an iterator over the bytes in a string.
43+
func Bytes(s string) iter.Seq[byte] {
44+
return func(yield func(byte) bool) {
45+
for i := 0; i < len(s); i++ {
46+
// Avoid performing a bounds check each loop step.
47+
b := *unsafex.Add(unsafex.StringData(s), i)
48+
if !yield(b) {
49+
return
50+
}
51+
}
52+
}
53+
}

0 commit comments

Comments
 (0)