all repos

clerk @ 8dcac8b

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
lsp: workspace/symbol and textdocument/references, 1 month ago
1
package lsp
2
3
import (
4
	"context"
5
	"slices"
6
	"sort"
7
8
	"go.lsp.dev/protocol"
9
	"go.lsp.dev/uri"
10
11
	"olexsmir.xyz/clerk/internal/analyzer"
12
	"olexsmir.xyz/clerk/internal/lsp/lsputil"
13
	"olexsmir.xyz/clerk/journal/ast"
14
	"olexsmir.xyz/clerk/journal/token"
15
)
16
17
func (s *server) Definition(_ context.Context, params *protocol.DefinitionParams) (protocol.DefinitionResult, error) {
18
	state, ok := s.getDocState(params.TextDocument.URI)
19
	if !ok {
20
		return nil, nil
21
	}
22
23
	an := s.analysisFor(params.TextDocument.URI)
24
	cursor := state.lineIdx.Offset(int(params.Position.Line), int(params.Position.Character))
25
	return findDefinitionUnderCursor(an, params.TextDocument.URI.Path(), state.text, cursor), nil
26
}
27
28
func findDefinitionUnderCursor(an *analyzer.Analysis, docPath, content string, cursor int) protocol.LocationSlice {
29
	ref := findSymbolUnderCursor(an, docPath, content, cursor)
30
	if ref == nil {
31
		return nil
32
	}
33
	loc := resolveSymbol(ref, an)
34
	if loc == nil {
35
		return nil
36
	}
37
	return protocol.LocationSlice{*loc}
38
}
39
40
func resolveSymbol(ref *symbolRef, an *analyzer.Analysis) *protocol.Location {
41
	switch ref.kind {
42
	case symbolAccount:
43
		return findAccountDefinition(an, ref.name)
44
	case symbolCommodity:
45
		return findCommodityDefinition(an, ref.name)
46
	case symbolPayee:
47
		return findPayeeDefinition(an, ref.name)
48
	}
49
	return nil
50
}
51
52
func findAccountDefinition(an *analyzer.Analysis, name string) *protocol.Location {
53
	if canon, ok := an.AccountAliases[name]; ok {
54
		name = canon
55
	}
56
	info := an.Accounts[name]
57
	if info == nil {
58
		return nil
59
	}
60
	if len(info.Directives) > 0 {
61
		return locationForDirective(an, info.Directives[0], info.Directives[0].Account.Span)
62
	}
63
	if len(info.Usages) > 0 {
64
		u := info.Usages[0]
65
		return locationFor(an, u.FileIndex, u.Posting.Account.Span)
66
	}
67
	return nil
68
}
69
70
func findCommodityDefinition(an *analyzer.Analysis, symbol string) *protocol.Location {
71
	info := an.Commodities[symbol]
72
	if info == nil {
73
		return nil
74
	}
75
	if len(info.Directives) > 0 {
76
		return locationForDirective(an, info.Directives[0], info.Directives[0].CommoditySpan)
77
	}
78
	if len(info.Usages) > 0 {
79
		u := info.Usages[0]
80
		return locationFor(an, u.FileIndex, u.Amount.CommoditySpan)
81
	}
82
	return nil
83
}
84
85
func findPayeeDefinition(an *analyzer.Analysis, name string) *protocol.Location {
86
	info := an.Payees[name]
87
	if info == nil {
88
		return nil
89
	}
90
	if len(info.Directives) > 0 {
91
		d := info.Directives[0]
92
		if d.Name == nil {
93
			return nil
94
		}
95
		return locationForDirective(an, d, d.Name.Span)
96
	}
97
	if len(info.Usage) > 0 {
98
		u := info.Usage[0]
99
		return locationFor(an, u.FileIndex, u.Payee.Span)
100
	}
101
	return nil
102
}
103
104
func findTagDefinition(an *analyzer.Analysis, key string) *protocol.Location {
105
	info := an.Tags[key]
106
	if info == nil {
107
		return nil
108
	}
109
	if len(info.Directives) > 0 {
110
		d := info.Directives[0]
111
		fileIdx := fileIndexForEntry(an, d)
112
		if fileIdx < 0 {
113
			return nil
114
		}
115
		if span, ok := tagDirectiveSpan(string(an.Files[fileIdx].Src), d); ok {
116
			return locationFor(an, fileIdx, span)
117
		}
118
	}
119
	if len(info.Usage) > 0 {
120
		u := info.Usage[0]
121
		span := tagKeySpan(string(an.Files[u.FileIndex].Src), u.Tag)
122
		return locationFor(an, u.FileIndex, span)
123
	}
124
	return nil
125
}
126
127
func fileIndexForEntry(a *analyzer.Analysis, d ast.Entry) int {
128
	for i, pf := range a.Files {
129
		if slices.Contains(pf.Ast.Entries, d) {
130
			return i
131
		}
132
	}
133
	return -1
134
}
135
136
func locationForDirective(a *analyzer.Analysis, d ast.Entry, span token.Span) *protocol.Location {
137
	fileIdx := fileIndexForEntry(a, d)
138
	if fileIdx < 0 {
139
		return nil
140
	}
141
	return locationFor(a, fileIdx, span)
142
}
143
144
func locationFor(a *analyzer.Analysis, fileIdx int, span token.Span) *protocol.Location {
145
	pf := a.Files[fileIdx]
146
	return &protocol.Location{
147
		URI:   uri.File(pf.Path),
148
		Range: spanRangeFromSrc(pf.Src, span),
149
	}
150
}
151
152
// spanRangeFromSrc converts a span to a protocol range. Parsed spans carry
153
// 1-based Line/Col and are converted directly; spans whose end runs into
154
// trailing whitespace or uses the next-line-start convention (Col == 0) get a
155
// one-line scan back from the end offset.
156
func spanRangeFromSrc(src []byte, span token.Span) protocol.Range {
157
	if span.Start.Line == 0 || span.End.Line == 0 {
158
		// spans built from offsets without Line/Col: full line index
159
		return lsputil.NewLineIndex(string(src)).SpanRange(span)
160
	}
161
	start := protocol.Position{Line: uint32(span.Start.Line - 1), Character: uint32(span.Start.Col - 1)}
162
	end := span.End.Offset
163
	if span.End.Col > 0 && end > span.Start.Offset && !isSpanSpace(src[end-1]) {
164
		// the span's stored end position matches its offset
165
		return protocol.Range{Start: start, End: protocol.Position{Line: uint32(span.End.Line - 1), Character: uint32(span.End.Col - 1)}}
166
	}
167
	// Trim trailing whitespace back from the end offset; both scans are
168
	// bounded by the one line the span ends on.
169
	line := span.End.Line - 1 // 1-based line holding the end
170
	for end > span.Start.Offset && isSpanSpace(src[end-1]) {
171
		if src[end-1] == '\n' {
172
			line--
173
		}
174
		end--
175
	}
176
	lineStart := end
177
	for lineStart > 0 && src[lineStart-1] != '\n' {
178
		lineStart--
179
	}
180
	return protocol.Range{
181
		Start: start,
182
		End: protocol.Position{
183
			Line:      uint32(line - 1),
184
			Character: uint32(lsputil.Utf16ColBytes(src[lineStart:end])),
185
		},
186
	}
187
}
188
189
func isSpanSpace(b byte) bool {
190
	switch b {
191
	case ' ', '\t', '\r', '\n':
192
		return true
193
	}
194
	return false
195
}
196
197
func spanContains(content string, span token.Span, offset int) bool {
198
	if span.End.Offset <= span.Start.Offset {
199
		return false
200
	}
201
	end := spanEndClamped(content, span.End.Offset)
202
	return span.Start.Offset <= offset && offset <= end
203
}
204
205
// entryAt returns the entry whose start offset is at or before cursor, the
206
// only entry whose tokens can contain it. Entries are stored in file order,
207
// so a binary search replaces a linear scan for late-cursor requests.
208
func entryAt(entries []ast.Entry, cursor int) ast.Entry {
209
	idx := sort.Search(len(entries), func(i int) bool { return entryStart(entries[i]) > cursor }) - 1
210
	if idx < 0 {
211
		return nil
212
	}
213
	return entries[idx]
214
}
215
216
func entryStart(e ast.Entry) int {
217
	switch e := e.(type) {
218
	case *ast.BlankLine:
219
		return e.Span.Start.Offset
220
	case *ast.Transaction:
221
		return e.Span.Start.Offset
222
	case *ast.PeriodicTransaction:
223
		return e.Span.Start.Offset
224
	case *ast.AutomatedTransaction:
225
		return e.Span.Start.Offset
226
	case *ast.Comment:
227
		return e.Span.Start.Offset
228
	case *ast.AccountDirective:
229
		return e.Span.Start.Offset
230
	case *ast.CommodityDirective:
231
		return e.Span.Start.Offset
232
	case *ast.PayeeDirective:
233
		return e.Span.Start.Offset
234
	case *ast.TagDirective:
235
		return e.Span.Start.Offset
236
	case *ast.IncludeDirective:
237
		return e.Span.Start.Offset
238
	case *ast.AliasDirective:
239
		return e.Span.Start.Offset
240
	case *ast.YearDirective:
241
		return e.Span.Start.Offset
242
	case *ast.DecimalMarkDirective:
243
		return e.Span.Start.Offset
244
	case *ast.DefaultCommodityDirective:
245
		return e.Span.Start.Offset
246
	case *ast.MarketPriceDirective:
247
		return e.Span.Start.Offset
248
	case *ast.ConversionDirective:
249
		return e.Span.Start.Offset
250
	case *ast.ApplyDirective:
251
		return e.Span.Start.Offset
252
	case *ast.EndDirective:
253
		return e.Span.Start.Offset
254
	case *ast.CommentBlockDirective:
255
		return e.Span.Start.Offset
256
	case *ast.IgnoredDirective:
257
		return e.Span.Start.Offset
258
	}
259
	return 0
260
}
261
262
func spanEndClamped(content string, end int) int {
263
	for end > 0 {
264
		switch content[end-1] {
265
		case ' ', '\t', '\r', '\n':
266
			end--
267
		default:
268
			return end
269
		}
270
	}
271
	return end
272
}