all repos

clerk @ 1efa5a1e8c99f7bd4df51278482a040da2794d9e

missing tooling for ledger/hledger

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

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