all repos

clerk @ d3fcad3049dc9b55eb775f22fdfb98d83101cd26

missing tooling for ledger/hledger

clerk/internal/lsp/textdocument_rename.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
	"fmt"
6
	"sort"
7
	"strings"
8
9
	"go.lsp.dev/protocol"
10
	"go.lsp.dev/uri"
11
12
	"olexsmir.xyz/clerk/internal/analyzer"
13
	"olexsmir.xyz/clerk/internal/lsp/lsputil"
14
	"olexsmir.xyz/clerk/journal/ast"
15
	"olexsmir.xyz/clerk/journal/token"
16
)
17
18
func (s *server) PrepareRename(_ context.Context, params *protocol.PrepareRenameParams) (protocol.PrepareRenameResult, error) {
19
	state, ok := s.getDocState(params.TextDocument.URI)
20
	if !ok {
21
		return nil, nil
22
	}
23
24
	an := s.analysisFor(params.TextDocument.URI)
25
	cursor := state.lineIdx.Offset(int(params.Position.Line), int(params.Position.Character))
26
	ref := findSymbolUnderCursor(an, params.TextDocument.URI.Path(), state.text, cursor)
27
	if ref == nil {
28
		return nil, nil
29
	}
30
	switch ref.kind {
31
	case symbolAccount, symbolCommodity, symbolPayee, symbolTag:
32
	default:
33
		return nil, nil // kinds without rename support
34
	}
35
36
	return &protocol.PrepareRenamePlaceholder{
37
		Range:       state.lineIdx.SpanRange(ref.span),
38
		Placeholder: ref.name,
39
	}, nil
40
}
41
42
func (s *server) Rename(_ context.Context, params *protocol.RenameParams) (*protocol.WorkspaceEdit, error) {
43
	state, ok := s.getDocState(params.TextDocument.URI)
44
	if !ok {
45
		return nil, nil
46
	}
47
48
	an := s.analysisFor(params.TextDocument.URI)
49
	cursor := state.lineIdx.Offset(int(params.Position.Line), int(params.Position.Character))
50
	ref := findSymbolUnderCursor(an, params.TextDocument.URI.Path(), state.text, cursor)
51
	if ref == nil {
52
		return nil, nil
53
	}
54
55
	switch ref.kind {
56
	case symbolAccount:
57
		if err := validateAccountName(params.NewName); err != nil {
58
			return nil, err
59
		}
60
	case symbolCommodity:
61
		if err := validateCommodityName(params.NewName); err != nil {
62
			return nil, err
63
		}
64
	case symbolPayee:
65
		if err := validatePayeeName(params.NewName); err != nil {
66
			return nil, err
67
		}
68
	case symbolTag:
69
		if err := validateTagName(params.NewName); err != nil {
70
			return nil, err
71
		}
72
	default:
73
		return nil, nil
74
	}
75
76
	changes := renameChanges(an, ref, params.NewName, state.lineIdx)
77
	if len(changes) == 0 {
78
		return nil, nil
79
	}
80
	return &protocol.WorkspaceEdit{Changes: changes}, nil
81
}
82
83
func findSymbolUnderCursor(an *analyzer.Analysis, docPath, content string, cursor int) *symbolRef {
84
	pf := parsedFileFor(an, docPath)
85
	if pf == nil {
86
		return nil
87
	}
88
	if entry := entryAt(pf.Ast.Entries, cursor); entry != nil {
89
		return symbolInEntry(content, entry, cursor)
90
	}
91
	return nil
92
}
93
94
func symbolInEntry(content string, e ast.Entry, cursor int) *symbolRef {
95
	switch e := e.(type) {
96
	case *ast.Transaction:
97
		if e.Payee != "" && spanContains(content, e.PayeeSpan, cursor) {
98
			return &symbolRef{symbolPayee, e.Payee, e.PayeeSpan}
99
		}
100
		if ref := tagRefInComment(content, e.Comment, cursor); ref != nil {
101
			return ref
102
		}
103
		for _, c := range e.HeaderComments {
104
			if ref := tagRefInComment(content, c, cursor); ref != nil {
105
				return ref
106
			}
107
		}
108
		return symbolInPostings(content, e.Postings, cursor)
109
	case *ast.PeriodicTransaction:
110
		if ref := tagRefInComment(content, e.Comment, cursor); ref != nil {
111
			return ref
112
		}
113
		for _, c := range e.HeaderComments {
114
			if ref := tagRefInComment(content, c, cursor); ref != nil {
115
				return ref
116
			}
117
		}
118
		return symbolInPostings(content, e.Postings, cursor)
119
	case *ast.AutomatedTransaction:
120
		if ref := tagRefInComment(content, e.Comment, cursor); ref != nil {
121
			return ref
122
		}
123
		for _, c := range e.HeaderComments {
124
			if ref := tagRefInComment(content, c, cursor); ref != nil {
125
				return ref
126
			}
127
		}
128
		return symbolInPostings(content, e.Postings, cursor)
129
	case *ast.Comment:
130
		return tagRefInComment(content, e, cursor)
131
	case *ast.AccountDirective:
132
		if spanContains(content, e.Account.Span, cursor) {
133
			return &symbolRef{symbolAccount, e.Account.String(), e.Account.Span}
134
		}
135
		for _, sd := range e.Subdirectives {
136
			if sd.Kind == ast.SubdirectiveAlias && spanContains(content, sd.ValueSpan, cursor) {
137
				return &symbolRef{symbolAccount, sd.Value, sd.ValueSpan}
138
			}
139
		}
140
	case *ast.AliasDirective:
141
		if spanContains(content, e.From.Span, cursor) {
142
			return &symbolRef{symbolAccount, e.From.String(), e.From.Span}
143
		}
144
		if spanContains(content, e.To.Span, cursor) {
145
			return &symbolRef{symbolAccount, e.To.String(), e.To.Span}
146
		}
147
	case *ast.CommodityDirective:
148
		if spanContains(content, e.CommoditySpan, cursor) {
149
			return &symbolRef{symbolCommodity, e.Commodity, e.CommoditySpan}
150
		}
151
	case *ast.PayeeDirective:
152
		if e.Name != "" && spanContains(content, e.NameSpan, cursor) {
153
			return &symbolRef{symbolPayee, e.Name, e.NameSpan}
154
		}
155
	case *ast.IncludeDirective:
156
		if spanContains(content, e.Span, cursor) {
157
			return &symbolRef{symbolInclude, e.Path, e.Span}
158
		}
159
	}
160
	return nil
161
}
162
163
// symbolKind is the kind of symbol under the cursor.
164
type symbolKind int
165
166
const (
167
	symbolAccount symbolKind = iota
168
	symbolTransaction
169
	symbolCommodity
170
	symbolPayee
171
	symbolTag
172
	symbolInclude
173
)
174
175
func (s symbolKind) ToProtocol() protocol.SymbolKind {
176
	switch s {
177
	case symbolAccount:
178
		return protocol.SymbolKindClass
179
	case symbolTransaction:
180
		return protocol.SymbolKindEvent
181
	case symbolCommodity:
182
		return protocol.SymbolKindVariable
183
	case symbolPayee:
184
		return protocol.SymbolKindObject
185
	case symbolTag:
186
		return protocol.SymbolKindProperty
187
	}
188
	return protocol.SymbolKindFile
189
}
190
191
// symbolRef is a symbol under the cursor, ready to be resolved or renamed.
192
type symbolRef struct {
193
	kind symbolKind
194
	name string
195
	span token.Span
196
}
197
198
func accountMatches(name, old string) bool {
199
	return name == old || strings.HasPrefix(name, old+":")
200
}
201
202
func tagRefInComment(content string, c *ast.Comment, cursor int) *symbolRef {
203
	if c == nil {
204
		return nil
205
	}
206
	for i := range c.Tags {
207
		t := &c.Tags[i]
208
		if span := tagKeySpan(content, t); spanContains(content, span, cursor) {
209
			return &symbolRef{symbolTag, t.Key, span}
210
		}
211
	}
212
	return nil
213
}
214
215
func commodityRef(content string, am *ast.Amount, cursor int) *symbolRef {
216
	if am == nil || am.Commodity == "" || !spanContains(content, am.CommoditySpan, cursor) {
217
		return nil
218
	}
219
	return &symbolRef{symbolCommodity, am.Commodity, am.CommoditySpan}
220
}
221
222
func symbolInPostings(content string, postings []ast.Posting, cursor int) *symbolRef {
223
	for _, p := range postings {
224
		if spanContains(content, p.Account.Span, cursor) {
225
			return &symbolRef{symbolAccount, p.Account.String(), p.Account.Span}
226
		}
227
		if ref := commodityRef(content, p.Amount, cursor); ref != nil {
228
			return ref
229
		}
230
		if p.Cost != nil {
231
			if ref := commodityRef(content, &p.Cost.Amount, cursor); ref != nil {
232
				return ref
233
			}
234
		}
235
		if p.Balance != nil {
236
			if ref := commodityRef(content, &p.Balance.Amount, cursor); ref != nil {
237
				return ref
238
			}
239
		}
240
		if ref := tagRefInComment(content, p.Comment, cursor); ref != nil {
241
			return ref
242
		}
243
		for i := range p.Comments {
244
			if ref := tagRefInComment(content, &p.Comments[i], cursor); ref != nil {
245
				return ref
246
			}
247
		}
248
	}
249
	return nil
250
}
251
252
func renameChanges(an *analyzer.Analysis, ref *symbolRef, newName string, primaryLI *lsputil.LineIndex) map[uri.URI][]protocol.TextEdit {
253
	type fileEdits struct {
254
		li    *lsputil.LineIndex
255
		edits []protocol.TextEdit
256
	}
257
	files := make(map[int]*fileEdits)
258
	add := func(fileIdx int, span token.Span, text string) {
259
		fe := files[fileIdx]
260
		if fe == nil {
261
			fe = &fileEdits{}
262
			if fileIdx == 0 {
263
				fe.li = primaryLI
264
			} else {
265
				fe.li = lsputil.NewLineIndex(string(an.Files[fileIdx].Src))
266
			}
267
			files[fileIdx] = fe
268
		}
269
		fe.edits = append(fe.edits, protocol.TextEdit{
270
			Range:   fe.li.SpanRange(span),
271
			NewText: text,
272
		})
273
	}
274
275
	switch ref.kind {
276
	case symbolAccount:
277
		renameAccountEdits(an, ref, newName, add)
278
	case symbolCommodity:
279
		renameCommodityEdits(an, ref, newName, add)
280
	case symbolPayee:
281
		renamePayeeEdits(an, ref, newName, add)
282
	case symbolTag:
283
		renameTagEdits(an, ref, newName, add)
284
	}
285
286
	changes := make(map[uri.URI][]protocol.TextEdit, len(files))
287
	for fileIdx, fe := range files {
288
		changes[uri.File(an.Files[fileIdx].Path)] = fe.edits
289
	}
290
	sortAndDedup(changes)
291
	return changes
292
}
293
294
func renameAccountEdits(an *analyzer.Analysis, ref *symbolRef, newName string, add func(int, token.Span, string)) {
295
	for _, name := range an.AccountNames {
296
		if !accountMatches(name, ref.name) {
297
			continue
298
		}
299
		info := an.Accounts[name]
300
		text := newName + strings.TrimPrefix(name, ref.name)
301
		for _, u := range info.Usages {
302
			add(u.FileIndex, u.Posting.Account.Span, text)
303
		}
304
	}
305
	for _, info := range an.Accounts {
306
		for _, d := range info.Directives {
307
			fileIdx := fileIndexForEntry(an, d)
308
			if fileIdx < 0 {
309
				continue
310
			}
311
			if accountMatches(d.Account.String(), ref.name) {
312
				add(fileIdx, d.Account.Span, newName+strings.TrimPrefix(d.Account.String(), ref.name))
313
			}
314
			for _, sd := range d.Subdirectives {
315
				if sd.Kind == ast.SubdirectiveAlias && accountMatches(sd.Value, ref.name) {
316
					add(fileIdx, sd.ValueSpan, newName+strings.TrimPrefix(sd.Value, ref.name))
317
				}
318
			}
319
		}
320
	}
321
	for _, ad := range an.AliasDirectives {
322
		fileIdx := fileIndexForEntry(an, ad)
323
		if fileIdx < 0 {
324
			continue
325
		}
326
		if accountMatches(ad.From.String(), ref.name) {
327
			add(fileIdx, ad.From.Span, newName+strings.TrimPrefix(ad.From.String(), ref.name))
328
		}
329
		if accountMatches(ad.To.String(), ref.name) {
330
			add(fileIdx, ad.To.Span, newName+strings.TrimPrefix(ad.To.String(), ref.name))
331
		}
332
	}
333
}
334
335
func renameCommodityEdits(an *analyzer.Analysis, ref *symbolRef, newName string, add func(int, token.Span, string)) {
336
	info := an.Commodities[ref.name]
337
	if info == nil {
338
		return
339
	}
340
	for _, d := range info.Directives {
341
		if fileIdx := fileIndexForEntry(an, d); fileIdx >= 0 {
342
			add(fileIdx, d.CommoditySpan, newName)
343
		}
344
	}
345
	for _, u := range info.Usages {
346
		add(u.FileIndex, u.Amount.CommoditySpan, newName)
347
	}
348
}
349
350
func renamePayeeEdits(an *analyzer.Analysis, ref *symbolRef, newName string, add func(int, token.Span, string)) {
351
	info := an.Payees[ref.name]
352
	if info == nil {
353
		return
354
	}
355
	for _, d := range info.Directives {
356
		if d.Name != "" {
357
			if fileIdx := fileIndexForEntry(an, d); fileIdx >= 0 {
358
				add(fileIdx, d.NameSpan, newName)
359
			}
360
		}
361
	}
362
	for _, u := range info.Usage {
363
		add(u.FileIndex, u.Span, newName)
364
	}
365
}
366
367
func renameTagEdits(an *analyzer.Analysis, ref *symbolRef, newName string, add func(int, token.Span, string)) {
368
	info := an.Tags[ref.name]
369
	if info == nil {
370
		return
371
	}
372
	contents := make(map[int]string) // file index → source, converted once per file
373
	content := func(fileIdx int) string {
374
		s, ok := contents[fileIdx]
375
		if !ok {
376
			s = string(an.Files[fileIdx].Src)
377
			contents[fileIdx] = s
378
		}
379
		return s
380
	}
381
	for _, d := range info.Directives {
382
		fileIdx := fileIndexForEntry(an, d)
383
		if fileIdx < 0 {
384
			continue
385
		}
386
		if span, ok := tagDirectiveSpan(content(fileIdx), d); ok {
387
			add(fileIdx, span, newName)
388
		}
389
	}
390
	for _, u := range info.Usage {
391
		span := tagKeySpan(content(u.FileIndex), u.Tag)
392
		add(u.FileIndex, span, newName)
393
	}
394
}
395
396
func tagDirectiveSpan(content string, d *ast.TagDirective) (token.Span, bool) {
397
	end := d.Span.End.Offset
398
	if d.Comment != nil {
399
		end = d.Comment.Span.Start.Offset
400
	}
401
	return betweenSpan(content, d.Span.File, d.Span.Start.Offset+len("tag"), end)
402
}
403
404
func tagKeySpan(content string, t *ast.Tag) token.Span {
405
	end := t.Span.End.Offset
406
	for off := t.Span.Start.Offset; off < end; off++ {
407
		if content[off] == ':' || content[off] == ',' {
408
			end = off
409
			break
410
		}
411
	}
412
	for end > t.Span.Start.Offset && (content[end-1] == ' ' || content[end-1] == '\t') {
413
		end--
414
	}
415
	return token.Span{File: t.Span.File, Start: t.Span.Start, End: token.Pos{Offset: end}}
416
}
417
418
func sortAndDedup(changes map[uri.URI][]protocol.TextEdit) {
419
	for u, edits := range changes {
420
		sort.Slice(edits, func(i, j int) bool {
421
			ri, rj := edits[i].Range, edits[j].Range
422
			if ri.Start.Line != rj.Start.Line {
423
				return ri.Start.Line < rj.Start.Line
424
			}
425
			return ri.Start.Character < rj.Start.Character
426
		})
427
		dedup := edits[:0]
428
		for _, e := range edits {
429
			if len(dedup) == 0 || dedup[len(dedup)-1] != e {
430
				dedup = append(dedup, e)
431
			}
432
		}
433
		changes[u] = dedup
434
	}
435
}
436
437
// Validation
438
439
func validateAccountName(name string) error   { return validateRenameName(name, "account", ";") }
440
func validateCommodityName(name string) error { return validateRenameName(name, "commodity", ";") }
441
func validatePayeeName(name string) error     { return validateRenameName(name, "payee", ";|") }
442
func validateTagName(name string) error       { return validateRenameName(name, "tag", ":,; \t") }
443
func validateRenameName(name, what, forbidden string) error {
444
	if name == "" {
445
		return fmt.Errorf("%s name must not be empty", what)
446
	}
447
	if name != strings.TrimSpace(name) {
448
		return fmt.Errorf("%s name must not have leading or trailing whitespace", what)
449
	}
450
	for _, r := range name {
451
		if strings.ContainsRune(forbidden, r) || r == '\n' || r == '\r' {
452
			return fmt.Errorf("%s name contains illegal character %q", what, r)
453
		}
454
	}
455
	return nil
456
}