all repos

clerk @ ab4f6083f493cb5911c86ae5f723d4519f7d52da

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
remove duplication of token.Pos.File in token.Span, 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, state.lineIdx)
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.Kind == ast.SubdirectiveAlias && 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
	symbolTransaction
162
	symbolCommodity
163
	symbolPayee
164
	symbolTag
165
)
166
167
func (s symbolKind) ToProtocol() protocol.SymbolKind {
168
	switch s {
169
	case symbolAccount:
170
		return protocol.SymbolKindClass
171
	case symbolTransaction:
172
		return protocol.SymbolKindEvent
173
	case symbolCommodity:
174
		return protocol.SymbolKindVariable
175
	case symbolPayee:
176
		return protocol.SymbolKindObject
177
	case symbolTag:
178
		return protocol.SymbolKindProperty
179
	}
180
	return protocol.SymbolKindFile
181
}
182
183
// symbolRef is a symbol under the cursor, ready to be resolved or renamed.
184
type symbolRef struct {
185
	kind symbolKind
186
	name string
187
	span token.Span
188
}
189
190
func accountMatches(name, old string) bool {
191
	return name == old || strings.HasPrefix(name, old+":")
192
}
193
194
func tagRefInComment(content string, c *ast.Comment, cursor int) *symbolRef {
195
	if c == nil {
196
		return nil
197
	}
198
	for i := range c.Tags {
199
		t := &c.Tags[i]
200
		if span := tagKeySpan(content, t); spanContains(content, span, cursor) {
201
			return &symbolRef{symbolTag, t.Key, span}
202
		}
203
	}
204
	return nil
205
}
206
207
func commodityRef(content string, am *ast.Amount, cursor int) *symbolRef {
208
	if am == nil || am.Commodity == "" || !spanContains(content, am.CommoditySpan, cursor) {
209
		return nil
210
	}
211
	return &symbolRef{symbolCommodity, am.Commodity, am.CommoditySpan}
212
}
213
214
func symbolInPostings(content string, postings []*ast.Posting, cursor int) *symbolRef {
215
	for _, p := range postings {
216
		if spanContains(content, p.Account.Span, cursor) {
217
			return &symbolRef{symbolAccount, p.Account.String(), p.Account.Span}
218
		}
219
		if ref := commodityRef(content, p.Amount, cursor); ref != nil {
220
			return ref
221
		}
222
		if p.Cost != nil {
223
			if ref := commodityRef(content, &p.Cost.Amount, cursor); ref != nil {
224
				return ref
225
			}
226
		}
227
		if p.Balance != nil {
228
			if ref := commodityRef(content, &p.Balance.Amount, cursor); ref != nil {
229
				return ref
230
			}
231
		}
232
		if ref := tagRefInComment(content, p.Comment, cursor); ref != nil {
233
			return ref
234
		}
235
		for i := range p.Comments {
236
			if ref := tagRefInComment(content, &p.Comments[i], cursor); ref != nil {
237
				return ref
238
			}
239
		}
240
	}
241
	return nil
242
}
243
244
func renameChanges(an *analyzer.Analysis, ref *symbolRef, newName string, primaryLI *lsputil.LineIndex) map[uri.URI][]protocol.TextEdit {
245
	type fileEdits struct {
246
		li    *lsputil.LineIndex
247
		edits []protocol.TextEdit
248
	}
249
	files := make(map[int]*fileEdits)
250
	add := func(fileIdx int, span token.Span, text string) {
251
		fe := files[fileIdx]
252
		if fe == nil {
253
			fe = &fileEdits{}
254
			if fileIdx == 0 {
255
				fe.li = primaryLI
256
			} else {
257
				fe.li = lsputil.NewLineIndex(string(an.Files[fileIdx].Src))
258
			}
259
			files[fileIdx] = fe
260
		}
261
		fe.edits = append(fe.edits, protocol.TextEdit{
262
			Range:   fe.li.SpanRange(span),
263
			NewText: text,
264
		})
265
	}
266
267
	switch ref.kind {
268
	case symbolAccount:
269
		renameAccountEdits(an, ref, newName, add)
270
	case symbolCommodity:
271
		renameCommodityEdits(an, ref, newName, add)
272
	case symbolPayee:
273
		renamePayeeEdits(an, ref, newName, add)
274
	case symbolTag:
275
		renameTagEdits(an, ref, newName, add)
276
	}
277
278
	changes := make(map[uri.URI][]protocol.TextEdit, len(files))
279
	for fileIdx, fe := range files {
280
		changes[uri.File(an.Files[fileIdx].Path)] = fe.edits
281
	}
282
	sortAndDedup(changes)
283
	return changes
284
}
285
286
func renameAccountEdits(an *analyzer.Analysis, ref *symbolRef, newName string, add func(int, token.Span, string)) {
287
	for _, name := range an.AccountNames {
288
		if !accountMatches(name, ref.name) {
289
			continue
290
		}
291
		info := an.Accounts[name]
292
		text := newName + strings.TrimPrefix(name, ref.name)
293
		for _, u := range info.Usages {
294
			add(u.FileIndex, u.Posting.Account.Span, text)
295
		}
296
	}
297
	for _, info := range an.Accounts {
298
		for _, d := range info.Directives {
299
			fileIdx := fileIndexForEntry(an, d)
300
			if fileIdx < 0 {
301
				continue
302
			}
303
			if accountMatches(d.Account.String(), ref.name) {
304
				add(fileIdx, d.Account.Span, newName+strings.TrimPrefix(d.Account.String(), ref.name))
305
			}
306
			for _, sd := range d.Subdirectives {
307
				if sd.Kind == ast.SubdirectiveAlias && accountMatches(sd.Value, ref.name) {
308
					add(fileIdx, sd.ValueSpan, newName+strings.TrimPrefix(sd.Value, ref.name))
309
				}
310
			}
311
		}
312
	}
313
	for _, ad := range an.AliasDirectives {
314
		fileIdx := fileIndexForEntry(an, ad)
315
		if fileIdx < 0 {
316
			continue
317
		}
318
		if accountMatches(ad.From.String(), ref.name) {
319
			add(fileIdx, ad.From.Span, newName+strings.TrimPrefix(ad.From.String(), ref.name))
320
		}
321
		if accountMatches(ad.To.String(), ref.name) {
322
			add(fileIdx, ad.To.Span, newName+strings.TrimPrefix(ad.To.String(), ref.name))
323
		}
324
	}
325
}
326
327
func renameCommodityEdits(an *analyzer.Analysis, ref *symbolRef, newName string, add func(int, token.Span, string)) {
328
	info := an.Commodities[ref.name]
329
	if info == nil {
330
		return
331
	}
332
	for _, d := range info.Directives {
333
		if fileIdx := fileIndexForEntry(an, d); fileIdx >= 0 {
334
			add(fileIdx, d.CommoditySpan, newName)
335
		}
336
	}
337
	for _, u := range info.Usages {
338
		add(u.FileIndex, u.Amount.CommoditySpan, newName)
339
	}
340
}
341
342
func renamePayeeEdits(an *analyzer.Analysis, ref *symbolRef, newName string, add func(int, token.Span, string)) {
343
	info := an.Payees[ref.name]
344
	if info == nil {
345
		return
346
	}
347
	for _, d := range info.Directives {
348
		if d.Name != nil {
349
			if fileIdx := fileIndexForEntry(an, d); fileIdx >= 0 {
350
				add(fileIdx, d.Name.Span, newName)
351
			}
352
		}
353
	}
354
	for _, u := range info.Usage {
355
		add(u.FileIndex, u.Payee.Span, newName)
356
	}
357
}
358
359
func renameTagEdits(an *analyzer.Analysis, ref *symbolRef, newName string, add func(int, token.Span, string)) {
360
	info := an.Tags[ref.name]
361
	if info == nil {
362
		return
363
	}
364
	contents := make(map[int]string) // file index → source, converted once per file
365
	content := func(fileIdx int) string {
366
		s, ok := contents[fileIdx]
367
		if !ok {
368
			s = string(an.Files[fileIdx].Src)
369
			contents[fileIdx] = s
370
		}
371
		return s
372
	}
373
	for _, d := range info.Directives {
374
		fileIdx := fileIndexForEntry(an, d)
375
		if fileIdx < 0 {
376
			continue
377
		}
378
		if span, ok := tagDirectiveSpan(content(fileIdx), d); ok {
379
			add(fileIdx, span, newName)
380
		}
381
	}
382
	for _, u := range info.Usage {
383
		span := tagKeySpan(content(u.FileIndex), u.Tag)
384
		add(u.FileIndex, span, newName)
385
	}
386
}
387
388
func tagDirectiveSpan(content string, d *ast.TagDirective) (token.Span, bool) {
389
	end := d.Span.End.Offset
390
	if d.Comment != nil {
391
		end = d.Comment.Span.Start.Offset
392
	}
393
	return betweenSpan(content, d.Span.File, d.Span.Start.Offset+len("tag"), end)
394
}
395
396
func tagKeySpan(content string, t *ast.Tag) token.Span {
397
	end := t.Span.End.Offset
398
	for off := t.Span.Start.Offset; off < end; off++ {
399
		if content[off] == ':' || content[off] == ',' {
400
			end = off
401
			break
402
		}
403
	}
404
	for end > t.Span.Start.Offset && (content[end-1] == ' ' || content[end-1] == '\t') {
405
		end--
406
	}
407
	return token.Span{File: t.Span.File, Start: t.Span.Start, End: token.Pos{Offset: end}}
408
}
409
410
func sortAndDedup(changes map[uri.URI][]protocol.TextEdit) {
411
	for u, edits := range changes {
412
		sort.Slice(edits, func(i, j int) bool {
413
			ri, rj := edits[i].Range, edits[j].Range
414
			if ri.Start.Line != rj.Start.Line {
415
				return ri.Start.Line < rj.Start.Line
416
			}
417
			return ri.Start.Character < rj.Start.Character
418
		})
419
		dedup := edits[:0]
420
		for _, e := range edits {
421
			if len(dedup) == 0 || dedup[len(dedup)-1] != e {
422
				dedup = append(dedup, e)
423
			}
424
		}
425
		changes[u] = dedup
426
	}
427
}
428
429
// Validation
430
431
func validateAccountName(name string) error   { return validateRenameName(name, "account", ";") }
432
func validateCommodityName(name string) error { return validateRenameName(name, "commodity", ";") }
433
func validatePayeeName(name string) error     { return validateRenameName(name, "payee", ";|") }
434
func validateTagName(name string) error       { return validateRenameName(name, "tag", ":,; \t") }
435
func validateRenameName(name, what, forbidden string) error {
436
	if name == "" {
437
		return fmt.Errorf("%s name must not be empty", what)
438
	}
439
	if name != strings.TrimSpace(name) {
440
		return fmt.Errorf("%s name must not have leading or trailing whitespace", what)
441
	}
442
	for _, r := range name {
443
		if strings.ContainsRune(forbidden, r) || r == '\n' || r == '\r' {
444
			return fmt.Errorf("%s name contains illegal character %q", what, r)
445
		}
446
	}
447
	return nil
448
}