all repos

clerk @ 7be0936

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
lsp: implement textdocument/documentSymbol, 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 findTransactionDefinition(an *analyzer.Analysis, e ast.Entry) *protocol.Location {
71
	fileIdx := fileIndexForEntry(an, e)
72
	if fileIdx < 0 {
73
		return nil
74
	}
75
	var span token.Span
76
	switch e := e.(type) {
77
	case *ast.Transaction:
78
		span = e.Date.Span
79
	case *ast.PeriodicTransaction:
80
		span = e.Period.Span
81
	case *ast.AutomatedTransaction:
82
		span = e.Expr.Span
83
	default:
84
		return nil
85
	}
86
	return locationFor(an, fileIdx, span)
87
}
88
89
func findCommodityDefinition(an *analyzer.Analysis, symbol string) *protocol.Location {
90
	info := an.Commodities[symbol]
91
	if info == nil {
92
		return nil
93
	}
94
	if len(info.Directives) > 0 {
95
		return locationForDirective(an, info.Directives[0], info.Directives[0].CommoditySpan)
96
	}
97
	if len(info.Usages) > 0 {
98
		u := info.Usages[0]
99
		return locationFor(an, u.FileIndex, u.Amount.CommoditySpan)
100
	}
101
	return nil
102
}
103
104
func findPayeeDefinition(an *analyzer.Analysis, name string) *protocol.Location {
105
	info := an.Payees[name]
106
	if info == nil {
107
		return nil
108
	}
109
	if len(info.Directives) > 0 {
110
		d := info.Directives[0]
111
		if d.Name == nil {
112
			return nil
113
		}
114
		return locationForDirective(an, d, d.Name.Span)
115
	}
116
	if len(info.Usage) > 0 {
117
		u := info.Usage[0]
118
		return locationFor(an, u.FileIndex, u.Payee.Span)
119
	}
120
	return nil
121
}
122
123
func findTagDefinition(an *analyzer.Analysis, key string) *protocol.Location {
124
	info := an.Tags[key]
125
	if info == nil {
126
		return nil
127
	}
128
	if len(info.Directives) > 0 {
129
		d := info.Directives[0]
130
		fileIdx := fileIndexForEntry(an, d)
131
		if fileIdx < 0 {
132
			return nil
133
		}
134
		if span, ok := tagDirectiveSpan(string(an.Files[fileIdx].Src), d); ok {
135
			return locationFor(an, fileIdx, span)
136
		}
137
	}
138
	if len(info.Usage) > 0 {
139
		u := info.Usage[0]
140
		span := tagKeySpan(string(an.Files[u.FileIndex].Src), u.Tag)
141
		return locationFor(an, u.FileIndex, span)
142
	}
143
	return nil
144
}
145
146
func fileIndexForEntry(a *analyzer.Analysis, d ast.Entry) int {
147
	for i, pf := range a.Files {
148
		if slices.Contains(pf.Ast.Entries, d) {
149
			return i
150
		}
151
	}
152
	return -1
153
}
154
155
func locationForDirective(a *analyzer.Analysis, d ast.Entry, span token.Span) *protocol.Location {
156
	fileIdx := fileIndexForEntry(a, d)
157
	if fileIdx < 0 {
158
		return nil
159
	}
160
	return locationFor(a, fileIdx, span)
161
}
162
163
func locationFor(a *analyzer.Analysis, fileIdx int, span token.Span) *protocol.Location {
164
	pf := a.Files[fileIdx]
165
	return &protocol.Location{
166
		URI:   uri.File(pf.Path),
167
		Range: spanRangeFromSrc(pf.Src, span),
168
	}
169
}
170
171
func spanRangeFromSrc(src []byte, span token.Span) protocol.Range {
172
	if span.Start.Line == 0 || span.End.Line == 0 {
173
		return lsputil.NewLineIndex(string(src)).SpanRange(span)
174
	}
175
	start := protocol.Position{Line: uint32(span.Start.Line - 1), Character: uint32(span.Start.Col - 1)}
176
	end := span.End.Offset
177
	if span.End.Col > 0 && end > span.Start.Offset && !isSpanSpace(src[end-1]) {
178
		// the span's stored end position matches its offset
179
		return protocol.Range{Start: start, End: protocol.Position{Line: uint32(span.End.Line - 1), Character: uint32(span.End.Col - 1)}}
180
	}
181
	// trim trailing whitespace back from the end offset; both scans are
182
	// bounded by the one line the span ends on.
183
	line := span.End.Line - 1 // 0-based line of the end, decremented per newline trimmed
184
	if end < len(src) && src[end] == '\n' {
185
		line-- // end sits on a newline, which the parser records as the next line's start
186
	}
187
	for end > span.Start.Offset && isSpanSpace(src[end-1]) {
188
		if src[end-1] == '\n' {
189
			line--
190
		}
191
		end--
192
	}
193
	lineStart := end
194
	for lineStart > 0 && src[lineStart-1] != '\n' {
195
		lineStart--
196
	}
197
	return protocol.Range{
198
		Start: start,
199
		End: protocol.Position{
200
			Line:      uint32(line),
201
			Character: uint32(lsputil.Utf16ColBytes(src[lineStart:end])),
202
		},
203
	}
204
}
205
206
func isSpanSpace(b byte) bool {
207
	switch b {
208
	case ' ', '\t', '\r', '\n':
209
		return true
210
	}
211
	return false
212
}
213
214
func spanContains(content string, span token.Span, offset int) bool {
215
	if span.End.Offset <= span.Start.Offset {
216
		return false
217
	}
218
	end := spanEndClamped(content, span.End.Offset)
219
	return span.Start.Offset <= offset && offset <= end
220
}
221
222
func entryAt(entries []ast.Entry, cursor int) ast.Entry {
223
	idx := sort.Search(len(entries), func(i int) bool { return entryStart(entries[i]) > cursor }) - 1
224
	if idx < 0 {
225
		return nil
226
	}
227
	return entries[idx]
228
}
229
230
func entryStart(e ast.Entry) int {
231
	switch e := e.(type) {
232
	case *ast.BlankLine:
233
		return e.Span.Start.Offset
234
	case *ast.Transaction:
235
		return e.Span.Start.Offset
236
	case *ast.PeriodicTransaction:
237
		return e.Span.Start.Offset
238
	case *ast.AutomatedTransaction:
239
		return e.Span.Start.Offset
240
	case *ast.Comment:
241
		return e.Span.Start.Offset
242
	case *ast.AccountDirective:
243
		return e.Span.Start.Offset
244
	case *ast.CommodityDirective:
245
		return e.Span.Start.Offset
246
	case *ast.PayeeDirective:
247
		return e.Span.Start.Offset
248
	case *ast.TagDirective:
249
		return e.Span.Start.Offset
250
	case *ast.IncludeDirective:
251
		return e.Span.Start.Offset
252
	case *ast.AliasDirective:
253
		return e.Span.Start.Offset
254
	case *ast.YearDirective:
255
		return e.Span.Start.Offset
256
	case *ast.DecimalMarkDirective:
257
		return e.Span.Start.Offset
258
	case *ast.DefaultCommodityDirective:
259
		return e.Span.Start.Offset
260
	case *ast.MarketPriceDirective:
261
		return e.Span.Start.Offset
262
	case *ast.ConversionDirective:
263
		return e.Span.Start.Offset
264
	case *ast.ApplyDirective:
265
		return e.Span.Start.Offset
266
	case *ast.EndDirective:
267
		return e.Span.Start.Offset
268
	case *ast.CommentBlockDirective:
269
		return e.Span.Start.Offset
270
	case *ast.IgnoredDirective:
271
		return e.Span.Start.Offset
272
	}
273
	return 0
274
}
275
276
func spanEndClamped(content string, end int) int {
277
	for end > 0 {
278
		switch content[end-1] {
279
		case ' ', '\t', '\r', '\n':
280
			end--
281
		default:
282
			return end
283
		}
284
	}
285
	return end
286
}