5 files changed,
134 insertions(+),
46 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-08-16 12:25:42 +0300
Authored at:
2026-08-15 22:21:40 +0300
Change ID:
tyspkpmvvwyxrltsuowssxtqtpkzuwqs
Parent:
b238a8c
M
internal/lsp/testdata/workspace-symbol.txtar
··· 4 4 account assets:cash 5 5 6 6 2024-01-15 Grocery Store ; project:work 7 - expenses:food $50 8 - assets:cash $-50 7 + expenses:food $50 8 + assets:cash $-50 9 + 10 +2024-01-16 * Coffee Shop | cappuccino 11 + expenses:taxi $4 12 + assets:cash $-4 13 + 14 +^ 9 15 10 16 payee Grocery Store 11 17 payee Coffee Shop ··· 15 21 16 22 tag project 17 23 tag client 18 -^ 24 + 25 +~ monthly 26 + expenses:food $10 27 + 28 += expenses:food 29 + expenses:food $-1 19 30 20 31 -- queries -- 21 32 food ··· 24 35 EUR 25 36 project 26 37 xyz 38 +cappuccino 39 +monthly 40 +2024 41 +expenses:food 27 42 28 43 -- expect -- 29 44 "food" class expenses:food in.journal 0:8-0:21 45 +"food" event = expenses:food in.journal 26:2-26:15 30 46 "cash" class assets:cash in.journal 2:8-2:19 31 -"grocery" object Grocery Store in.journal 8:6-8:19 32 -"EUR" variable EUR in.journal 12:10-12:13 33 -"project" property project in.journal 14:4-14:11 47 +"grocery" object Grocery Store in.journal 14:6-14:19 48 +"grocery" event 2024-01-15 Grocery Store in.journal 4:0-4:10 49 +"EUR" variable EUR in.journal 18:10-18:13 50 +"project" property project in.journal 20:4-20:11 34 51 "xyz" <none> 52 +"cappuccino" event 2024-01-16 * Coffee Shop | cappuccino in.journal 8:0-8:10 53 +"monthly" event ~ monthly in.journal 23:2-23:9 54 +"2024" event 2024-01-15 Grocery Store in.journal 4:0-4:10 55 +"2024" event 2024-01-16 * Coffee Shop | cappuccino in.journal 8:0-8:10 56 +"expenses:food" class expenses:food in.journal 0:8-0:21 57 +"expenses:food" event = expenses:food in.journal 26:2-26:15 35 58 "" <none>
M
internal/lsp/textdocument_definition.go
··· 67 67 return nil 68 68 } 69 69 70 +func findTransactionDefinition(an *analyzer.Analysis, e ast.Entry) *protocol.Location { 71 + fileIdx := fileIndexForEntry(an, e) 72 + if fileIdx < 0 { 73 + return nil 74 + } 75 + var span token.Span 76 + switch e := e.(type) { 77 + case *ast.Transaction: 78 + span = e.Date.Span 79 + case *ast.PeriodicTransaction: 80 + span = e.Period.Span 81 + case *ast.AutomatedTransaction: 82 + span = e.Expr.Span 83 + default: 84 + return nil 85 + } 86 + return locationFor(an, fileIdx, span) 87 +} 88 + 70 89 func findCommodityDefinition(an *analyzer.Analysis, symbol string) *protocol.Location { 71 90 info := an.Commodities[symbol] 72 91 if info == nil {
M
internal/lsp/textdocument_rename.go
··· 158 158 159 159 const ( 160 160 symbolAccount symbolKind = iota 161 + symbolTransaction 161 162 symbolCommodity 162 163 symbolPayee 163 164 symbolTag ··· 167 168 switch s { 168 169 case symbolAccount: 169 170 return protocol.SymbolKindClass 171 + case symbolTransaction: 172 + return protocol.SymbolKindEvent 170 173 case symbolCommodity: 171 174 return protocol.SymbolKindVariable 172 175 case symbolPayee: ··· 182 185 kind symbolKind 183 186 name string 184 187 span token.Span 185 -} 186 - 187 -// renameTo returns the replacement rext for an occurrence of the nodeKind with the given name. 188 -func (ref *symbolRef) renameTo(nodeKind symbolKind, name, newName string) (text string, renamed bool) { 189 - if ref.kind != nodeKind { 190 - return "", false 191 - } 192 - if ref.kind == symbolAccount { 193 - if !accountMatches(name, ref.name) { 194 - return "", false 195 - } 196 - return newName + strings.TrimPrefix(name, ref.name), true 197 - } 198 - if name != ref.name { 199 - return "", false 200 - } 201 - return newName, true 202 188 } 203 189 204 190 func accountMatches(name, old string) bool {
M
internal/lsp/workspace_symbol.go
··· 3 3 import ( 4 4 "context" 5 5 "sort" 6 + "strings" 6 7 7 8 "go.lsp.dev/protocol" 8 9 "go.lsp.dev/uri" 9 10 10 11 "olexsmir.xyz/clerk/internal/analyzer" 11 12 "olexsmir.xyz/clerk/internal/lsp/fuzzy" 13 + "olexsmir.xyz/clerk/journal/ast" 12 14 ) 13 15 14 16 func (s *server) Symbols(_ context.Context, params *protocol.WorkspaceSymbolParams) (protocol.WorkspaceSymbolResult, error) { ··· 37 39 const maxSymbolResults = 100 38 40 39 41 type scoredSymbol struct { 40 - kind symbolKind 41 - name string 42 - score float64 42 + kind symbolKind 43 + name string 44 + score float64 45 + tnxEntry ast.Entry // set if kind == symbolTransaction, used for location resolution 43 46 } 44 47 45 48 func searchSymbols(an *analyzer.Analysis, query string) []protocol.WorkspaceSymbol { 46 49 matcher := fuzzy.Compile(query) 47 - 48 - scored := make([]scoredSymbol, 0, len(an.AccountNames)+len(an.Commodities)+len(an.PayeeNames)+len(an.TagNames)) 49 - add := func(kind symbolKind, name string) { 50 - if score := matcher.Score(name); score > 0 { 51 - scored = append(scored, scoredSymbol{kind, name, score}) 50 + scored := make([]scoredSymbol, 0) 51 + add := func(s scoredSymbol) { 52 + if score := matcher.Score(s.name); score > 0 { 53 + s.score = score 54 + scored = append(scored, s) 52 55 } 53 56 } 54 57 for _, name := range an.AccountNames { 55 - add(symbolAccount, name) 58 + add(scoredSymbol{kind: symbolAccount, name: name}) 56 59 } 57 60 for name := range an.Commodities { 58 - add(symbolCommodity, name) 61 + add(scoredSymbol{kind: symbolCommodity, name: name}) 59 62 } 60 63 for _, name := range an.PayeeNames { 61 - add(symbolPayee, name) 64 + add(scoredSymbol{kind: symbolPayee, name: name}) 62 65 } 63 66 for _, name := range an.TagNames { 64 - add(symbolTag, name) 67 + add(scoredSymbol{kind: symbolTag, name: name}) 68 + } 69 + for _, tx := range an.Transactions { 70 + add(scoredSymbol{kind: symbolTransaction, name: transactionName(tx), tnxEntry: tx}) 71 + } 72 + for _, tx := range an.PeriodicTransactions { 73 + add(scoredSymbol{kind: symbolTransaction, name: transactionName(tx), tnxEntry: tx}) 74 + } 75 + for _, tx := range an.AutomatedTransactions { 76 + add(scoredSymbol{kind: symbolTransaction, name: transactionName(tx), tnxEntry: tx}) 65 77 } 66 78 67 79 sortScoredSymbols(scored) ··· 71 83 72 84 symbols := make([]protocol.WorkspaceSymbol, 0, len(scored)) 73 85 for _, s := range scored { 74 - loc := definitionLocation(an, s.kind, s.name) 86 + loc := definitionLocation(an, s) 75 87 if loc == nil { 76 88 continue 77 89 } ··· 86 98 return symbols 87 99 } 88 100 89 -func definitionLocation(an *analyzer.Analysis, kind symbolKind, name string) *protocol.Location { 90 - switch kind { 101 +func definitionLocation(an *analyzer.Analysis, s scoredSymbol) *protocol.Location { 102 + switch s.kind { 91 103 case symbolAccount: 92 - return findAccountDefinition(an, name) 104 + return findAccountDefinition(an, s.name) 105 + case symbolTransaction: 106 + return findTransactionDefinition(an, s.tnxEntry) 93 107 case symbolCommodity: 94 - return findCommodityDefinition(an, name) 108 + return findCommodityDefinition(an, s.name) 95 109 case symbolPayee: 96 - return findPayeeDefinition(an, name) 110 + return findPayeeDefinition(an, s.name) 97 111 case symbolTag: 98 - return findTagDefinition(an, name) 112 + return findTagDefinition(an, s.name) 99 113 } 100 114 return nil 101 115 } 102 116 117 +func transactionName(e ast.Entry) string { 118 + var b strings.Builder 119 + switch e := e.(type) { 120 + case *ast.Transaction: 121 + b.Grow(24) 122 + b.WriteString(e.Date.String()) 123 + if s := e.Status.Value.String(); s != "" { 124 + b.WriteByte(' ') 125 + b.WriteString(s) 126 + } 127 + if e.Payee != nil { 128 + b.WriteByte(' ') 129 + b.WriteString(e.Payee.Name) 130 + } 131 + if e.Note != nil { 132 + b.WriteString(" | ") 133 + b.WriteString(e.Note.Value) 134 + } 135 + case *ast.PeriodicTransaction: 136 + b.WriteByte('~') 137 + if s := e.Status.Value.String(); s != "" { 138 + b.WriteByte(' ') 139 + b.WriteString(s) 140 + } 141 + b.WriteByte(' ') 142 + b.WriteString(e.Period.Raw) 143 + if e.Description != nil { 144 + b.WriteString(" | ") 145 + b.WriteString(e.Description.Value) 146 + } 147 + case *ast.AutomatedTransaction: 148 + b.WriteByte('=') 149 + b.WriteByte(' ') 150 + b.WriteString(e.Expr.Value) 151 + } 152 + return b.String() 153 +} 154 + 103 155 func sortScoredSymbols(scored []scoredSymbol) { 104 156 sort.Slice(scored, func(i, j int) bool { 105 157 if scored[i].score != scored[j].score { 106 158 return scored[i].score > scored[j].score 159 + } 160 + if len(scored[i].name) != len(scored[j].name) { 161 + return len(scored[i].name) < len(scored[j].name) 107 162 } 108 163 return scored[i].name < scored[j].name 109 164 })
M
internal/lsp/workspace_symbol_test.go
··· 16 16 17 17 func TestSortScoredSymbols(t *testing.T) { 18 18 scored := []scoredSymbol{ 19 - {symbolAccount, "zz:zz", 0.5}, 20 - {symbolTag, "aa", 1.0}, 21 - {symbolPayee, "bb", 0.5}, 19 + {symbolAccount, "zz:zz", 0.5, nil}, 20 + {symbolTag, "aa", 1.0, nil}, 21 + {symbolPayee, "bb", 0.5, nil}, 22 + {symbolTag, "ccc", 0.5, nil}, 23 + {symbolPayee, "bb", 0.5, nil}, 22 24 } 23 25 sortScoredSymbols(scored) 24 26 25 - want := []string{"aa", "bb", "zz:zz"} 27 + want := []string{"aa", "bb", "bb", "ccc", "zz:zz"} 26 28 for i, s := range scored { 27 29 if s.name != want[i] { 28 30 t.Errorf("pos %d = %q, want %q", i, s.name, want[i]) ··· 94 96 }{ 95 97 "account prefix": {"1:2", true}, 96 98 "all payees": {"transaction", true}, 99 + "txn dates": {"2000", true}, 97 100 "mixed kinds": {"B", true}, 98 101 "no match": {"xyz", false}, 99 102 } { ··· 135 138 return "object" 136 139 case protocol.SymbolKindProperty: 137 140 return "property" 141 + case protocol.SymbolKindEvent: 142 + return "event" 138 143 } 139 144 return "other" 140 145 }