all repos

clerk @ 6573acb

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
ast: inline leaf value+span wrapper structs, 1 month 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 includeTargets(an *analyzer.Analysis, docPath, pattern string) protocol.LocationSlice {
153
	target := filepath.Join(filepath.Dir(docPath), pattern)
154
	glob := strings.ContainsAny(target, "*?[")
155
	found := make(map[string]bool)
156
	var locs protocol.LocationSlice
157
	for _, occ := range an.Files {
158
		if found[occ.Path] {
159
			continue
160
		}
161
		if glob {
162
			if m, _ := filepath.Match(target, occ.Path); !m {
163
				continue
164
			}
165
		} else if occ.Path != target {
166
			continue
167
		}
168
		found[occ.Path] = true
169
		locs = append(locs, protocol.Location{URI: uri.File(occ.Path)})
170
	}
171
	return locs
172
}
173
174
func fileIndexForEntry(a *analyzer.Analysis, d ast.Entry) int {
175
	for i, pf := range a.Files {
176
		if slices.Contains(pf.Ast.Entries, d) {
177
			return i
178
		}
179
	}
180
	return -1
181
}
182
183
func locationForDirective(a *analyzer.Analysis, d ast.Entry, span token.Span) *protocol.Location {
184
	fileIdx := fileIndexForEntry(a, d)
185
	if fileIdx < 0 {
186
		return nil
187
	}
188
	return locationFor(a, fileIdx, span)
189
}
190
191
func locationFor(a *analyzer.Analysis, fileIdx int, span token.Span) *protocol.Location {
192
	pf := a.Files[fileIdx]
193
	return &protocol.Location{
194
		URI:   uri.File(pf.Path),
195
		Range: spanRangeFromSrc(pf.Src, span),
196
	}
197
}
198
199
func spanRangeFromSrc(src []byte, span token.Span) protocol.Range {
200
	if span.Start.Line == 0 || span.End.Line == 0 {
201
		return lsputil.NewLineIndex(string(src)).SpanRange(span)
202
	}
203
	start := protocol.Position{Line: uint32(span.Start.Line - 1), Character: uint32(span.Start.Col - 1)}
204
	end := span.End.Offset
205
	if span.End.Col > 0 && end > span.Start.Offset && !isSpanSpace(src[end-1]) {
206
		// the span's stored end position matches its offset
207
		return protocol.Range{Start: start, End: protocol.Position{Line: uint32(span.End.Line - 1), Character: uint32(span.End.Col - 1)}}
208
	}
209
	// trim trailing whitespace back from the end offset; both scans are
210
	// bounded by the one line the span ends on.
211
	line := span.End.Line - 1 // 0-based line of the end, decremented per newline trimmed
212
	if end < len(src) && src[end] == '\n' {
213
		line-- // end sits on a newline, which the parser records as the next line's start
214
	}
215
	for end > span.Start.Offset && isSpanSpace(src[end-1]) {
216
		if src[end-1] == '\n' {
217
			line--
218
		}
219
		end--
220
	}
221
	lineStart := end
222
	for lineStart > 0 && src[lineStart-1] != '\n' {
223
		lineStart--
224
	}
225
	return protocol.Range{
226
		Start: start,
227
		End: protocol.Position{
228
			Line:      uint32(line),
229
			Character: uint32(lsputil.Utf16ColBytes(src[lineStart:end])),
230
		},
231
	}
232
}
233
234
func isSpanSpace(b byte) bool {
235
	switch b {
236
	case ' ', '\t', '\r', '\n':
237
		return true
238
	}
239
	return false
240
}
241
242
func spanContains(content string, span token.Span, offset int) bool {
243
	if span.End.Offset <= span.Start.Offset {
244
		return false
245
	}
246
	end := spanEndClamped(content, span.End.Offset)
247
	return span.Start.Offset <= offset && offset <= end
248
}
249
250
func entryAt(entries []ast.Entry, cursor int) ast.Entry {
251
	idx := sort.Search(len(entries), func(i int) bool { return entrySpan(entries[i]).Start.Offset > cursor }) - 1
252
	if idx < 0 {
253
		return nil
254
	}
255
	return entries[idx]
256
}
257
258
// entrySpan returns the span covering an entry.
259
func entrySpan(e ast.Entry) token.Span {
260
	switch e := e.(type) {
261
	case *ast.BlankLine:
262
		return e.Span
263
	case *ast.Transaction:
264
		return e.Span
265
	case *ast.PeriodicTransaction:
266
		return e.Span
267
	case *ast.AutomatedTransaction:
268
		return e.Span
269
	case *ast.Comment:
270
		return e.Span
271
	case *ast.AccountDirective:
272
		return e.Span
273
	case *ast.CommodityDirective:
274
		return e.Span
275
	case *ast.PayeeDirective:
276
		return e.Span
277
	case *ast.TagDirective:
278
		return e.Span
279
	case *ast.IncludeDirective:
280
		return e.Span
281
	case *ast.AliasDirective:
282
		return e.Span
283
	case *ast.YearDirective:
284
		return e.Span
285
	case *ast.DecimalMarkDirective:
286
		return e.Span
287
	case *ast.DefaultCommodityDirective:
288
		return e.Span
289
	case *ast.MarketPriceDirective:
290
		return e.Span
291
	case *ast.ConversionDirective:
292
		return e.Span
293
	case *ast.ApplyDirective:
294
		return e.Span
295
	case *ast.EndDirective:
296
		return e.Span
297
	case *ast.CommentBlockDirective:
298
		return e.Span
299
	case *ast.IgnoredDirective:
300
		return e.Span
301
	}
302
	return token.Span{}
303
}
304
305
func spanEndClamped(content string, end int) int {
306
	for end > 0 {
307
		switch content[end-1] {
308
		case ' ', '\t', '\r', '\n':
309
			end--
310
		default:
311
			return end
312
		}
313
	}
314
	return end
315
}