all repos

clerk @ 83b23d21e192297a33f109de92612e0b164e1c64

missing tooling for ledger/hledger

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

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