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}