all repos

clerk @ cf9c1d930d43e1c0015820114a18abaf71ae60f3

missing tooling for ledger/hledger

clerk/internal/lsp/diagnostics.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
feat: config (#6), 1 month ago
1
package lsp
2
3
import (
4
	"context"
5
	"time"
6
7
	"go.lsp.dev/jsonrpc2"
8
	"go.lsp.dev/protocol"
9
	"go.lsp.dev/uri"
10
11
	"olexsmir.xyz/clerk/internal/linter"
12
	"olexsmir.xyz/clerk/journal/token"
13
)
14
15
const diagDebounce = 200 * time.Millisecond
16
17
func (s *server) scheduleDiagnostics(ctx context.Context) {
18
	s.mu.Lock()
19
	defer s.mu.Unlock()
20
21
	if s.diagCancel != nil {
22
		s.diagCancel()
23
	}
24
25
	ctx, cancel := context.WithCancel(context.Background())
26
	s.diagCancel = cancel
27
28
	time.AfterFunc(diagDebounce, func() {
29
		if ctx.Err() != nil {
30
			return
31
		}
32
		s.publishDiagnostics(jsonrpc2.DetachContext(ctx))
33
	})
34
}
35
36
func (s *server) publishDiagnostics(ctx context.Context) {
37
	s.log.Debug("publishing diagnostics")
38
39
	s.mu.RLock()
40
	var dirtyURIs []uri.URI
41
	for u, state := range s.openDocs {
42
		if state.dirty {
43
			dirtyURIs = append(dirtyURIs, u)
44
		}
45
	}
46
	s.mu.RUnlock()
47
	if len(dirtyURIs) == 0 {
48
		s.log.Debug("no dirty files")
49
		return
50
	}
51
52
	// Rebuild every dirty doc and publish the union of their files;
53
	// the same included file may appear in several trees and must be published once.
54
	s.mu.RLock()
55
	lintCfg := s.settings.Linter
56
	s.mu.RUnlock()
57
	lint, err := linter.NewLinter(lintCfg)
58
	if err != nil {
59
		s.log.Warn("building linter failed", "err", err)
60
		return
61
	}
62
63
	var finds []linter.Find
64
	paths := make(map[string]bool)
65
	for _, u := range dirtyURIs {
66
		a := s.analysisFor(u)
67
		if a == nil {
68
			continue
69
		}
70
		for _, pf := range a.Files {
71
			paths[pf.Path] = true
72
		}
73
		finds = append(finds, lint.Run(a)...)
74
	}
75
76
	if ctx.Err() != nil {
77
		return
78
	}
79
80
	s.assignSeverities(finds)
81
	diagsByFile := s.groupFindsByFile(dedupFinds(finds))
82
	for fpath := range paths {
83
		if err := s.client.PublishDiagnostics(ctx, &protocol.PublishDiagnosticsParams{
84
			URI:         uri.File(fpath),
85
			Diagnostics: diagsByFile[fpath],
86
		}); err != nil {
87
			s.log.Warn("publish diagnostics failed", "uri", uri.File(fpath), "err", err)
88
		}
89
	}
90
91
	s.log.Debug("diagnostics published", "files", len(paths), "findings", len(finds))
92
}
93
94
func (s *server) groupFindsByFile(finds []linter.Find) map[string][]protocol.Diagnostic {
95
	// count per file to pre-size the diagnostic slices: append growth on ~10k
96
	// findings is the dominant allocation in the diagnostics path
97
	counts := make(map[string]int, len(finds))
98
	for _, find := range finds {
99
		if find.Span.File != "" {
100
			counts[find.Span.File]++
101
		}
102
	}
103
	diags := make(map[string][]protocol.Diagnostic, len(counts))
104
	for fpath, n := range counts {
105
		diags[fpath] = make([]protocol.Diagnostic, 0, n)
106
	}
107
	for _, find := range finds {
108
		file := find.Span.File
109
		if file == "" {
110
			continue
111
		}
112
		diags[file] = append(diags[file], s.findToDiagnostic(find))
113
	}
114
	return diags
115
}
116
117
func (s *server) findToDiagnostic(find linter.Find) protocol.Diagnostic {
118
	return protocol.Diagnostic{
119
		Range:    spanToRange(find.Span),
120
		Severity: severityToLSP(find.Severity),
121
		Message:  protocol.String(find.Message),
122
		Source:   protocol.NewOptional(s.name),
123
		Code:     protocol.String(string(find.Code)),
124
	}
125
}
126
127
func dedupFinds(finds []linter.Find) []linter.Find {
128
	seen := make(map[findKey]bool, len(finds))
129
	dedup := make([]linter.Find, 0, len(finds))
130
	for _, f := range finds {
131
		k := findKey{f.Span.File, f.Span.Start.Line, f.Span.Start.Col, f.Code}
132
		if seen[k] {
133
			continue
134
		}
135
		seen[k] = true
136
		dedup = append(dedup, f)
137
	}
138
	return dedup
139
}
140
141
type findKey struct {
142
	file      string
143
	line, col int
144
	code      linter.RuleID
145
}
146
147
func spanToRange(span token.Span) protocol.Range {
148
	return protocol.Range{
149
		Start: protocol.Position{
150
			Line:      max(0, uint32(span.Start.Line-1)),
151
			Character: max(0, uint32(span.Start.Col-1)),
152
		},
153
		End: protocol.Position{
154
			Line:      max(0, uint32(span.End.Line-1)),
155
			Character: uint32(max(0, span.End.Col-1)),
156
		},
157
	}
158
}
159
160
func (s *server) assignSeverities(finds []linter.Find) {
161
	s.mu.RLock()
162
	l := s.settings.Linter
163
	s.mu.RUnlock()
164
	for i := range finds {
165
		finds[i].Severity = l.SeverityFor(finds[i].Code)
166
	}
167
}
168
169
func severityToLSP(s linter.Severity) protocol.DiagnosticSeverity {
170
	switch s {
171
	case linter.SeverityError:
172
		return protocol.DiagnosticSeverityError
173
	case linter.SeverityWarning:
174
		return protocol.DiagnosticSeverityWarning
175
	case linter.SeverityInfo:
176
		return protocol.DiagnosticSeverityInformation
177
	case linter.SeverityHint:
178
		return protocol.DiagnosticSeverityHint
179
	default:
180
		panic("impossible diagnostic severity")
181
	}
182
}