all repos

clerk @ 2ca3278ede09b510e058a42bcca8f9e4ffcc7bfa

missing tooling for ledger/hledger

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

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