all repos

clerk @ 892180582dccc852236879a1acc412c90cac01ad

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
perf: lsp: resolve positions and entries via cached line index..., 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.analysis()
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 locationForDirective(a *analyzer.Analysis, d ast.Entry, span token.Span) *protocol.Location {
105
	for i, pf := range a.Files {
106
		if slices.Contains(pf.Ast.Entries, d) {
107
			return locationFor(a, i, span)
108
		}
109
	}
110
	return nil
111
}
112
113
func locationFor(a *analyzer.Analysis, fileIdx int, span token.Span) *protocol.Location {
114
	pf := a.Files[fileIdx]
115
	return &protocol.Location{
116
		URI:   uri.File(pf.Path),
117
		Range: spanRangeFromSrc(pf.Src, span),
118
	}
119
}
120
121
// spanRangeFromSrc converts a span to a protocol range. Parsed spans carry
122
// 1-based Line/Col and are converted directly; spans whose end runs into
123
// trailing whitespace or uses the next-line-start convention (Col == 0) get a
124
// one-line scan back from the end offset.
125
func spanRangeFromSrc(src []byte, span token.Span) protocol.Range {
126
	if span.Start.Line == 0 || span.End.Line == 0 {
127
		// spans built from offsets without Line/Col: full line index
128
		return lsputil.NewLineIndex(string(src)).SpanRange(span)
129
	}
130
	start := protocol.Position{Line: uint32(span.Start.Line - 1), Character: uint32(span.Start.Col - 1)}
131
	end := span.End.Offset
132
	if span.End.Col > 0 && end > span.Start.Offset && !isSpanSpace(src[end-1]) {
133
		// the span's stored end position matches its offset
134
		return protocol.Range{Start: start, End: protocol.Position{Line: uint32(span.End.Line - 1), Character: uint32(span.End.Col - 1)}}
135
	}
136
	// Trim trailing whitespace back from the end offset; both scans are
137
	// bounded by the one line the span ends on.
138
	line := span.End.Line - 1 // 1-based line holding the end
139
	for end > span.Start.Offset && isSpanSpace(src[end-1]) {
140
		if src[end-1] == '\n' {
141
			line--
142
		}
143
		end--
144
	}
145
	lineStart := end
146
	for lineStart > 0 && src[lineStart-1] != '\n' {
147
		lineStart--
148
	}
149
	return protocol.Range{
150
		Start: start,
151
		End: protocol.Position{
152
			Line:      uint32(line - 1),
153
			Character: uint32(lsputil.Utf16ColBytes(src[lineStart:end])),
154
		},
155
	}
156
}
157
158
func isSpanSpace(b byte) bool {
159
	switch b {
160
	case ' ', '\t', '\r', '\n':
161
		return true
162
	}
163
	return false
164
}
165
166
func spanContains(content string, span token.Span, offset int) bool {
167
	if span.End.Offset <= span.Start.Offset {
168
		return false
169
	}
170
	end := spanEndClamped(content, span.End.Offset)
171
	return span.Start.Offset <= offset && offset <= end
172
}
173
174
// entryAt returns the entry whose start offset is at or before cursor, the
175
// only entry whose tokens can contain it. Entries are stored in file order,
176
// so a binary search replaces a linear scan for late-cursor requests.
177
func entryAt(entries []ast.Entry, cursor int) ast.Entry {
178
	idx := sort.Search(len(entries), func(i int) bool { return entryStart(entries[i]) > cursor }) - 1
179
	if idx < 0 {
180
		return nil
181
	}
182
	return entries[idx]
183
}
184
185
func entryStart(e ast.Entry) int {
186
	switch e := e.(type) {
187
	case *ast.BlankLine:
188
		return e.Span.Start.Offset
189
	case *ast.Transaction:
190
		return e.Span.Start.Offset
191
	case *ast.PeriodicTransaction:
192
		return e.Span.Start.Offset
193
	case *ast.AutomatedTransaction:
194
		return e.Span.Start.Offset
195
	case *ast.Comment:
196
		return e.Span.Start.Offset
197
	case *ast.AccountDirective:
198
		return e.Span.Start.Offset
199
	case *ast.CommodityDirective:
200
		return e.Span.Start.Offset
201
	case *ast.PayeeDirective:
202
		return e.Span.Start.Offset
203
	case *ast.TagDirective:
204
		return e.Span.Start.Offset
205
	case *ast.IncludeDirective:
206
		return e.Span.Start.Offset
207
	case *ast.AliasDirective:
208
		return e.Span.Start.Offset
209
	case *ast.YearDirective:
210
		return e.Span.Start.Offset
211
	case *ast.DecimalMarkDirective:
212
		return e.Span.Start.Offset
213
	case *ast.DefaultCommodityDirective:
214
		return e.Span.Start.Offset
215
	case *ast.MarketPriceDirective:
216
		return e.Span.Start.Offset
217
	case *ast.ConversionDirective:
218
		return e.Span.Start.Offset
219
	case *ast.ApplyDirective:
220
		return e.Span.Start.Offset
221
	case *ast.EndDirective:
222
		return e.Span.Start.Offset
223
	case *ast.CommentBlockDirective:
224
		return e.Span.Start.Offset
225
	case *ast.IgnoredDirective:
226
		return e.Span.Start.Offset
227
	}
228
	return 0
229
}
230
231
func spanEndClamped(content string, end int) int {
232
	for end > 0 {
233
		switch content[end-1] {
234
		case ' ', '\t', '\r', '\n':
235
			end--
236
		default:
237
			return end
238
		}
239
	}
240
	return end
241
}