all repos

clerk @ 2f348cb24ce6a19e716dc0bbedb7add6a250072f

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
lsp: list transactions in workspace/symbol, 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
// spanRangeFromSrc converts a span to a protocol range. Parsed spans carry
172
// 1-based Line/Col and are converted directly; spans whose end runs into
173
// trailing whitespace or uses the next-line-start convention (Col == 0) get a
174
// one-line scan back from the end offset.
175
func spanRangeFromSrc(src []byte, span token.Span) protocol.Range {
176
	if span.Start.Line == 0 || span.End.Line == 0 {
177
		// spans built from offsets without Line/Col: full line index
178
		return lsputil.NewLineIndex(string(src)).SpanRange(span)
179
	}
180
	start := protocol.Position{Line: uint32(span.Start.Line - 1), Character: uint32(span.Start.Col - 1)}
181
	end := span.End.Offset
182
	if span.End.Col > 0 && end > span.Start.Offset && !isSpanSpace(src[end-1]) {
183
		// the span's stored end position matches its offset
184
		return protocol.Range{Start: start, End: protocol.Position{Line: uint32(span.End.Line - 1), Character: uint32(span.End.Col - 1)}}
185
	}
186
	// Trim trailing whitespace back from the end offset; both scans are
187
	// bounded by the one line the span ends on.
188
	line := span.End.Line - 1 // 1-based line holding the end
189
	for end > span.Start.Offset && isSpanSpace(src[end-1]) {
190
		if src[end-1] == '\n' {
191
			line--
192
		}
193
		end--
194
	}
195
	lineStart := end
196
	for lineStart > 0 && src[lineStart-1] != '\n' {
197
		lineStart--
198
	}
199
	return protocol.Range{
200
		Start: start,
201
		End: protocol.Position{
202
			Line:      uint32(line - 1),
203
			Character: uint32(lsputil.Utf16ColBytes(src[lineStart:end])),
204
		},
205
	}
206
}
207
208
func isSpanSpace(b byte) bool {
209
	switch b {
210
	case ' ', '\t', '\r', '\n':
211
		return true
212
	}
213
	return false
214
}
215
216
func spanContains(content string, span token.Span, offset int) bool {
217
	if span.End.Offset <= span.Start.Offset {
218
		return false
219
	}
220
	end := spanEndClamped(content, span.End.Offset)
221
	return span.Start.Offset <= offset && offset <= end
222
}
223
224
// entryAt returns the entry whose start offset is at or before cursor, the
225
// only entry whose tokens can contain it. Entries are stored in file order,
226
// so a binary search replaces a linear scan for late-cursor requests.
227
func entryAt(entries []ast.Entry, cursor int) ast.Entry {
228
	idx := sort.Search(len(entries), func(i int) bool { return entryStart(entries[i]) > cursor }) - 1
229
	if idx < 0 {
230
		return nil
231
	}
232
	return entries[idx]
233
}
234
235
func entryStart(e ast.Entry) int {
236
	switch e := e.(type) {
237
	case *ast.BlankLine:
238
		return e.Span.Start.Offset
239
	case *ast.Transaction:
240
		return e.Span.Start.Offset
241
	case *ast.PeriodicTransaction:
242
		return e.Span.Start.Offset
243
	case *ast.AutomatedTransaction:
244
		return e.Span.Start.Offset
245
	case *ast.Comment:
246
		return e.Span.Start.Offset
247
	case *ast.AccountDirective:
248
		return e.Span.Start.Offset
249
	case *ast.CommodityDirective:
250
		return e.Span.Start.Offset
251
	case *ast.PayeeDirective:
252
		return e.Span.Start.Offset
253
	case *ast.TagDirective:
254
		return e.Span.Start.Offset
255
	case *ast.IncludeDirective:
256
		return e.Span.Start.Offset
257
	case *ast.AliasDirective:
258
		return e.Span.Start.Offset
259
	case *ast.YearDirective:
260
		return e.Span.Start.Offset
261
	case *ast.DecimalMarkDirective:
262
		return e.Span.Start.Offset
263
	case *ast.DefaultCommodityDirective:
264
		return e.Span.Start.Offset
265
	case *ast.MarketPriceDirective:
266
		return e.Span.Start.Offset
267
	case *ast.ConversionDirective:
268
		return e.Span.Start.Offset
269
	case *ast.ApplyDirective:
270
		return e.Span.Start.Offset
271
	case *ast.EndDirective:
272
		return e.Span.Start.Offset
273
	case *ast.CommentBlockDirective:
274
		return e.Span.Start.Offset
275
	case *ast.IgnoredDirective:
276
		return e.Span.Start.Offset
277
	}
278
	return 0
279
}
280
281
func spanEndClamped(content string, end int) int {
282
	for end > 0 {
283
		switch content[end-1] {
284
		case ' ', '\t', '\r', '\n':
285
			end--
286
		default:
287
			return end
288
		}
289
	}
290
	return end
291
}