all repos

clerk @ 4b5c1da

missing tooling for ledger/hledger

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

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