10 files changed,
782 insertions(+),
135 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-08-12 13:28:05 +0300
Authored at:
2026-08-10 16:23:27 +0300
Change ID:
yqykprmkrmsxusrtzwmrxykmyorskkvr
Parent:
c027d7c
jump to
M
internal/lsp/server.go
··· 72 72 Capabilities: protocol.ServerCapabilities{ 73 73 DocumentFormattingProvider: &protocol.DocumentFormattingOptions{}, 74 74 DefinitionProvider: protocol.Boolean(true), 75 + RenameProvider: &protocol.RenameOptions{ 76 + PrepareProvider: new(true), 77 + }, 75 78 CompletionProvider: &protocol.CompletionOptions{ 76 79 TriggerCharacters: []string{":", "@"}, 77 80 },
A
internal/lsp/testdata/rename-account.txtar
··· 1 +-- in.journal -- 2 +include sub.journal 3 + 4 +account ex^penses 5 +account expenses2 6 +account expenses:food 7 + 8 +2024-01-15 Grocery Store 9 + expenses:food $50 10 + expenses2 $20 11 + (expenses:food) $10 12 + 13 +2024-01-16 Another Store 14 + expen^ses:food $30 15 + assets:cash 16 + 17 +-- sub.journal -- 18 +account expenses:food 19 + 20 +2024-01-16 Grocery Store 21 + expenses:food $5 22 + 23 +-- rename -- 24 +costs 25 +food 26 + 27 +-- expect -- 28 +2:10 29 + prep in.journal 2:8-2:16 "expenses" 30 + ren in.journal 2:8-2:16 "costs" 31 + ren in.journal 4:8-4:21 "costs:food" 32 + ren in.journal 7:4-7:17 "costs:food" 33 + ren in.journal 9:5-9:18 "costs:food" 34 + ren in.journal 12:4-12:17 "costs:food" 35 + ren sub.journal 0:8-0:21 "costs:food" 36 + ren sub.journal 3:4-3:17 "costs:food" 37 +12:9 38 + prep in.journal 12:4-12:17 "expenses:food" 39 + ren in.journal 4:8-4:21 "food" 40 + ren in.journal 7:4-7:17 "food" 41 + ren in.journal 9:5-9:18 "food" 42 + ren in.journal 12:4-12:17 "food" 43 + ren sub.journal 0:8-0:21 "food" 44 + ren sub.journal 3:4-3:17 "food"
A
internal/lsp/testdata/rename-commodity.txtar
··· 1 +-- in.journal -- 2 +commodity $ 3 + format 1,000.00 4 + 5 +commodity EUR 6 + format 1.000,00 EUR 7 + 8 +2024-01-15 Grocery Store 9 + expenses:food ^$50 10 + assets:cash 100.00 EUR 11 + 12 +2024-01-16 Another Store 13 + expenses:food 50 E^UR 14 + assets:cash $30 @ ^$1.5 15 + assets:checking = ^$40 16 + 17 +-- rename -- 18 +€ 19 +USD 20 +€ 21 +€ 22 + 23 +-- expect -- 24 +7:19 25 + prep in.journal 7:19-7:20 "$" 26 + ren in.journal 0:10-0:11 "€" 27 + ren in.journal 7:19-7:20 "€" 28 + ren in.journal 12:17-12:18 "€" 29 + ren in.journal 12:23-12:24 "€" 30 + ren in.journal 13:23-13:24 "€" 31 +11:23 32 + prep in.journal 11:22-11:25 "EUR" 33 + ren in.journal 3:10-3:13 "USD" 34 + ren in.journal 8:24-8:27 "USD" 35 + ren in.journal 11:22-11:25 "USD" 36 +12:23 37 + prep in.journal 12:23-12:24 "$" 38 + ren in.journal 0:10-0:11 "€" 39 + ren in.journal 7:19-7:20 "€" 40 + ren in.journal 12:17-12:18 "€" 41 + ren in.journal 12:23-12:24 "€" 42 + ren in.journal 13:23-13:24 "€" 43 +13:23 44 + prep in.journal 13:23-13:24 "$" 45 + ren in.journal 0:10-0:11 "€" 46 + ren in.journal 7:19-7:20 "€" 47 + ren in.journal 12:17-12:18 "€" 48 + ren in.journal 12:23-12:24 "€" 49 + ren in.journal 13:23-13:24 "€"
A
internal/lsp/testdata/rename-payee.txtar
··· 1 +-- in.journal -- 2 +payee Grocery Store 3 + 4 +2024-01-15 Grocery^ Store 5 + expenses:food $50 6 + 7 +2024-01-16 "Grocery Store" 8 + expenses:food $30 9 + 10 +^2024-01-17 продукти 11 + expenses:food $10 12 + 13 +2024-01-18 прод^укти 14 + expenses:food $20 15 + 16 +-- rename -- 17 +Acme 18 +Acme 19 +products 20 + 21 +-- expect -- 22 +2:18 23 + prep in.journal 2:11-2:24 "Grocery Store" 24 + ren in.journal 0:6-0:19 "Acme" 25 + ren in.journal 2:11-2:24 "Acme" 26 + ren in.journal 5:11-5:26 "Acme" 27 +8:0 28 + prep <none> 29 + ren <none> 30 +11:15 31 + prep in.journal 11:11-11:19 "продукти" 32 + ren in.journal 8:11-8:19 "products" 33 + ren in.journal 11:11-11:19 "products"
A
internal/lsp/testdata/rename-tag.txtar
··· 1 +-- in.journal -- 2 +2024-01-15 Grocery Store ; cli^ent:acme, urgent 3 + expenses:food $50 ; project:alpha 4 + 5 +2024-01-16 Grocery Store 6 + ; client:other 7 + expenses:food $30 ; client:beta, url:https://x/y 8 + 9 +tag cli^ent 10 + 11 +2024-01-17 Grocery Store ; ur^gent, url:https://x^/y 12 + expenses:food $10 ; pro^ject:beta 13 + 14 +-- rename -- 15 +customer 16 +customer 17 +priority 18 +ignored 19 +task 20 + 21 +-- expect -- 22 +0:30 23 + prep in.journal 0:27-0:33 "client" 24 + ren in.journal 0:27-0:33 "customer" 25 + ren in.journal 4:6-4:12 "customer" 26 + ren in.journal 5:25-5:31 "customer" 27 + ren in.journal 7:4-7:10 "customer" 28 +7:7 29 + prep <none> 30 + ren <none> 31 +9:29 32 + prep <none> 33 + ren <none> 34 +9:48 35 + prep <none> 36 + ren <none> 37 +10:28 38 + prep in.journal 10:25-10:32 "project" 39 + ren in.journal 1:25-1:32 "task" 40 + ren in.journal 10:25-10:32 "task"
M
internal/lsp/textdocument_completion_test.go
··· 162 162 // Golden 163 163 164 164 func TestCompletionTxtar(t *testing.T) { 165 - tests := []string{ 166 - "completion-journal", 167 - "completion-unicode", 168 - "completion-crlf", 169 - } 170 - 171 - for _, tt := range tests { 165 + for _, tt := range []string{"completion-journal", "completion-unicode", "completion-crlf"} { 172 166 ar := golden.Read(t, tt) 173 167 174 168 t.Run(tt, func(t *testing.T) { 175 - content := string(ar.Get("in.journal")) 176 - 177 - var cursors []int 178 - for { 179 - m := strings.Index(content, "^") 180 - if m < 0 { 181 - break 182 - } 183 - cursors = append(cursors, m) 184 - content = content[:m] + content[m+1:] 185 - } 186 - if len(cursors) == 0 { 187 - t.Fatal("no '^' markers in in.journal") 188 - } 189 - 190 - srv := NewServer("test") 191 - srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal") 192 - srv.server.current = analyzer.Build(srv.server.loader.ResolveBytes("", []byte(content))) 169 + h := newTxtarHarness(t, ar) 193 170 194 171 var b strings.Builder 195 - for _, c := range cursors { 196 - line, col := lsputil.LineCol(content, c) 197 - res, err := srv.server.Completion(t.Context(), &protocol.CompletionParams{ 198 - TextDocumentPositionParams: protocol.TextDocumentPositionParams{ 199 - TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///test.journal")}, 200 - Position: protocol.Position{Line: uint32(line), Character: uint32(col)}, 201 - }, 172 + for i, c := range h.cursors { 173 + tdp := h.textDocumentPosition(i) 174 + res, err := h.srv.Completion(t.Context(), &protocol.CompletionParams{ 175 + TextDocumentPositionParams: tdp, 202 176 }) 203 177 if err != nil { 204 178 t.Fatal(err) 205 179 } 206 - ctx, start := detectCompletionCtx(content, c) 207 - fmt.Fprintf(&b, "%d:%d %s %q\n", line, col, ctx, content[start:c]) 180 + ctx, start := detectCompletionCtx(h.content, c) 181 + fmt.Fprintf(&b, "%d:%d %s %q\n", tdp.Position.Line, tdp.Position.Character, ctx, h.content[start:c]) 208 182 list, ok := res.(*protocol.CompletionList) 209 183 if !ok { 210 184 t.Fatalf("Completion returned %T, want *protocol.CompletionList", res)
M
internal/lsp/textdocument_definition.go
··· 25 25 } 26 26 27 27 func findDefinitionUnderCursor(an *analyzer.Analysis, docPath, content string, cursor int) protocol.LocationSlice { 28 - for _, pf := range an.Files { 29 - if pf.Path != docPath { 30 - continue 31 - } 32 - for _, entry := range pf.Ast.Entries { 33 - if loc := definitionInEntry(an, content, entry, cursor); loc != nil { 34 - return protocol.LocationSlice{*loc} 35 - } 36 - } 28 + ref := findSymbolUnderCursor(an, docPath, content, cursor) 29 + if ref == nil { 37 30 return nil 38 31 } 39 - return nil 40 -} 41 - 42 -func definitionInEntry(an *analyzer.Analysis, content string, e ast.Entry, cursor int) *protocol.Location { 43 - switch e := e.(type) { 44 - case *ast.Transaction: 45 - if e.Payee != nil && spanContains(content, e.Payee.Span, cursor) { 46 - return findPayeeDefinition(an, e.Payee.Name) 47 - } 48 - return definitionInPostings(an, content, e.Postings, cursor) 49 - case *ast.PeriodicTransaction: 50 - return definitionInPostings(an, content, e.Postings, cursor) 51 - case *ast.AutomatedTransaction: 52 - return definitionInPostings(an, content, e.Postings, cursor) 53 - case *ast.AccountDirective: 54 - if spanContains(content, e.Account.Span, cursor) { 55 - return findAccountDefinition(an, e.Account.String()) 56 - } 57 - case *ast.CommodityDirective: 58 - if spanContains(content, e.CommoditySpan, cursor) { 59 - return findCommodityDefinition(an, e.Commodity) 60 - } 61 - case *ast.PayeeDirective: 62 - if e.Name != nil && spanContains(content, e.Name.Span, cursor) { 63 - return findPayeeDefinition(an, e.Name.Name) 64 - } 32 + loc := resolveSymbol(ref, an) 33 + if loc == nil { 34 + return nil 65 35 } 66 - return nil 36 + return protocol.LocationSlice{*loc} 67 37 } 68 38 69 -func definitionInPostings(an *analyzer.Analysis, content string, postings []*ast.Posting, cursor int) *protocol.Location { 70 - for _, p := range postings { 71 - if spanContains(content, p.Account.Span, cursor) { 72 - return findAccountDefinition(an, p.Account.String()) 73 - } 74 - if loc := commodityDefinition(an, content, p.Amount, cursor); loc != nil { 75 - return loc 76 - } 77 - if p.Cost != nil { 78 - if loc := commodityDefinition(an, content, &p.Cost.Amount, cursor); loc != nil { 79 - return loc 80 - } 81 - } 82 - if p.Balance != nil { 83 - if loc := commodityDefinition(an, content, &p.Balance.Amount, cursor); loc != nil { 84 - return loc 85 - } 86 - } 39 +func resolveSymbol(ref *symbolRef, an *analyzer.Analysis) *protocol.Location { 40 + switch ref.kind { 41 + case symbolAccount: 42 + return findAccountDefinition(an, ref.name) 43 + case symbolCommodity: 44 + return findCommodityDefinition(an, ref.name) 45 + case symbolPayee: 46 + return findPayeeDefinition(an, ref.name) 87 47 } 88 48 return nil 89 -} 90 - 91 -func commodityDefinition(an *analyzer.Analysis, content string, am *ast.Amount, cursor int) *protocol.Location { 92 - if am == nil || am.Commodity == "" || !spanContains(content, am.CommoditySpan, cursor) { 93 - return nil 94 - } 95 - return findCommodityDefinition(an, am.Commodity) 96 49 } 97 50 98 51 func findAccountDefinition(an *analyzer.Analysis, name string) *protocol.Location {
M
internal/lsp/textdocument_definition_test.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "os" 6 5 "path/filepath" 7 6 "strings" 8 7 "testing" ··· 10 9 "go.lsp.dev/protocol" 11 10 "go.lsp.dev/uri" 12 11 13 - "olexsmir.xyz/clerk/internal/lsp/lsputil" 14 12 "olexsmir.xyz/clerk/internal/testutil/golden" 15 13 ) 16 14 ··· 33 31 // Golden 34 32 35 33 func TestDefinitionTxtar(t *testing.T) { 36 - tests := []string{ 37 - "definition-journal", 38 - "definition-include", 39 - } 40 - for _, tt := range tests { 34 + for _, tt := range []string{"definition-journal", "definition-include"} { 41 35 ar := golden.Read(t, tt) 42 36 t.Run(tt, func(t *testing.T) { 43 - dir := t.TempDir() 44 - for _, f := range ar.Files { 45 - if f.Name != "expect" { 46 - if err := os.WriteFile(filepath.Join(dir, f.Name), f.Data, 0o644); err != nil { 47 - t.Fatal(err) 48 - } 49 - } 50 - } 51 - 52 - content, cursors := stripCursors(string(ar.Get("in.journal"))) 53 - u := uri.File(filepath.Join(dir, "in.journal")) 54 - 55 - srv := NewServer("test") 56 - srv.server.openDoc(u, content, 1, "journal") 37 + h := newTxtarHarness(t, ar) 57 38 58 39 var b strings.Builder 59 - for _, c := range cursors { 60 - pos := lsputil.Position(content, c) 61 - res, err := srv.server.Definition(t.Context(), &protocol.DefinitionParams{ 62 - TextDocumentPositionParams: protocol.TextDocumentPositionParams{ 63 - TextDocument: protocol.TextDocumentIdentifier{URI: u}, 64 - Position: pos, 65 - }, 40 + for i := range h.cursors { 41 + pos := h.textDocumentPosition(i).Position 42 + res, err := h.srv.Definition(t.Context(), &protocol.DefinitionParams{ 43 + TextDocumentPositionParams: h.textDocumentPosition(i), 66 44 }) 67 45 if err != nil { 68 46 t.Fatal(err) ··· 81 59 }) 82 60 } 83 61 } 84 - 85 -func stripCursors(content string) (string, []int) { 86 - var cursors []int 87 - for { 88 - i := strings.Index(content, "^") 89 - if i < 0 { 90 - return content, cursors 91 - } 92 - cursors = append(cursors, i) 93 - content = content[:i] + content[i+1:] 94 - } 95 -}
A
internal/lsp/textdocument_rename.go
··· 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 +}
A
internal/lsp/textdocument_rename_test.go
··· 1 +package lsp 2 + 3 +import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "sort" 8 + "strings" 9 + "testing" 10 + 11 + "go.lsp.dev/protocol" 12 + "go.lsp.dev/uri" 13 + 14 + "olexsmir.xyz/clerk/internal/lsp/lsputil" 15 + "olexsmir.xyz/clerk/internal/testutil/golden" 16 +) 17 + 18 +func TestValidateRenameName(t *testing.T) { 19 + valid := map[string][]string{ 20 + "account": {"expenses", "expenses:food", "a:b:c"}, 21 + "commodity": {"$", "€", "USD", "US$"}, 22 + "payee": {"Grocery Store", "acme", "продукти"}, 23 + "tag": {"client", "a-b_c", "x123"}, 24 + } 25 + 26 + invalid := map[string][]string{ 27 + "account": {"", " expenses", "expenses ", "expenses;x", "expenses\nx", "expenses\r"}, 28 + "commodity": {"", " $", "$ ", "$;x", "$\n"}, 29 + "payee": {"", " payee", "payee|note", "payee;x"}, 30 + "tag": {"", "a:b", "a,b", "a b", "a;b", "tag\tx"}, 31 + } 32 + 33 + validators := map[string]func(string) error{ 34 + "account": validateAccountName, 35 + "commodity": validateCommodityName, 36 + "payee": validatePayeeName, 37 + "tag": validateTagName, 38 + } 39 + 40 + for kind, v := range validators { 41 + for _, name := range valid[kind] { 42 + if err := v(name); err != nil { 43 + t.Errorf("%s %q: unexpected error %v", kind, name, err) 44 + } 45 + } 46 + for _, name := range invalid[kind] { 47 + if err := v(name); err == nil { 48 + t.Errorf("%s %q: expected error", kind, name) 49 + } 50 + } 51 + } 52 +} 53 + 54 +func TestAccountMatches(t *testing.T) { 55 + tests := map[string]bool{ 56 + "expenses": true, 57 + "expenses:food": true, 58 + "expenses:food:l": true, 59 + "expenses2": false, 60 + "ex": false, 61 + "other": false, 62 + } 63 + for name, want := range tests { 64 + if got := accountMatches(name, "expenses"); got != want { 65 + t.Errorf("accountMatches(%q, expenses) = %v, want %v", name, got, want) 66 + } 67 + } 68 +} 69 + 70 +// Golden 71 + 72 +func TestRenameTxtar(t *testing.T) { 73 + for _, tt := range []string{"rename-account", "rename-commodity", "rename-payee", "rename-tag"} { 74 + ar := golden.Read(t, tt) 75 + t.Run(tt, func(t *testing.T) { 76 + h := newTxtarHarness(t, ar) 77 + 78 + newNames := strings.Split(strings.TrimRight(string(ar.Get("rename")), "\n"), "\n") 79 + if len(newNames) != len(h.cursors) { 80 + t.Fatalf("%d cursors, %d rename names", len(h.cursors), len(newNames)) 81 + } 82 + 83 + var b strings.Builder 84 + for i := range h.cursors { 85 + tdp := h.textDocumentPosition(i) 86 + prep, err := h.srv.PrepareRename(t.Context(), &protocol.PrepareRenameParams{TextDocumentPositionParams: tdp}) 87 + if err != nil { 88 + t.Fatal(err) 89 + } 90 + edit, err := h.srv.Rename(t.Context(), &protocol.RenameParams{ 91 + TextDocumentPositionParams: tdp, 92 + NewName: newNames[i], 93 + }) 94 + if err != nil { 95 + t.Fatal(err) 96 + } 97 + 98 + fmt.Fprintf(&b, "%d:%d\n", tdp.Position.Line, tdp.Position.Character) 99 + if p, ok := prep.(*protocol.PrepareRenamePlaceholder); ok { 100 + r := p.Range 101 + fmt.Fprintf(&b, " prep in.journal %d:%d-%d:%d %q\n", 102 + r.Start.Line, r.Start.Character, r.End.Line, r.End.Character, p.Placeholder) 103 + } else { 104 + b.WriteString(" prep <none>\n") 105 + } 106 + if edit == nil { 107 + b.WriteString(" ren <none>\n") 108 + continue 109 + } 110 + uris := make([]string, 0, len(edit.Changes)) 111 + for u := range edit.Changes { 112 + uris = append(uris, string(u)) 113 + } 114 + sort.Strings(uris) 115 + for _, us := range uris { 116 + for _, e := range edit.Changes[uri.URI(us)] { 117 + r := e.Range 118 + fmt.Fprintf(&b, " ren %s %d:%d-%d:%d %q\n", filepath.Base(uri.URI(us).Path()), 119 + r.Start.Line, r.Start.Character, r.End.Line, r.End.Character, e.NewText) 120 + } 121 + } 122 + } 123 + golden.Assert(t, ar, b.String()) 124 + }) 125 + } 126 +} 127 + 128 +// txtarHarness runs per-cursor lsp requests againt golden tests 129 +type txtarHarness struct { 130 + srv *server 131 + uri uri.URI 132 + content string 133 + cursors []int 134 +} 135 + 136 +func newTxtarHarness(t *testing.T, ar *golden.Archive) *txtarHarness { 137 + t.Helper() 138 + dir := t.TempDir() 139 + for _, f := range ar.Files { 140 + if f.Name == "expect" || f.Name == "rename" { 141 + continue 142 + } 143 + if err := os.WriteFile(filepath.Join(dir, f.Name), f.Data, 0o644); err != nil { 144 + t.Fatal(err) 145 + } 146 + } 147 + 148 + content, cursors := stripCursors(string(ar.Get("in.journal"))) 149 + if len(cursors) == 0 { 150 + t.Fatal("no '^' markers in in.journal") 151 + } 152 + u := uri.File(filepath.Join(dir, "in.journal")) 153 + 154 + srv := NewServer("test") 155 + srv.server.openDoc(u, content, 1, "journal") 156 + 157 + return &txtarHarness{srv: srv.server, uri: u, content: content, cursors: cursors} 158 +} 159 + 160 +func (h *txtarHarness) textDocumentPosition(i int) protocol.TextDocumentPositionParams { 161 + return protocol.TextDocumentPositionParams{ 162 + TextDocument: protocol.TextDocumentIdentifier{URI: h.uri}, 163 + Position: lsputil.Position(h.content, h.cursors[i]), 164 + } 165 +} 166 + 167 +func stripCursors(content string) (string, []int) { 168 + var cursors []int 169 + for { 170 + i := strings.Index(content, "^") 171 + if i < 0 { 172 + return content, cursors 173 + } 174 + cursors = append(cursors, i) 175 + content = content[:i] + content[i+1:] 176 + } 177 +}