all repos

clerk @ 892180582dccc852236879a1acc412c90cac01ad

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
perf: lsp: dedupe and group diagnostics without string keys..., 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
	if ctx.Err() != nil {
40
		return
41
	}
42
43
	a := s.buildAnalysis()
44
	if a == nil {
45
		s.log.Debug("no files in workspace")
46
		return
47
	}
48
49
	activePaths := make(map[string]bool, len(a.Files))
50
	for _, pf := range a.Files {
51
		activePaths[pf.Path] = true
52
	}
53
54
	if ctx.Err() != nil {
55
		return
56
	}
57
58
	finds := dedupFinds(s.linter.Run(a))
59
60
	s.mu.Lock()
61
	s.current = a
62
	s.mu.Unlock()
63
64
	if ctx.Err() != nil {
65
		return
66
	}
67
68
	diagsByFile := s.groupFindsByFile(finds)
69
70
	for fpath := range activePaths {
71
		if ctx.Err() != nil {
72
			return
73
		}
74
		if err := s.client.PublishDiagnostics(ctx, &protocol.PublishDiagnosticsParams{
75
			URI:         uri.File(fpath),
76
			Diagnostics: diagsByFile[fpath],
77
		}); err != nil {
78
			s.log.Warn("publish diagnostics failed", "uri", uri.File(fpath), "err", err)
79
		}
80
	}
81
82
	s.log.Debug("diagnostics published", "files", len(a.Files), "findings", len(finds))
83
}
84
85
func (s *server) groupFindsByFile(finds []linter.Find) map[string][]protocol.Diagnostic {
86
	// count per file to pre-size the diagnostic slices: append growth on ~10k
87
	// findings is the dominant allocation in the diagnostics path
88
	counts := make(map[string]int, len(finds))
89
	for _, find := range finds {
90
		if find.Span.Start.File != "" {
91
			counts[find.Span.Start.File]++
92
		}
93
	}
94
	diags := make(map[string][]protocol.Diagnostic, len(counts))
95
	for fpath, n := range counts {
96
		diags[fpath] = make([]protocol.Diagnostic, 0, n)
97
	}
98
	for _, find := range finds {
99
		file := find.Span.Start.File
100
		if file == "" {
101
			continue
102
		}
103
		diags[file] = append(diags[file], s.findToDiagnostic(find))
104
	}
105
	return diags
106
}
107
108
func (s *server) findToDiagnostic(find linter.Find) protocol.Diagnostic {
109
	return protocol.Diagnostic{
110
		Range:    spanToRange(find.Span),
111
		Severity: severityToLSP(find.Severity),
112
		Message:  protocol.String(find.Message),
113
		Source:   protocol.NewOptional(s.name),
114
		Code:     protocol.String(string(find.Code)),
115
	}
116
}
117
118
func dedupFinds(finds []linter.Find) []linter.Find {
119
	seen := make(map[findKey]bool, len(finds))
120
	dedup := make([]linter.Find, 0, len(finds))
121
	for _, f := range finds {
122
		k := findKey{f.Span.Start.File, f.Span.Start.Line, f.Span.Start.Col, f.Code}
123
		if seen[k] {
124
			continue
125
		}
126
		seen[k] = true
127
		dedup = append(dedup, f)
128
	}
129
	return dedup
130
}
131
132
// findKey identifies a find by its position and rule; a struct key avoids a
133
// per-find fmt.Sprintf.
134
type findKey struct {
135
	file      string
136
	line, col int
137
	code      linter.RuleID
138
}
139
140
func spanToRange(span token.Span) protocol.Range {
141
	return protocol.Range{
142
		Start: protocol.Position{
143
			Line:      max(0, uint32(span.Start.Line-1)),
144
			Character: max(0, uint32(span.Start.Col-1)),
145
		},
146
		End: protocol.Position{
147
			Line:      max(0, uint32(span.End.Line-1)),
148
			Character: uint32(max(0, span.End.Col-1)),
149
		},
150
	}
151
}
152
153
func severityToLSP(s linter.Severity) protocol.DiagnosticSeverity {
154
	switch s {
155
	case linter.SeverityError:
156
		return protocol.DiagnosticSeverityError
157
	case linter.SeverityWarning:
158
		return protocol.DiagnosticSeverityWarning
159
	case linter.SeverityInfo:
160
		return protocol.DiagnosticSeverityInformation
161
	case linter.SeverityHint:
162
		return protocol.DiagnosticSeverityHint
163
	default:
164
		panic("impossible diagnostic severity")
165
	}
166
}