v0.50.0 c4ec31f

diagnostics.go RawHistoryFolder

1package lsp
2
3import (
4	"fmt"
5	"path/filepath"
6	"regexp"
7	"sort"
8	"strings"
9)
10
11const DefaultDiagnosticCap = 50
12
13var missingModulePatterns = []*regexp.Regexp{
14	regexp.MustCompile(`(?i)cannot find module ["']([^"']+)["']`),
15	regexp.MustCompile(`(?i)could not find (?:a declaration file for )?module ["']([^"']+)["']`),
16	regexp.MustCompile(`(?i)(?:cannot|could not) resolve (?:module|import|package) ["']?([^"' ]+)["']?`),
17	regexp.MustCompile(`(?i)import .*?(["'][^"']+["']).*could not be resolved`),
18}
19
20// DiagnosticKey is stable across providers. Server/source are intentionally
21// excluded so an LSP and a CLI linter reporting the same issue collapse.
22func DiagnosticKey(d Diagnostic) string {
23	// Match the bridge's merge key: the start location is stable enough to
24	// identify one report while providers may disagree on the end range.
25	return strings.Join([]string{d.Path, defaultSeverity(d.Severity), d.Code,
26		fmt.Sprintf("%d:%d", d.Range.Start.Line, d.Range.Start.Character), compactWhitespace(d.Message)}, "\x00")
27}
28
29func diagnosticIssueKey(d Diagnostic) string {
30	return strings.Join([]string{defaultSeverity(d.Severity), d.Code, compactWhitespace(d.Message)}, "\x00")
31}
32
33// DiagnosticIdentity identifies the issue independently of its current
34// location. It is used by write/edit diagnostics to avoid replaying the same
35// error after a harmless line shift while still re-surfacing it after removal.
36func DiagnosticIdentity(d Diagnostic) string {
37	return diagnosticIssueKey(d)
38}
39
40// DeduplicateDiagnostics removes duplicates across servers and sorts errors,
41// warnings, info, and hints in that order, then path and location.
42func DeduplicateDiagnostics(input []Diagnostic) []Diagnostic {
43	seen := make(map[string]Diagnostic, len(input))
44	for _, diagnostic := range input {
45		key := DiagnosticKey(diagnostic)
46		if old, exists := seen[key]; !exists || old.Server == "" {
47			seen[key] = diagnostic
48		}
49	}
50	out := make([]Diagnostic, 0, len(seen))
51	for _, diagnostic := range seen {
52		out = append(out, diagnostic)
53	}
54	sort.SliceStable(out, func(i, j int) bool {
55		if severityRank(out[i].Severity) != severityRank(out[j].Severity) {
56			return severityRank(out[i].Severity) < severityRank(out[j].Severity)
57		}
58		if out[i].Path != out[j].Path {
59			return out[i].Path < out[j].Path
60		}
61		if out[i].Range.Start.Line != out[j].Range.Start.Line {
62			return out[i].Range.Start.Line < out[j].Range.Start.Line
63		}
64		if out[i].Range.Start.Character != out[j].Range.Start.Character {
65			return out[i].Range.Start.Character < out[j].Range.Start.Character
66		}
67		if out[i].Message != out[j].Message {
68			return out[i].Message < out[j].Message
69		}
70		return out[i].Code < out[j].Code
71	})
72	return out
73}
74
75func severityRank(value string) int {
76	switch strings.ToLower(value) {
77	case "error":
78		return 0
79	case "warning":
80		return 1
81	case "info":
82		return 2
83	case "hint":
84		return 3
85	default:
86		return 2
87	}
88}
89
90// SummarizeDiagnostics renders a bounded diagnostic summary. Repeated issues
91// are represented once with occurrence/file counts; module-resolution errors
92// retain their root issue while grouping the cascade of secondary errors.
93func SummarizeDiagnostics(input []Diagnostic, root string, cap int) string {
94	if cap <= 0 {
95		cap = DefaultDiagnosticCap
96	}
97	diagnostics := DeduplicateDiagnostics(input)
98	if len(diagnostics) == 0 {
99		return "No diagnostics."
100	}
101	counts := [4]int{}
102	for _, diagnostic := range diagnostics {
103		switch severityRank(diagnostic.Severity) {
104		case 0:
105			counts[0]++
106		case 1:
107			counts[1]++
108		case 2:
109			counts[2]++
110		case 3:
111			counts[3]++
112		}
113	}
114	parts := make([]string, 0, 4)
115	for i, label := range []string{"error", "warning", "info", "hint"} {
116		if counts[i] > 0 {
117			parts = append(parts, fmt.Sprintf("%d %s%s", counts[i], label, plural(counts[i])))
118		}
119	}
120	lines := []string{fmt.Sprintf("Diagnostics: %s (deduplicated %d)", strings.Join(parts, ", "), len(diagnostics))}
121	secondary := moduleSecondary(diagnostics)
122	groups := make(map[string][]Diagnostic)
123	for _, diagnostic := range diagnostics {
124		if secondary[DiagnosticKey(diagnostic)] {
125			continue
126		}
127		key := diagnosticIssueKey(diagnostic)
128		groups[key] = append(groups[key], diagnostic)
129	}
130	emitted := make(map[string]bool)
131	emittedSecondary := make(map[string]bool)
132	displayed := 0
133	for _, diagnostic := range diagnostics {
134		if displayed >= cap {
135			break
136		}
137		if secondary[DiagnosticKey(diagnostic)] {
138			continue
139		}
140		groupKey := diagnosticIssueKey(diagnostic)
141		bucket := groups[groupKey]
142		if len(bucket) >= 3 || uniquePaths(bucket) >= 2 {
143			if emitted[groupKey] {
144				continue
145			}
146			emitted[groupKey] = true
147			lines = append(lines, formatRepeated(bucket, root))
148			displayed += len(bucket)
149		} else {
150			lines = append(lines, "- "+FormatDiagnostic(diagnostic, root))
151			displayed++
152		}
153		if displayed < cap && missingModuleName(diagnostic) != "" && !emittedSecondary[diagnostic.Path] {
154			if secondaryLine, secondaryCount := formatSecondaryForFile(diagnostic.Path, diagnostics, secondary, root); secondaryLine != "" {
155				lines = append(lines, "- "+secondaryLine)
156				emittedSecondary[diagnostic.Path] = true
157				displayed += secondaryCount
158			}
159		}
160	}
161	omitted := len(diagnostics) - minInt(displayed, len(diagnostics))
162	if omitted > 0 {
163		lines = append(lines, fmt.Sprintf("... truncated; %d diagnostics omitted (hard cap %d)", omitted, cap))
164	}
165	return strings.Join(lines, "\n")
166}
167
168func formatRepeated(bucket []Diagnostic, root string) string {
169	paths := make(map[string]bool)
170	lines := make(map[int]bool)
171	for _, diagnostic := range bucket {
172		paths[diagnostic.Path] = true
173		lines[diagnostic.Range.Start.Line+1] = true
174	}
175	example := make([]string, 0, 3)
176	for _, diagnostic := range bucket {
177		path := diagnostic.Path
178		if root != "" {
179			if rel, err := filepathRel(root, path); err == nil {
180				path = rel
181			}
182		}
183		if !contains(example, path) && len(example) < 3 {
184			example = append(example, path)
185		}
186	}
187	extra := ""
188	if len(example) > 0 {
189		extra = fmt.Sprintf(" (examples: %s)", strings.Join(example, ", "))
190	}
191	code := ""
192	if bucket[0].Code != "" {
193		code = " [" + bucket[0].Code + "]"
194	}
195	where := fmt.Sprintf("%d occurrences across %d files", len(bucket), len(paths))
196	if len(paths) == 1 {
197		where = fmt.Sprintf("%d occurrences in %s", len(bucket), example[0])
198	}
199	if len(paths) == 1 && len(lines) > 0 {
200		where += fmt.Sprintf(" (lines: %s)", joinInts(lines, 3))
201	}
202	message := truncateText(compactWhitespace(bucket[0].Message), 220)
203	return fmt.Sprintf("%s: repeated %s%s %s%s", strings.ToUpper(defaultSeverity(bucket[0].Severity)), where, code, message, extra)
204}
205
206func moduleSecondary(diagnostics []Diagnostic) map[string]bool {
207	roots := make(map[string]bool)
208	byFile := make(map[string][]Diagnostic)
209	for _, diagnostic := range diagnostics {
210		byFile[diagnostic.Path] = append(byFile[diagnostic.Path], diagnostic)
211		if missingModuleName(diagnostic) != "" {
212			roots[diagnostic.Path] = true
213		}
214	}
215	secondary := make(map[string]bool)
216	for path, fileDiagnostics := range byFile {
217		if !roots[path] {
218			continue
219		}
220		count := 0
221		for _, diagnostic := range fileDiagnostics {
222			if missingModuleName(diagnostic) == "" {
223				count++
224			}
225		}
226		if count < 2 {
227			continue
228		}
229		for _, diagnostic := range fileDiagnostics {
230			if missingModuleName(diagnostic) == "" {
231				secondary[DiagnosticKey(diagnostic)] = true
232			}
233		}
234	}
235	return secondary
236}
237
238func formatSecondaryForFile(path string, diagnostics []Diagnostic, secondary map[string]bool, root string) (string, int) {
239	count := 0
240	modules := make(map[string]bool)
241	for _, diagnostic := range diagnostics {
242		if diagnostic.Path == path && secondary[DiagnosticKey(diagnostic)] {
243			count++
244		}
245		if diagnostic.Path == path && missingModuleName(diagnostic) != "" {
246			modules[missingModuleName(diagnostic)] = true
247		}
248	}
249	if count == 0 {
250		return "", 0
251	}
252	shownPath := path
253	if root != "" {
254		if rel, err := filepathRel(root, path); err == nil {
255			shownPath = rel
256		}
257	}
258	moduleNames := make([]string, 0, len(modules))
259	for name := range modules {
260		moduleNames = append(moduleNames, name)
261	}
262	sort.Strings(moduleNames)
263	return fmt.Sprintf("SECONDARY: grouped %d additional diagnostics in %s under module-resolution error (%s). Fix the root import error first, then re-check.", count, shownPath, strings.Join(moduleNames, ", ")), count
264}
265
266func missingModuleName(diagnostic Diagnostic) string {
267	if strings.ToLower(diagnostic.Severity) != "error" {
268		return ""
269	}
270	for _, pattern := range missingModulePatterns {
271		if match := pattern.FindStringSubmatch(diagnostic.Message); len(match) > 1 {
272			return strings.Trim(match[1], `"'`)
273		}
274	}
275	return ""
276}
277
278func uniquePaths(diagnostics []Diagnostic) int {
279	paths := make(map[string]bool)
280	for _, diagnostic := range diagnostics {
281		paths[diagnostic.Path] = true
282	}
283	return len(paths)
284}
285
286func plural(n int) string {
287	if n == 1 {
288		return ""
289	}
290	return "s"
291}
292func minInt(a, b int) int {
293	if a < b {
294		return a
295	}
296	return b
297}
298func contains(values []string, value string) bool {
299	for _, item := range values {
300		if item == value {
301			return true
302		}
303	}
304	return false
305}
306func joinInts(values map[int]bool, max int) string {
307	ordered := make([]int, 0, len(values))
308	for value := range values {
309		ordered = append(ordered, value)
310	}
311	sort.Ints(ordered)
312	if len(ordered) > max {
313		ordered = ordered[:max]
314	}
315	parts := make([]string, len(ordered))
316	for i, value := range ordered {
317		parts[i] = fmt.Sprint(value)
318	}
319	return strings.Join(parts, ", ")
320}
321func filepathRel(root, path string) (string, error) {
322	// Kept here rather than importing agent/tools: lsp is intentionally
323	// independent of the tool package.
324	rel, err := filepath.Rel(root, path)
325	if err != nil {
326		return "", err
327	}
328	if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
329		return path, nil
330	}
331	return filepath.ToSlash(rel), nil
332}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close