Skip to content

Commit c8f69a8

Browse files
committed
implement diagnostic deduplication
1 parent 3187d7c commit c8f69a8

File tree

5 files changed

+114
-23
lines changed

5 files changed

+114
-23
lines changed

experimental/incremental/executor.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ func Run[T any](ctx context.Context, e *Executor, queries ...Query[T]) ([]Result
164164
record(dep)
165165
}
166166
}
167-
report.Sort()
167+
report.Canonicalize()
168168

169169
return results, report, nil
170170
}

experimental/parser/parse_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func TestParse(t *testing.T) {
4545
errs := &report.Report{Options: report.Options{Tracing: 10}}
4646
file, _ := Parse(report.NewFile(path, text), errs)
4747

48-
errs.Sort()
48+
errs.Canonicalize()
4949
stderr, _, _ := report.Renderer{
5050
Colorize: true,
5151
ShowDebug: true,

experimental/report/report.go

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import (
2323

2424
"google.golang.org/protobuf/proto"
2525

26+
"github.com/bufbuild/protocompile/internal/ext/cmpx"
27+
"github.com/bufbuild/protocompile/internal/ext/slicesx"
2628
compilerpb "github.com/bufbuild/protocompile/internal/gen/buf/compiler/v1alpha1"
2729
)
2830

@@ -54,6 +56,10 @@ type Options struct {
5456
// Higher values mean more debugging information. What debugging information
5557
// is actually provided is subject to change.
5658
Tracing int
59+
60+
// If set, [Report.Sort] will not discard duplicate diagnostics, as defined
61+
// in that function's contract.
62+
KeepDuplicates bool
5763
}
5864

5965
// Diagnose is a type that can be rendered as a diagnostic.
@@ -141,41 +147,63 @@ func (r *Report) CatchICE(resume bool, diagnose func(*Diagnostic)) {
141147
}
142148
}
143149

144-
// Sort canonicalizes this report's diagnostic order according to an specific
145-
// ordering criteria. Diagnostics are sorted by, in order;
150+
// Canonicalize sorts this report's diagnostics according to an specific
151+
// ordering criteria. Diagnostics are sorted by, in order:
146152
//
147-
// File name of primary span, SortOrder value, start offset of primary snippet,
148-
// end offset of primary snippet, content of error message.
153+
// 1. File name of primary span.
154+
// 2. SortOrder value.
155+
// 3. Start offset of primary snippet.
156+
// 4. End offset of primary snippet.
157+
// 5. Diagnostic tag.
158+
// 6. Textual content of error message.
149159
//
150160
// Where diagnostics have no primary span, the file is treated as empty and the
151161
// offsets are treated as zero.
152162
//
153163
// These criteria ensure that diagnostics for the same file go together,
154164
// diagnostics for the same sort order (lex, parse, etc) go together, and they
155165
// are otherwise ordered by where they occur in the file.
156-
func (r *Report) Sort() {
157-
slices.SortFunc(r.Diagnostics, func(a, b Diagnostic) int {
158-
aPrime := a.Primary()
159-
bPrime := b.Primary()
160-
161-
if diff := strings.Compare(aPrime.Path(), bPrime.Path()); diff != 0 {
162-
return diff
163-
}
164-
165-
if diff := a.sortOrder - b.sortOrder; diff != 0 {
166-
return diff
167-
}
166+
//
167+
// Canonicalize will deduplicate diagnostics whose primary span and (nonempty)
168+
// diagnostic tags are equal, selecting the diagnostic that sorts as greatest
169+
// as the canonical value. This allows later diagnostics to replace earlier
170+
// diagnostics, so long as they cooperate by using the same tag. Deduplication
171+
// can be suppressed using [Options].KeepDuplicates.
172+
func (r *Report) Canonicalize() {
173+
slices.SortFunc(r.Diagnostics, cmpx.Join(
174+
cmpx.Key(func(d Diagnostic) string { return d.Primary().Path() }),
175+
cmpx.Key(func(d Diagnostic) int { return d.sortOrder }),
176+
cmpx.Key(func(d Diagnostic) int { return d.Primary().Start }),
177+
cmpx.Key(func(d Diagnostic) int { return d.Primary().End }),
178+
cmpx.Key(func(d Diagnostic) string { return d.tag }),
179+
cmpx.Key(func(d Diagnostic) string { return d.message }),
180+
))
181+
182+
if r.KeepDuplicates {
183+
return
184+
}
168185

169-
if diff := aPrime.Start - bPrime.Start; diff != 0 {
170-
return diff
186+
type key struct {
187+
span Span
188+
tag string
189+
}
190+
var cur key
191+
slicesx.Backward(r.Diagnostics)(func(i int, d Diagnostic) bool {
192+
if d.tag == "" {
193+
return true
171194
}
172195

173-
if diff := aPrime.End - bPrime.End; diff != 0 {
174-
return diff
196+
key := key{d.Primary().Span(), d.tag}
197+
if cur.tag != "" && cur == key {
198+
r.Diagnostics[i].level = -1 // Use this to mark which diagnostics to delete.
199+
} else {
200+
cur = key
175201
}
176202

177-
return strings.Compare(a.message, b.message)
203+
return true
178204
})
205+
206+
r.Diagnostics = slices.DeleteFunc(r.Diagnostics, func(d Diagnostic) bool { return d.level == -1 })
179207
}
180208

181209
// ToProto converts this report into a Protobuf message for serialization.

internal/ext/cmpx/cmpx.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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 cmpx contains extensions to Go's package cmp.
16+
package cmpx
17+
18+
import "cmp"
19+
20+
// Result is the type returned by an [Ordering], and in particular
21+
// [cmp.Compare].
22+
type Result = int
23+
24+
const (
25+
// [cmp.Compare] guarantees these return values.
26+
Less Result = -1
27+
Equal Result = 0
28+
Greater Result = 1
29+
)
30+
31+
// Ordering is an ordering for the type T, which is any function with the same
32+
// signature as [Compare].
33+
type Ordering[T any] func(T, T) Result
34+
35+
// Key returns an ordering for T according to a key function, which must return
36+
// a [cmp.Ordered] value.
37+
func Key[T any, U cmp.Ordered](key func(T) U) Ordering[T] {
38+
return func(a, b T) Result { return cmp.Compare(key(a), key(b)) }
39+
}
40+
41+
// Join returns an ordering for T which returns the first of cmps returns a
42+
// non-[Equal] value.
43+
func Join[T any](cmps ...Ordering[T]) Ordering[T] {
44+
return func(a, b T) Result {
45+
for _, cmp := range cmps {
46+
if n := cmp(a, b); n != Equal {
47+
return n
48+
}
49+
}
50+
return Equal
51+
}
52+
}

internal/ext/slicesx/iter.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,14 @@ func Values[S ~[]E, E any](s S) iter.Seq[E] {
119119
}
120120
}
121121
}
122+
123+
// Backward is a polyfill for [slices.Backward].
124+
func Backward[S ~[]E, E any](s S) iter.Seq2[int, E] {
125+
return func(yield func(int, E) bool) {
126+
for i := len(s) - 1; i > 0; i-- {
127+
if !yield(i, s[i]) {
128+
return
129+
}
130+
}
131+
}
132+
}

0 commit comments

Comments
 (0)