all repos

clerk @ d3fcad3049dc9b55eb775f22fdfb98d83101cd26

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
lsp: comply with lsp3.18 lifecycle, 29 days 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/internal/lsp/lsputil"
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
	lines := make(map[string]*lsputil.LineIndex)
65
	paths := make(map[string]bool)
66
	for _, u := range dirtyURIs {
67
		a := s.analysisFor(u)
68
		if a == nil {
69
			continue
70
		}
71
		var idx map[string]*lsputil.LineIndex
72
		if st, ok := s.getDocState(u); ok && st.cache != nil {
73
			idx = st.cache.fileIdx
74
		} else {
75
			idx = buildFileIdx(a)
76
		}
77
		for _, pf := range a.Files {
78
			lines[pf.Path] = idx[pf.Path]
79
			paths[pf.Path] = true
80
		}
81
		finds = append(finds, lint.Run(a)...)
82
	}
83
84
	if ctx.Err() != nil {
85
		return
86
	}
87
88
	s.assignSeverities(finds)
89
	diagsByFile := s.groupFindsByFile(dedupFinds(finds), lines)
90
	for fpath := range paths {
91
		if err := s.client.PublishDiagnostics(ctx, &protocol.PublishDiagnosticsParams{
92
			URI:         uri.File(fpath),
93
			Diagnostics: diagsByFile[fpath],
94
		}); err != nil {
95
			s.log.Warn("publish diagnostics failed", "uri", uri.File(fpath), "err", err)
96
		}
97
	}
98
99
	s.log.Debug("diagnostics published", "files", len(paths), "findings", len(finds))
100
}
101
102
func (s *server) groupFindsByFile(finds []linter.Find, lines map[string]*lsputil.LineIndex) map[string][]protocol.Diagnostic {
103
	// count per file to pre-size the diagnostic slices: append growth on ~10k
104
	// findings is the dominant allocation in the diagnostics path
105
	counts := make(map[string]int, len(finds))
106
	for _, find := range finds {
107
		if find.Span.File != "" {
108
			counts[find.Span.File]++
109
		}
110
	}
111
	diags := make(map[string][]protocol.Diagnostic, len(counts))
112
	for fpath, n := range counts {
113
		diags[fpath] = make([]protocol.Diagnostic, 0, n)
114
	}
115
	for _, find := range finds {
116
		file := find.Span.File
117
		if file == "" {
118
			continue
119
		}
120
		diags[file] = append(diags[file], s.findToDiagnostic(find, lines[file]))
121
	}
122
	return diags
123
}
124
125
func (s *server) findToDiagnostic(find linter.Find, lines *lsputil.LineIndex) protocol.Diagnostic {
126
	return protocol.Diagnostic{
127
		Range:    lines.SpanRange(find.Span),
128
		Severity: severityToLSP(find.Severity),
129
		Message:  protocol.String(find.Message),
130
		Source:   protocol.NewOptional(s.name),
131
		Code:     protocol.String(string(find.Code)),
132
	}
133
}
134
135
func dedupFinds(finds []linter.Find) []linter.Find {
136
	seen := make(map[findKey]bool, len(finds))
137
	dedup := make([]linter.Find, 0, len(finds))
138
	for _, f := range finds {
139
		k := findKey{f.Span.File, f.Span.Start.Line, f.Span.Start.Col, f.Code}
140
		if seen[k] {
141
			continue
142
		}
143
		seen[k] = true
144
		dedup = append(dedup, f)
145
	}
146
	return dedup
147
}
148
149
type findKey struct {
150
	file      string
151
	line, col int
152
	code      linter.RuleID
153
}
154
155
func (s *server) assignSeverities(finds []linter.Find) {
156
	s.mu.RLock()
157
	l := s.settings.Linter
158
	s.mu.RUnlock()
159
	for i := range finds {
160
		finds[i].Severity = l.SeverityFor(finds[i].Code)
161
	}
162
}
163
164
func severityToLSP(s linter.Severity) protocol.DiagnosticSeverity {
165
	switch s {
166
	case linter.SeverityError:
167
		return protocol.DiagnosticSeverityError
168
	case linter.SeverityWarning:
169
		return protocol.DiagnosticSeverityWarning
170
	case linter.SeverityInfo:
171
		return protocol.DiagnosticSeverityInformation
172
	case linter.SeverityHint:
173
		return protocol.DiagnosticSeverityHint
174
	default:
175
		panic("impossible diagnostic severity")
176
	}
177
}