all repos

clerk @ e12aa47

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
refactor: lsp: better naming of functions; move some stuff around, 29 days ago
1
package lsp
2
3
import (
4
	"context"
5
	"path/filepath"
6
	"slices"
7
	"sort"
8
	"strings"
9
10
	"go.lsp.dev/protocol"
11
	"go.lsp.dev/uri"
12
13
	"olexsmir.xyz/clerk/internal/analyzer"
14
	"olexsmir.xyz/clerk/internal/lsp/lsputil"
15
	"olexsmir.xyz/clerk/journal/ast"
16
	"olexsmir.xyz/clerk/journal/token"
17
)
18
19
func (s *server) Definition(_ context.Context, params *protocol.DefinitionParams) (protocol.DefinitionResult, error) {
20
	state, ok := s.getDocState(params.TextDocument.URI)
21
	if !ok {
22
		return nil, nil
23
	}
24
25
	an := s.analysisFor(params.TextDocument.URI)
26
	cursor := state.lineIdx.Offset(int(params.Position.Line), int(params.Position.Character))
27
	return findDefinitionUnderCursor(an, params.TextDocument.URI.Path(), state.text, cursor), nil
28
}
29
30
func findDefinitionUnderCursor(an *analyzer.Analysis, docPath, content string, cursor int) protocol.LocationSlice {
31
	ref := findSymbolUnderCursor(an, docPath, content, cursor)
32
	if ref == nil {
33
		return nil
34
	}
35
	return resolveSymbol(ref, an, docPath)
36
}
37
38
func resolveSymbol(ref *symbolRef, an *analyzer.Analysis, docPath string) protocol.LocationSlice {
39
	var loc *protocol.Location
40
	switch ref.kind {
41
	case symbolAccount:
42
		loc = findAccountDefinition(an, ref.name)
43
	case symbolCommodity:
44
		loc = findCommodityDefinition(an, ref.name)
45
	case symbolPayee:
46
		loc = findPayeeDefinition(an, ref.name)
47
	case symbolInclude:
48
		return includeTargets(an, docPath, ref.name)
49
	default:
50
		return nil
51
	}
52
	if loc == nil {
53
		return nil
54
	}
55
	return protocol.LocationSlice{*loc}
56
}
57
58
func findAccountDefinition(an *analyzer.Analysis, name string) *protocol.Location {
59
	if canon, ok := an.AccountAliases[name]; ok {
60
		name = canon
61
	}
62
	info := an.Accounts[name]
63
	if info == nil {
64
		return nil
65
	}
66
	if len(info.Directives) > 0 {
67
		return locationForDirective(an, info.Directives[0], info.Directives[0].Account.Span)
68
	}
69
	if len(info.Usages) > 0 {
70
		u := info.Usages[0]
71
		return locationFor(an, u.FileIndex, u.Posting.Account.Span)
72
	}
73
	return nil
74
}
75
76
func findTransactionDefinition(an *analyzer.Analysis, e ast.Entry) *protocol.Location {
77
	fileIdx := fileIndexForEntry(an, e)
78
	if fileIdx < 0 {
79
		return nil
80
	}
81
	var span token.Span
82
	switch e := e.(type) {
83
	case *ast.Transaction:
84
		span = e.Date.Span
85
	case *ast.PeriodicTransaction:
86
		span = e.Period.Span
87
	case *ast.AutomatedTransaction:
88
		span = e.ExprSpan
89
	default:
90
		return nil
91
	}
92
	return locationFor(an, fileIdx, span)
93
}
94
95
func findCommodityDefinition(an *analyzer.Analysis, symbol string) *protocol.Location {
96
	info := an.Commodities[symbol]
97
	if info == nil {
98
		return nil
99
	}
100
	if len(info.Directives) > 0 {
101
		return locationForDirective(an, info.Directives[0], info.Directives[0].CommoditySpan)
102
	}
103
	if len(info.Usages) > 0 {
104
		u := info.Usages[0]
105
		return locationFor(an, u.FileIndex, u.Amount.CommoditySpan)
106
	}
107
	return nil
108
}
109
110
func findPayeeDefinition(an *analyzer.Analysis, name string) *protocol.Location {
111
	info := an.Payees[name]
112
	if info == nil {
113
		return nil
114
	}
115
	if len(info.Directives) > 0 {
116
		d := info.Directives[0]
117
		if d.Name == "" {
118
			return nil
119
		}
120
		return locationForDirective(an, d, d.NameSpan)
121
	}
122
	if len(info.Usage) > 0 {
123
		u := info.Usage[0]
124
		return locationFor(an, u.FileIndex, u.Span)
125
	}
126
	return nil
127
}
128
129
func findTagDefinition(an *analyzer.Analysis, key string) *protocol.Location {
130
	info := an.Tags[key]
131
	if info == nil {
132
		return nil
133
	}
134
	if len(info.Directives) > 0 {
135
		d := info.Directives[0]
136
		fileIdx := fileIndexForEntry(an, d)
137
		if fileIdx < 0 {
138
			return nil
139
		}
140
		if span, ok := tagDirectiveSpan(string(an.Files[fileIdx].Src), d); ok {
141
			return locationFor(an, fileIdx, span)
142
		}
143
	}
144
	if len(info.Usage) > 0 {
145
		u := info.Usage[0]
146
		span := tagKeySpan(string(an.Files[u.FileIndex].Src), u.Tag)
147
		return locationFor(an, u.FileIndex, span)
148
	}
149
	return nil
150
}
151
152
func definitionLocation(an *analyzer.Analysis, s scoredSymbol) *protocol.Location {
153
	switch s.kind {
154
	case symbolAccount:
155
		return findAccountDefinition(an, s.name)
156
	case symbolTransaction:
157
		return findTransactionDefinition(an, s.tnxEntry)
158
	case symbolCommodity:
159
		return findCommodityDefinition(an, s.name)
160
	case symbolPayee:
161
		return findPayeeDefinition(an, s.name)
162
	case symbolTag:
163
		return findTagDefinition(an, s.name)
164
	}
165
	return nil
166
}
167
168
func includeTargets(an *analyzer.Analysis, docPath, pattern string) protocol.LocationSlice {
169
	target := filepath.Join(filepath.Dir(docPath), pattern)
170
	glob := strings.ContainsAny(target, "*?[")
171
	found := make(map[string]bool)
172
	var locs protocol.LocationSlice
173
	for _, occ := range an.Files {
174
		if found[occ.Path] {
175
			continue
176
		}
177
		if glob {
178
			if m, _ := filepath.Match(target, occ.Path); !m {
179
				continue
180
			}
181
		} else if occ.Path != target {
182
			continue
183
		}
184
		found[occ.Path] = true
185
		locs = append(locs, protocol.Location{URI: uri.File(occ.Path)})
186
	}
187
	return locs
188
}
189
190
func fileIndexForEntry(a *analyzer.Analysis, d ast.Entry) int {
191
	for i, pf := range a.Files {
192
		if slices.Contains(pf.Ast.Entries, d) {
193
			return i
194
		}
195
	}
196
	return -1
197
}
198
199
func locationForDirective(a *analyzer.Analysis, d ast.Entry, span token.Span) *protocol.Location {
200
	fileIdx := fileIndexForEntry(a, d)
201
	if fileIdx < 0 {
202
		return nil
203
	}
204
	return locationFor(a, fileIdx, span)
205
}
206
207
func locationFor(a *analyzer.Analysis, fileIdx int, span token.Span) *protocol.Location {
208
	pf := a.Files[fileIdx]
209
	return &protocol.Location{
210
		URI:   uri.File(pf.Path),
211
		Range: spanRangeFromSrc(pf.Src, span),
212
	}
213
}
214
215
func spanRangeFromSrc(src []byte, span token.Span) protocol.Range {
216
	if span.Start.Line == 0 || span.End.Line == 0 {
217
		return lsputil.NewLineIndex(string(src)).SpanRange(span)
218
	}
219
	start := protocol.Position{Line: uint32(span.Start.Line - 1), Character: uint32(span.Start.Col - 1)}
220
	end := span.End.Offset
221
	if span.End.Col > 0 && end > span.Start.Offset && !isSpanSpace(src[end-1]) {
222
		// the span's stored end position matches its offset
223
		return protocol.Range{Start: start, End: protocol.Position{Line: uint32(span.End.Line - 1), Character: uint32(span.End.Col - 1)}}
224
	}
225
	// trim trailing whitespace back from the end offset; both scans are
226
	// bounded by the one line the span ends on.
227
	line := span.End.Line - 1 // 0-based line of the end, decremented per newline trimmed
228
	if end < len(src) && src[end] == '\n' {
229
		line-- // end sits on a newline, which the parser records as the next line's start
230
	}
231
	for end > span.Start.Offset && isSpanSpace(src[end-1]) {
232
		if src[end-1] == '\n' {
233
			line--
234
		}
235
		end--
236
	}
237
	lineStart := end
238
	for lineStart > 0 && src[lineStart-1] != '\n' {
239
		lineStart--
240
	}
241
	return protocol.Range{
242
		Start: start,
243
		End: protocol.Position{
244
			Line:      uint32(line),
245
			Character: uint32(lsputil.Utf16ColBytes(src[lineStart:end])),
246
		},
247
	}
248
}
249
250
func isSpanSpace(b byte) bool {
251
	switch b {
252
	case ' ', '\t', '\r', '\n':
253
		return true
254
	}
255
	return false
256
}
257
258
func spanContains(content string, span token.Span, offset int) bool {
259
	if span.End.Offset <= span.Start.Offset {
260
		return false
261
	}
262
	end := spanEndClamped(content, span.End.Offset)
263
	return span.Start.Offset <= offset && offset <= end
264
}
265
266
func entryAt(entries []ast.Entry, cursor int) ast.Entry {
267
	idx := sort.Search(len(entries), func(i int) bool { return entrySpan(entries[i]).Start.Offset > cursor }) - 1
268
	if idx < 0 {
269
		return nil
270
	}
271
	return entries[idx]
272
}
273
274
// entrySpan returns the span covering an entry.
275
func entrySpan(e ast.Entry) token.Span {
276
	switch e := e.(type) {
277
	case *ast.BlankLine:
278
		return e.Span
279
	case *ast.Transaction:
280
		return e.Span
281
	case *ast.PeriodicTransaction:
282
		return e.Span
283
	case *ast.AutomatedTransaction:
284
		return e.Span
285
	case *ast.Comment:
286
		return e.Span
287
	case *ast.AccountDirective:
288
		return e.Span
289
	case *ast.CommodityDirective:
290
		return e.Span
291
	case *ast.PayeeDirective:
292
		return e.Span
293
	case *ast.TagDirective:
294
		return e.Span
295
	case *ast.IncludeDirective:
296
		return e.Span
297
	case *ast.AliasDirective:
298
		return e.Span
299
	case *ast.YearDirective:
300
		return e.Span
301
	case *ast.DecimalMarkDirective:
302
		return e.Span
303
	case *ast.DefaultCommodityDirective:
304
		return e.Span
305
	case *ast.MarketPriceDirective:
306
		return e.Span
307
	case *ast.ConversionDirective:
308
		return e.Span
309
	case *ast.ApplyDirective:
310
		return e.Span
311
	case *ast.EndDirective:
312
		return e.Span
313
	case *ast.CommentBlockDirective:
314
		return e.Span
315
	case *ast.IgnoredDirective:
316
		return e.Span
317
	}
318
	return token.Span{}
319
}
320
321
func spanEndClamped(content string, end int) int {
322
	for end > 0 {
323
		switch content[end-1] {
324
		case ' ', '\t', '\r', '\n':
325
			end--
326
		default:
327
			return end
328
		}
329
	}
330
	return end
331
}