v0.14.0 75d4151

parsers.go RawHistoryFolder

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}

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 477

↑↓ moveenter openesc close