13 files changed,
377 insertions(+),
19 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-08-12 13:28:05 +0300
Authored at:
2026-08-10 15:32:27 +0300
Change ID:
uyumnxzlyyymzulzqzoplnnunyonslty
Parent:
907ec02
jump to
M
internal/analyzer/analyzer_test.go
··· 125 125 fmt.Fprintf(w, " directives: %d\n", len(info.Directives)) 126 126 fmt.Fprintf(w, " used: %d\n", info.UsedCount) 127 127 for _, d := range info.Directives { 128 - fmt.Fprintf(w, " payee %s\n", d.Name) 128 + if d.Name != nil { 129 + fmt.Fprintf(w, " payee %s\n", d.Name.Name) 130 + } 129 131 } 130 132 fmt.Fprintf(w, " usages: %d\n", len(info.Usage)) 131 133 for _, u := range info.Usage { ··· 166 168 case *ast.CommodityDirective: 167 169 fmt.Fprintf(w, " commodity %s\n", d.Commodity) 168 170 case *ast.PayeeDirective: 169 - fmt.Fprintf(w, " payee %s\n", d.Name) 171 + if d.Name != nil { 172 + fmt.Fprintf(w, " payee %s\n", d.Name.Name) 173 + } 170 174 case *ast.TagDirective: 171 175 fmt.Fprintf(w, " tag %s\n", d.Name) 172 176 case *ast.IncludeDirective:
M
internal/analyzer/build.go
··· 122 122 } 123 123 124 124 func (a *Analysis) addPayeeDirective(pd *ast.PayeeDirective) { 125 - info, ok := a.Payees[pd.Name] 125 + if pd.Name == nil { 126 + return 127 + } 128 + info, ok := a.Payees[pd.Name.Name] 126 129 if !ok { 127 130 info = &PayeeInfo{} 128 - a.Payees[pd.Name] = info 131 + a.Payees[pd.Name.Name] = info 129 132 } 130 - info.Directives = append(info.Directives, &ast.Payee{ 131 - Name: pd.Name, 132 - Span: pd.Span, 133 - }) 133 + info.Directives = append(info.Directives, pd) 134 134 } 135 135 136 136 func (a *Analysis) addTagDirective(td *ast.TagDirective) {
M
internal/lsp/server.go
··· 71 71 }, 72 72 Capabilities: protocol.ServerCapabilities{ 73 73 DocumentFormattingProvider: &protocol.DocumentFormattingOptions{}, 74 + DefinitionProvider: protocol.Boolean(true), 74 75 CompletionProvider: &protocol.CompletionOptions{ 75 76 TriggerCharacters: []string{":", "@"}, 76 77 },
A
internal/lsp/testdata/definition-include.txtar
··· 1 +-- in.journal -- 2 +include sub.journal 3 + 4 +2024-01-16 Another^ Store 5 + expenses:fo^od 100.00 EUR 6 + assets:ca^sh 7 + 8 +-- sub.journal -- 9 +account expenses:food 10 + 11 +2024-01-15 Grocery Store 12 + expenses:food $50 13 + assets:cash 14 + 15 +-- expect -- 16 +2:18 in.journal 2:11-2:24 17 +3:15 sub.journal 0:8-0:21 18 +4:13 sub.journal 4:4-4:15
A
internal/lsp/testdata/definition-journal.txtar
··· 1 +-- in.journal -- 2 +account ex^penses:food 3 +account assets:checking 4 +account untracked 5 + no^te ignore 6 + 7 +commodity ^$ 8 + format 1,000.00 9 + 10 +payee Grocery^ Store 11 + 12 +^2024-01-15 Grocery^ Store ; no^te 13 + ^expenses:food^ $5^0 14 + expenses:food ^$50 15 + assets:checking = ^$30 16 + assets:cash 100.00 E^UR 17 + 18 +2024-01-16 Another^ Store 19 + expenses:food $50 20 + assets:cash 100.00 E^UR 21 + assets:checking $30 @ ^$1.5 22 + 23 +2024-01-17 продукти 24 + expenses:food $10 25 + assets:cash 26 + 27 +2024-01-18 прод^укти 28 + expenses:food $20 29 + assets:ca^sh 30 + 31 +-- expect -- 32 +0:10 in.journal 0:8-0:21 33 +3:6 <none> 34 +5:10 in.journal 5:10-5:11 35 +8:13 in.journal 8:6-8:19 36 +10:0 <none> 37 +10:18 in.journal 8:6-8:19 38 +10:29 <none> 39 +11:4 in.journal 0:8-0:21 40 +11:17 in.journal 0:8-0:21 41 +11:21 <none> 42 +12:19 in.journal 5:10-5:11 43 +13:23 in.journal 5:10-5:11 44 +14:25 in.journal 14:24-14:27 45 +16:18 in.journal 16:11-16:24 46 +18:25 in.journal 14:24-14:27 47 +19:27 in.journal 5:10-5:11 48 +25:15 in.journal 21:11-21:19 49 +27:13 in.journal 14:4-14:15
A
internal/lsp/textdocument_definition.go
··· 1 +package lsp 2 + 3 +import ( 4 + "context" 5 + "slices" 6 + 7 + "go.lsp.dev/protocol" 8 + "go.lsp.dev/uri" 9 + 10 + "olexsmir.xyz/clerk/internal/analyzer" 11 + "olexsmir.xyz/clerk/internal/lsp/lsputil" 12 + "olexsmir.xyz/clerk/journal/ast" 13 + "olexsmir.xyz/clerk/journal/token" 14 +) 15 + 16 +func (s *server) Definition(_ context.Context, params *protocol.DefinitionParams) (protocol.DefinitionResult, error) { 17 + state, ok := s.getDocState(params.TextDocument.URI) 18 + if !ok { 19 + return nil, nil 20 + } 21 + 22 + an := s.analysis() 23 + cursor := lsputil.Offset(state.text, int(params.Position.Line), int(params.Position.Character)) 24 + return findDefinitionUnderCursor(an, params.TextDocument.URI.Path(), state.text, cursor), nil 25 +} 26 + 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 + } 37 + return nil 38 + } 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 + } 65 + } 66 + return nil 67 +} 68 + 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 + } 87 + } 88 + 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 +} 97 + 98 +func findAccountDefinition(an *analyzer.Analysis, name string) *protocol.Location { 99 + info := an.Accounts[name] 100 + if info == nil { 101 + return nil 102 + } 103 + if len(info.Directives) > 0 { 104 + return locationForDirective(an, info.Directives[0], info.Directives[0].Account.Span) 105 + } 106 + if len(info.Usages) > 0 { 107 + u := info.Usages[0] 108 + return locationFor(an, u.FileIndex, u.Posting.Account.Span) 109 + } 110 + return nil 111 +} 112 + 113 +func findCommodityDefinition(an *analyzer.Analysis, symbol string) *protocol.Location { 114 + info := an.Commodities[symbol] 115 + if info == nil { 116 + return nil 117 + } 118 + if len(info.Directives) > 0 { 119 + return locationForDirective(an, info.Directives[0], info.Directives[0].CommoditySpan) 120 + } 121 + if len(info.Usages) > 0 { 122 + u := info.Usages[0] 123 + return locationFor(an, u.FileIndex, u.Amount.CommoditySpan) 124 + } 125 + return nil 126 +} 127 + 128 +func findPayeeDefinition(an *analyzer.Analysis, name string) *protocol.Location { 129 + info := an.Payees[name] 130 + if info == nil { 131 + return nil 132 + } 133 + if len(info.Directives) > 0 { 134 + d := info.Directives[0] 135 + if d.Name == nil { 136 + return nil 137 + } 138 + return locationForDirective(an, d, d.Name.Span) 139 + } 140 + if len(info.Usage) > 0 { 141 + u := info.Usage[0] 142 + return locationFor(an, u.FileIndex, u.Payee.Span) 143 + } 144 + return nil 145 +} 146 + 147 +func locationForDirective(a *analyzer.Analysis, d ast.Entry, span token.Span) *protocol.Location { 148 + for i, pf := range a.Files { 149 + if slices.Contains(pf.Ast.Entries, d) { 150 + return locationFor(a, i, span) 151 + } 152 + } 153 + return nil 154 +} 155 + 156 +func locationFor(a *analyzer.Analysis, fileIdx int, span token.Span) *protocol.Location { 157 + pf := a.Files[fileIdx] 158 + return &protocol.Location{ 159 + URI: uri.File(pf.Path), 160 + Range: spanToProtocolRange(string(pf.Src), span), 161 + } 162 +} 163 + 164 +func spanToProtocolRange(content string, span token.Span) protocol.Range { 165 + return protocol.Range{ 166 + Start: lsputil.Position(content, span.Start.Offset), 167 + End: lsputil.Position(content, spanEndClamped(content, span.End.Offset)), 168 + } 169 +} 170 + 171 +func spanContains(content string, span token.Span, offset int) bool { 172 + if span.End.Offset <= span.Start.Offset { 173 + return false 174 + } 175 + end := spanEndClamped(content, span.End.Offset) 176 + return span.Start.Offset <= offset && offset <= end 177 +} 178 + 179 +func spanEndClamped(content string, end int) int { 180 + for end > 0 { 181 + switch content[end-1] { 182 + case ' ', '\t', '\r', '\n': 183 + end-- 184 + default: 185 + return end 186 + } 187 + } 188 + return end 189 +}
A
internal/lsp/textdocument_definition_test.go
··· 1 +package lsp 2 + 3 +import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "strings" 8 + "testing" 9 + 10 + "go.lsp.dev/protocol" 11 + "go.lsp.dev/uri" 12 + 13 + "olexsmir.xyz/clerk/internal/lsp/lsputil" 14 + "olexsmir.xyz/clerk/internal/testutil/golden" 15 +) 16 + 17 +func TestDefinition_DocumentNotFound(t *testing.T) { 18 + srv := NewServer("test") 19 + res, err := srv.server.Definition(t.Context(), &protocol.DefinitionParams{ 20 + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ 21 + TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///nonexistent.journal")}, 22 + Position: protocol.Position{Line: 0, Character: 0}, 23 + }, 24 + }) 25 + if err != nil { 26 + t.Fatal(err) 27 + } 28 + if res != nil { 29 + t.Errorf("got %v, want nil", res) 30 + } 31 +} 32 + 33 +// Golden 34 + 35 +func TestDefinitionTxtar(t *testing.T) { 36 + tests := []string{ 37 + "definition-journal", 38 + "definition-include", 39 + } 40 + for _, tt := range tests { 41 + ar := golden.Read(t, tt) 42 + 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") 57 + 58 + 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 + }, 66 + }) 67 + if err != nil { 68 + t.Fatal(err) 69 + } 70 + locs, _ := res.(protocol.LocationSlice) 71 + if len(locs) == 0 { 72 + fmt.Fprintf(&b, "%d:%d <none>\n", pos.Line, pos.Character) 73 + continue 74 + } 75 + r := locs[0].Range 76 + fmt.Fprintf(&b, "%d:%d %s %d:%d-%d:%d\n", pos.Line, pos.Character, 77 + filepath.Base(locs[0].URI.Path()), 78 + r.Start.Line, r.Start.Character, r.End.Line, r.End.Character) 79 + } 80 + golden.Assert(t, ar, b.String()) 81 + }) 82 + } 83 +} 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 +}
M
journal/ast/dump.go
··· 57 57 case *PayeeDirective: 58 58 indent(b, depth) 59 59 fmt.Fprintf(b, "PayeeDirective %s\n", e.Span) 60 - indent(b, depth+1) 61 - fmt.Fprintf(b, "Name: %q\n", e.Name) 60 + if e.Name != nil { 61 + indent(b, depth+1) 62 + fmt.Fprintf(b, "Name: %q %s\n", e.Name.Name, e.Name.Span) 63 + } 62 64 dumpOptComment(b, e.Comment, depth+1) 63 65 case *TagDirective: 64 66 indent(b, depth)
M
journal/parser/parser.go
··· 465 465 p.expect(token.PAYEE) 466 466 p.skipWhitespace() 467 467 468 - name := "" 468 + var name *ast.Payee 469 469 if p.got(token.TEXT) || p.got(token.STRING) || p.got(token.COMMODITYMARK) { 470 - name = p.parsePayee().Name 470 + name = p.parsePayee() 471 471 } 472 472 473 473 comment := p.parseOptInlineComment()
M
journal/parser/testdata/payee_directive.txtar
··· 6 6 -- expect -- 7 7 Journal 8 8 PayeeDirective j:1:1-2:1 9 - Name: "grocery store" 9 + Name: "grocery store" j:1:7-2:0 10 10 PayeeDirective j:2:1-3:1 11 - Name: "grocery store 3" 11 + Name: "grocery store 3" j:2:7-3:0 12 12 PayeeDirective j:3:1-4:1 13 - Name: "grocery store 2" 13 + Name: "grocery store 2" j:3:7-4:0 14 14 PayeeDirective j:4:1-5:1 15 - Name: "grocery store 1" 15 + Name: "grocery store 1" j:4:7-5:0