1package lsp
2
3import (
4 "bufio"
5 "encoding/json"
6 "fmt"
7 "path/filepath"
8 "regexp"
9 "strconv"
10 "strings"
11)
12
13// ParseDiagnostics parses output from one of the built-in CLI linter formats.
14// Unknown parser names use the generic file:line:column parser.
15func ParseDiagnostics(parser, provider, cwd, output string) []Diagnostic {
16 switch strings.ToLower(strings.TrimSpace(parser)) {
17 case "eslint-json", "eslint":
18 return parseESLint(provider, cwd, output)
19 case "golangci-lint-json", "golangci", "golangci-json":
20 return parseGolangCILint(provider, cwd, output)
21 case "ruff-json", "ruff":
22 return parseRuff(provider, cwd, output)
23 case "sarif":
24 return parseSARIF(provider, cwd, output)
25 default:
26 return parseGeneric(provider, cwd, output)
27 }
28}
29
30func parseESLint(provider, cwd, output string) []Diagnostic {
31 var results []struct {
32 FilePath string `json:"filePath"`
33 Messages []struct {
34 RuleID string `json:"ruleId"`
35 Severity int `json:"severity"`
36 Message string `json:"message"`
37 Line int `json:"line"`
38 Column int `json:"column"`
39 EndLine int `json:"endLine"`
40 EndColumn int `json:"endColumn"`
41 } `json:"messages"`
42 }
43 if json.Unmarshal([]byte(output), &results) != nil {
44 return parseGeneric(provider, cwd, output)
45 }
46 var out []Diagnostic
47 for _, result := range results {
48 path := normalizeDiagnosticPath(cwd, result.FilePath)
49 for _, message := range result.Messages {
50 severity := "info"
51 if message.Severity >= 2 {
52 severity = "error"
53 } else if message.Severity == 1 {
54 severity = "warning"
55 }
56 out = append(out, Diagnostic{Path: path, Server: provider, Source: provider, Severity: severity, Code: message.RuleID, Message: message.Message, Range: oneBasedRange(message.Line, message.Column, message.EndLine, message.EndColumn)})
57 }
58 }
59 return out
60}
61
62func parseGolangCILint(provider, cwd, output string) []Diagnostic {
63 var root struct {
64 Issues []struct {
65 FromLinter string `json:"FromLinter"`
66 Text string `json:"Text"`
67 Severity string `json:"Severity"`
68 Pos struct {
69 Filename string `json:"Filename"`
70 Line int `json:"Line"`
71 Column int `json:"Column"`
72 Offset int `json:"Offset"`
73 } `json:"Pos"`
74 LineRange *struct {
75 From int `json:"From"`
76 To int `json:"To"`
77 } `json:"LineRange"`
78 } `json:"Issues"`
79 }
80 if json.Unmarshal([]byte(output), &root) != nil {
81 return parseGeneric(provider, cwd, output)
82 }
83 var out []Diagnostic
84 for _, issue := range root.Issues {
85 severity := normalizeSeverity(issue.Severity)
86 endLine := issue.Pos.Line
87 if issue.LineRange != nil && issue.LineRange.To > endLine {
88 endLine = issue.LineRange.To
89 }
90 out = append(out, Diagnostic{Path: normalizeDiagnosticPath(cwd, issue.Pos.Filename), Server: provider, Source: issue.FromLinter, Severity: severity, Code: issue.FromLinter, Message: issue.Text, Range: oneBasedRange(issue.Pos.Line, issue.Pos.Column, endLine, issue.Pos.Column)})
91 }
92 return out
93}
94
95func parseRuff(provider, cwd, output string) []Diagnostic {
96 var records []struct {
97 Code string `json:"code"`
98 Message string `json:"message"`
99 Filename string `json:"filename"`
100 Location struct {
101 Row int `json:"row"`
102 Column int `json:"column"`
103 } `json:"location"`
104 EndLocation struct {
105 Row int `json:"row"`
106 Column int `json:"column"`
107 } `json:"end_location"`
108 Fix any `json:"fix"`
109 }
110 if json.Unmarshal([]byte(output), &records) != nil {
111 return parseGeneric(provider, cwd, output)
112 }
113 var out []Diagnostic
114 for _, record := range records {
115 endLine, endColumn := record.EndLocation.Row, record.EndLocation.Column
116 if endLine == 0 {
117 endLine, endColumn = record.Location.Row, record.Location.Column
118 }
119 out = append(out, Diagnostic{Path: normalizeDiagnosticPath(cwd, record.Filename), Server: provider, Source: provider, Severity: "error", Code: record.Code, Message: record.Message, Range: oneBasedRange(record.Location.Row, record.Location.Column, endLine, endColumn)})
120 }
121 return out
122}
123
124func parseSARIF(provider, cwd, output string) []Diagnostic {
125 var root struct {
126 Runs []struct {
127 Tool struct {
128 Driver struct {
129 Rules []struct {
130 ID string `json:"id"`
131 } `json:"rules"`
132 } `json:"driver"`
133 } `json:"tool"`
134 Results []struct {
135 RuleID string `json:"ruleId"`
136 Level string `json:"level"`
137 Message struct {
138 Text string `json:"text"`
139 } `json:"message"`
140 Locations []struct {
141 Physical struct {
142 ArtifactLocation struct {
143 URI string `json:"uri"`
144 } `json:"artifactLocation"`
145 Region struct {
146 StartLine int `json:"startLine"`
147 StartColumn int `json:"startColumn"`
148 EndLine int `json:"endLine"`
149 EndColumn int `json:"endColumn"`
150 } `json:"region"`
151 } `json:"physicalLocation"`
152 } `json:"locations"`
153 } `json:"results"`
154 } `json:"runs"`
155 }
156 if json.Unmarshal([]byte(output), &root) != nil {
157 return parseGeneric(provider, cwd, output)
158 }
159 var out []Diagnostic
160 for _, run := range root.Runs {
161 for _, result := range run.Results {
162 if len(result.Locations) == 0 {
163 continue
164 }
165 location := result.Locations[0].Physical
166 path := location.ArtifactLocation.URI
167 if strings.HasPrefix(path, "file://") {
168 if decoded, err := uriToPath(path); err == nil {
169 path = decoded
170 }
171 }
172 out = append(out, Diagnostic{Path: normalizeDiagnosticPath(cwd, path), Server: provider, Source: provider, Severity: normalizeSeverity(result.Level), Code: result.RuleID, Message: result.Message.Text, Range: oneBasedRange(location.Region.StartLine, location.Region.StartColumn, location.Region.EndLine, location.Region.EndColumn)})
173 }
174 }
175 return out
176}
177
178var genericDiagnosticPattern = regexp.MustCompile(`(?i)^\s*(.+?):([0-9]+):([0-9]+)(?::\s*(error|err|fatal|failure|warning|warn|info|hint|note)\b)?\s*[-:]?\s*(.*)$`)
179
180func parseGeneric(provider, cwd, output string) []Diagnostic {
181 var out []Diagnostic
182 scanner := bufio.NewScanner(strings.NewReader(output))
183 for scanner.Scan() {
184 line := strings.TrimSpace(scanner.Text())
185 match := genericDiagnosticPattern.FindStringSubmatch(line)
186 if len(match) == 0 {
187 continue
188 }
189 lineNo, _ := strconv.Atoi(match[2])
190 columnNo, _ := strconv.Atoi(match[3])
191 message := strings.TrimSpace(match[5])
192 if message == "" {
193 message = line
194 }
195 severity := normalizeSeverity(match[4])
196 out = append(out, Diagnostic{Path: normalizeDiagnosticPath(cwd, match[1]), Server: provider, Source: provider, Severity: severity, Message: message, Range: oneBasedRange(lineNo, columnNo, lineNo, columnNo)})
197 }
198 return out
199}
200
201func oneBasedRange(line, column, endLine, endColumn int) Range {
202 if line < 1 {
203 line = 1
204 }
205 if column < 1 {
206 column = 1
207 }
208 if endLine < 1 {
209 endLine = line
210 }
211 if endColumn < 1 {
212 endColumn = column
213 }
214 return Range{Start: Position{Line: line - 1, Character: column - 1}, End: Position{Line: endLine - 1, Character: endColumn - 1}}
215}
216
217func normalizeSeverity(value string) string {
218 switch strings.ToLower(strings.TrimSpace(value)) {
219 case "error", "err", "fatal", "failure":
220 return "error"
221 case "warning", "warn":
222 return "warning"
223 case "hint":
224 return "hint"
225 default:
226 return "info"
227 }
228}
229
230func normalizeDiagnosticPath(cwd, path string) string {
231 path = strings.TrimSpace(path)
232 if path == "" {
233 return ""
234 }
235 if strings.HasPrefix(path, "file://") {
236 if decoded, err := uriToPath(path); err == nil {
237 path = decoded
238 }
239 }
240 if !filepath.IsAbs(path) {
241 path = filepath.Join(cwd, path)
242 }
243 abs, err := filepath.Abs(path)
244 if err == nil {
245 return filepath.Clean(abs)
246 }
247 return filepath.Clean(path)
248}
249
250// FormatDiagnostic is the compact, model-facing form of one diagnostic.
251func FormatDiagnostic(d Diagnostic, root string) string {
252 path := d.Path
253 if root != "" {
254 if rel, err := filepath.Rel(root, d.Path); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
255 path = filepath.ToSlash(rel)
256 }
257 }
258 code := ""
259 if d.Code != "" {
260 code = " [" + d.Code + "]"
261 }
262 message := truncateText(compactWhitespace(d.Message), 220)
263 return fmt.Sprintf("%s: %s:%d:%d%s %s", strings.ToUpper(defaultSeverity(d.Severity)), filepath.ToSlash(path), d.Range.Start.Line+1, d.Range.Start.Character+1, code, message)
264}
265
266func defaultSeverity(value string) string {
267 if value == "" {
268 return "info"
269 }
270 return value
271}
272
273func compactWhitespace(value string) string { return strings.Join(strings.Fields(value), " ") }
274
275func truncateText(value string, limit int) string {
276 if limit <= 0 {
277 return ""
278 }
279 runes := []rune(value)
280 if len(runes) <= limit {
281 return value
282 }
283 if limit == 1 {
284 return "…"
285 }
286 return string(runes[:limit-1]) + "…"
287}