all repos

clerk @ ed6cfe6e32c6b1ff0a6e73bff077324b22452afa

missing tooling for ledger/hledger

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

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