11 files changed,
668 insertions(+),
7 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-08-15 18:59:46 +0300
Authored at:
2026-08-14 12:02:01 +0300
Change ID:
mpnqrosztvllyuzsnrqyyqvovptxutok
Parent:
3d2b11f
jump to
M
internal/lsp/fuzzy/fuzzy.go
··· 1 1 package fuzzy 2 2 3 -import "strings" 3 +import ( 4 + "strings" 5 + "unicode" 6 +) 4 7 5 8 // Matcher scores a precompiled pattern against candidate texts; [Compile] 6 9 // hoists the lowercasing and rune conversion out of a per-candidate loop. ··· 34 37 return 1 35 38 } 36 39 t := []rune(text) 37 - tl := []rune(strings.ToLower(text)) 38 40 39 41 total := 0 40 42 prev := -1 41 43 for i, pr := range p { 42 44 j := prev + 1 43 45 for ; j < len(t); j++ { 44 - if tl[j] == pr { 46 + if unicode.ToLower(t[j]) == pr { 45 47 break 46 48 } 47 49 }
M
internal/lsp/server.go
··· 28 28 29 29 mu sync.Mutex 30 30 openDocs map[uri.URI]docState 31 - current *analyzer.Analysis 32 31 diagCancel context.CancelFunc 33 32 34 33 cfgMu sync.RWMutex ··· 83 82 DocumentFormattingProvider: &protocol.DocumentFormattingOptions{}, 84 83 DefinitionProvider: protocol.Boolean(true), 85 84 HoverProvider: protocol.Boolean(true), 85 + ReferencesProvider: protocol.Boolean(true), 86 + WorkspaceSymbolProvider: protocol.Boolean(true), 86 87 RenameProvider: &protocol.RenameOptions{ 87 88 PrepareProvider: new(true), 88 89 },
A
internal/lsp/testdata/references-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:cash 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 +includeDeclaration:false 17 +3:15 sub.journal 3:4-3:17 18 +3:15 in.journal 3:4-3:17 19 +includeDeclaration:true 20 +3:15 sub.journal 3:4-3:17 21 +3:15 in.journal 3:4-3:17 22 +3:15 sub.journal 0:8-0:21
A
internal/lsp/testdata/references-journal.txtar
··· 1 +-- in.journal -- 2 +account ex^penses:food 3 +account assets:cash 4 + 5 +2024-01-15 Gro^cery Store 6 + expenses:food ^$50 7 + assets:cash $-50 8 + 9 +2024-01-16 Grocery Store ; proj^ect:work 10 + expenses:food $30 11 + assets:cash $-30 12 + 13 +2024-01-17 Coffee Shop ; project:home 14 + expenses:food $5 15 + assets:cash $-5 16 + 17 +payee Gro^cery Store 18 + 19 +commodity U^SD 20 + 21 +tag proj^ect 22 + 23 +-- expect -- 24 +includeDeclaration:false 25 +0:10 in.journal 4:4-4:17 26 +0:10 in.journal 8:4-8:17 27 +0:10 in.journal 12:4-12:17 28 +3:14 in.journal 3:11-3:24 29 +3:14 in.journal 7:11-7:24 30 +4:19 in.journal 4:19-4:20 31 +4:19 in.journal 5:17-5:18 32 +4:19 in.journal 8:19-8:20 33 +4:19 in.journal 9:17-9:18 34 +4:19 in.journal 12:19-12:20 35 +4:19 in.journal 13:17-13:18 36 +7:31 in.journal 7:27-7:34 37 +7:31 in.journal 11:25-11:32 38 +15:9 in.journal 3:11-3:24 39 +15:9 in.journal 7:11-7:24 40 +17:11 <none> 41 +19:8 <none> 42 +includeDeclaration:true 43 +0:10 in.journal 4:4-4:17 44 +0:10 in.journal 8:4-8:17 45 +0:10 in.journal 12:4-12:17 46 +0:10 in.journal 0:8-0:21 47 +3:14 in.journal 3:11-3:24 48 +3:14 in.journal 7:11-7:24 49 +3:14 in.journal 15:6-15:19 50 +4:19 in.journal 4:19-4:20 51 +4:19 in.journal 5:17-5:18 52 +4:19 in.journal 8:19-8:20 53 +4:19 in.journal 9:17-9:18 54 +4:19 in.journal 12:19-12:20 55 +4:19 in.journal 13:17-13:18 56 +7:31 in.journal 7:27-7:34 57 +7:31 in.journal 11:25-11:32 58 +7:31 in.journal 19:4-19:11 59 +15:9 in.journal 3:11-3:24 60 +15:9 in.journal 7:11-7:24 61 +15:9 in.journal 15:6-15:19 62 +17:11 in.journal 17:10-17:13 63 +19:8 <none>
A
internal/lsp/testdata/workspace-symbol.txtar
··· 1 +-- in.journal -- 2 +account expenses:food 3 +account expenses:taxi 4 +account assets:cash 5 + 6 +2024-01-15 Grocery Store ; project:work 7 + expenses:food $50 8 + assets:cash $-50 9 + 10 +payee Grocery Store 11 +payee Coffee Shop 12 + 13 +commodity USD 14 +commodity EUR 15 + 16 +tag project 17 +tag client 18 +^ 19 + 20 +-- queries -- 21 +food 22 +cash 23 +grocery 24 +EUR 25 +project 26 +xyz 27 + 28 +-- expect -- 29 +"food" class expenses:food in.journal 0:8-0:21 30 +"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 34 +"xyz" <none> 35 +"" <none>
M
internal/lsp/textdocument_definition.go
··· 101 101 return nil 102 102 } 103 103 104 -func locationForDirective(a *analyzer.Analysis, d ast.Entry, span token.Span) *protocol.Location { 104 +func findTagDefinition(an *analyzer.Analysis, key string) *protocol.Location { 105 + info := an.Tags[key] 106 + if info == nil { 107 + return nil 108 + } 109 + if len(info.Directives) > 0 { 110 + d := info.Directives[0] 111 + fileIdx := fileIndexForEntry(an, d) 112 + if fileIdx < 0 { 113 + return nil 114 + } 115 + if span, ok := tagDirectiveSpan(string(an.Files[fileIdx].Src), d); ok { 116 + return locationFor(an, fileIdx, span) 117 + } 118 + } 119 + if len(info.Usage) > 0 { 120 + u := info.Usage[0] 121 + span := tagKeySpan(string(an.Files[u.FileIndex].Src), u.Tag) 122 + return locationFor(an, u.FileIndex, span) 123 + } 124 + return nil 125 +} 126 + 127 +func fileIndexForEntry(a *analyzer.Analysis, d ast.Entry) int { 105 128 for i, pf := range a.Files { 106 129 if slices.Contains(pf.Ast.Entries, d) { 107 - return locationFor(a, i, span) 130 + return i 108 131 } 109 132 } 110 - return nil 133 + return -1 134 +} 135 + 136 +func locationForDirective(a *analyzer.Analysis, d ast.Entry, span token.Span) *protocol.Location { 137 + fileIdx := fileIndexForEntry(a, d) 138 + if fileIdx < 0 { 139 + return nil 140 + } 141 + return locationFor(a, fileIdx, span) 111 142 } 112 143 113 144 func locationFor(a *analyzer.Analysis, fileIdx int, span token.Span) *protocol.Location {
A
internal/lsp/textdocument_references.go
··· 1 +package lsp 2 + 3 +import ( 4 + "context" 5 + 6 + "go.lsp.dev/protocol" 7 + 8 + "olexsmir.xyz/clerk/internal/analyzer" 9 +) 10 + 11 +func (s *server) References(_ context.Context, params *protocol.ReferenceParams) ([]protocol.Location, error) { 12 + state, ok := s.getDocState(params.TextDocument.URI) 13 + if !ok { 14 + return nil, nil 15 + } 16 + 17 + an := s.analysisFor(params.TextDocument.URI) 18 + if an == nil { 19 + return nil, nil 20 + } 21 + cursor := state.lineIdx.Offset(int(params.Position.Line), int(params.Position.Character)) 22 + ref := findSymbolUnderCursor(an, params.TextDocument.URI.Path(), state.text, cursor) 23 + if ref == nil { 24 + return nil, nil 25 + } 26 + return findReferences(an, ref, params.Context.IncludeDeclaration), nil 27 +} 28 + 29 +func findReferences(an *analyzer.Analysis, ref *symbolRef, includeDeclaration bool) []protocol.Location { 30 + switch ref.kind { 31 + case symbolAccount: 32 + return findAccountReferences(an, ref.name, includeDeclaration) 33 + case symbolCommodity: 34 + return findCommodityReferences(an, ref.name, includeDeclaration) 35 + case symbolPayee: 36 + return findPayeeReferences(an, ref.name, includeDeclaration) 37 + case symbolTag: 38 + return findTagReferences(an, ref.name, includeDeclaration) 39 + } 40 + return nil 41 +} 42 + 43 +func findAccountReferences(an *analyzer.Analysis, name string, includeDeclaration bool) []protocol.Location { 44 + var locations []protocol.Location 45 + for _, candidate := range an.AccountNames { 46 + if !accountMatches(candidate, name) { 47 + continue 48 + } 49 + info := an.Accounts[candidate] 50 + for _, u := range info.Usages { 51 + appendLocation(&locations, locationFor(an, u.FileIndex, u.Posting.Account.Span)) 52 + } 53 + if includeDeclaration { 54 + for _, d := range info.Directives { 55 + appendLocation(&locations, locationForDirective(an, d, d.Account.Span)) 56 + } 57 + } 58 + } 59 + return locations 60 +} 61 + 62 +func findCommodityReferences(an *analyzer.Analysis, symbol string, includeDeclaration bool) []protocol.Location { 63 + info := an.Commodities[symbol] 64 + if info == nil { 65 + return nil 66 + } 67 + var locations []protocol.Location 68 + for _, u := range info.Usages { 69 + appendLocation(&locations, locationFor(an, u.FileIndex, u.Amount.CommoditySpan)) 70 + } 71 + if includeDeclaration { 72 + for _, d := range info.Directives { 73 + appendLocation(&locations, locationForDirective(an, d, d.CommoditySpan)) 74 + } 75 + } 76 + return locations 77 +} 78 + 79 +func findPayeeReferences(an *analyzer.Analysis, name string, includeDeclaration bool) []protocol.Location { 80 + info := an.Payees[name] 81 + if info == nil { 82 + return nil 83 + } 84 + var locations []protocol.Location 85 + for _, u := range info.Usage { 86 + appendLocation(&locations, locationFor(an, u.FileIndex, u.Payee.Span)) 87 + } 88 + if includeDeclaration { 89 + for _, d := range info.Directives { 90 + if d.Name != nil { 91 + appendLocation(&locations, locationForDirective(an, d, d.Name.Span)) 92 + } 93 + } 94 + } 95 + return locations 96 +} 97 + 98 +func findTagReferences(an *analyzer.Analysis, key string, includeDeclaration bool) []protocol.Location { 99 + info := an.Tags[key] 100 + if info == nil { 101 + return nil 102 + } 103 + var locations []protocol.Location 104 + for _, u := range info.Usage { 105 + span := tagKeySpan(string(an.Files[u.FileIndex].Src), u.Tag) 106 + appendLocation(&locations, locationFor(an, u.FileIndex, span)) 107 + } 108 + if includeDeclaration { 109 + for _, d := range info.Directives { 110 + fileIdx := fileIndexForEntry(an, d) 111 + if fileIdx < 0 { 112 + continue 113 + } 114 + if span, ok := tagDirectiveSpan(string(an.Files[fileIdx].Src), d); ok { 115 + appendLocation(&locations, locationFor(an, fileIdx, span)) 116 + } 117 + } 118 + } 119 + return locations 120 +} 121 + 122 +func appendLocation(locations *[]protocol.Location, loc *protocol.Location) { 123 + if loc != nil { 124 + *locations = append(*locations, *loc) 125 + } 126 +}
A
internal/lsp/textdocument_references_test.go
··· 1 +package lsp 2 + 3 +import ( 4 + "context" 5 + "fmt" 6 + "path/filepath" 7 + "strings" 8 + "testing" 9 + "time" 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 TestServer_References_DocumentNotFound(t *testing.T) { 19 + srv := NewServer("test") 20 + res, err := srv.server.References(context.Background(), &protocol.ReferenceParams{ 21 + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ 22 + TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///nonexistent.journal")}, 23 + Position: protocol.Position{Line: 0, Character: 0}, 24 + }, 25 + }) 26 + if err != nil { 27 + t.Fatal(err) 28 + } 29 + if res != nil { 30 + t.Errorf("got %v, want nil", res) 31 + } 32 +} 33 + 34 +func TestGolden_References(t *testing.T) { 35 + for _, tt := range []string{"references-journal", "references-include"} { 36 + ar := golden.Read(t, tt) 37 + t.Run(tt, func(t *testing.T) { 38 + h := newTxtarHarness(t, ar) 39 + 40 + var b strings.Builder 41 + for _, incl := range []bool{false, true} { 42 + fmt.Fprintf(&b, "includeDeclaration:%v\n", incl) 43 + for i := range h.cursors { 44 + txtDocPos := h.textDocumentPosition(i) 45 + res, err := h.srv.References(t.Context(), &protocol.ReferenceParams{ 46 + Context: protocol.ReferenceContext{IncludeDeclaration: incl}, 47 + TextDocumentPositionParams: txtDocPos, 48 + }) 49 + if err != nil { 50 + t.Fatal(err) 51 + } 52 + if len(res) == 0 { 53 + fmt.Fprintf(&b, "%d:%d <none>\n", txtDocPos.Position.Line, txtDocPos.Position.Character) 54 + continue 55 + } 56 + for _, loc := range res { 57 + r := loc.Range 58 + fmt.Fprintf(&b, "%d:%d %s %d:%d-%d:%d\n", txtDocPos.Position.Line, txtDocPos.Position.Character, 59 + filepath.Base(loc.URI.Path()), 60 + r.Start.Line, r.Start.Character, r.End.Line, r.End.Character) 61 + } 62 + } 63 + } 64 + golden.Assert(t, ar, b.String()) 65 + }) 66 + } 67 +} 68 + 69 +func BenchmarkReferences(b *testing.B) { 70 + content := openJouranl(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal") 71 + 72 + srv := NewServer("test") 73 + u := uri.URI("file:///test.journal") 74 + srv.server.openDoc(u, content, 1, "journal") 75 + srv.server.analysisFor(u) // warm per-doc cache 76 + 77 + for tname, tt := range map[string]int{ 78 + "1k txns, account": strings.Index(content, "\n 1:2:3 ") + len("\n ") + 2, 79 + "1k txns, payee": strings.Index(content, "transaction 1") + len("transaction "), 80 + "1k txns, commodity": strings.Index(content, "2 B @@") + len("2 B"), 81 + "late tx, payee": strings.Index(content, "transaction 1000") + len("transaction "), 82 + } { 83 + b.Run(tname, func(b *testing.B) { 84 + line, col := lsputil.LineCol(content, tt) 85 + params := &protocol.ReferenceParams{ 86 + Context: protocol.ReferenceContext{IncludeDeclaration: true}, 87 + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ 88 + TextDocument: protocol.TextDocumentIdentifier{URI: u}, 89 + Position: protocol.Position{Line: uint32(line), Character: uint32(col)}, 90 + }, 91 + } 92 + 93 + // warm up: first request resolves the symbol lazily; assert it found references 94 + res, err := srv.server.References(b.Context(), params) 95 + if err != nil { 96 + b.Fatal(err) 97 + } 98 + if len(res) == 0 { 99 + b.Fatalf("%s: no references", tname) 100 + } 101 + 102 + b.ReportAllocs() 103 + b.ResetTimer() 104 + for b.Loop() { 105 + if _, err := srv.server.References(b.Context(), params); err != nil { 106 + b.Fatal(err) 107 + } 108 + } 109 + 110 + // guard: a whole-file re-parse per request (~13ms) would blow past 111 + // this and must be caught 112 + if avg := b.Elapsed() / time.Duration(b.N); avg > 5*time.Millisecond { 113 + b.Fatalf("references %v/op: reparse regression", avg) 114 + } 115 + }) 116 + } 117 +}
M
internal/lsp/textdocument_rename.go
··· 163 163 symbolTag 164 164 ) 165 165 166 +func (s symbolKind) ToProtocol() protocol.SymbolKind { 167 + switch s { 168 + case symbolAccount: 169 + return protocol.SymbolKindClass 170 + case symbolCommodity: 171 + return protocol.SymbolKindVariable 172 + case symbolPayee: 173 + return protocol.SymbolKindObject 174 + case symbolTag: 175 + return protocol.SymbolKindProperty 176 + } 177 + return protocol.SymbolKindFile 178 +} 179 + 166 180 // symbolRef is a symbol under the cursor, ready to be resolved or renamed. 167 181 type symbolRef struct { 168 182 kind symbolKind
A
internal/lsp/workspace_symbol.go
··· 1 +package lsp 2 + 3 +import ( 4 + "context" 5 + "sort" 6 + 7 + "go.lsp.dev/protocol" 8 + "go.lsp.dev/uri" 9 + 10 + "olexsmir.xyz/clerk/internal/analyzer" 11 + "olexsmir.xyz/clerk/internal/lsp/fuzzy" 12 +) 13 + 14 +func (s *server) Symbols(_ context.Context, params *protocol.WorkspaceSymbolParams) (protocol.WorkspaceSymbolResult, error) { 15 + if params.Query == "" { 16 + return nil, nil 17 + } 18 + 19 + s.mu.Lock() 20 + var u uri.URI 21 + for u = range s.openDocs { 22 + break 23 + } 24 + s.mu.Unlock() 25 + an := s.analysisFor(u) 26 + if an == nil { 27 + return nil, nil 28 + } 29 + 30 + symbols := searchSymbols(an, params.Query) 31 + if len(symbols) == 0 { 32 + return nil, nil 33 + } 34 + return protocol.WorkspaceSymbolSlice(symbols), nil 35 +} 36 + 37 +const maxSymbolResults = 100 38 + 39 +type scoredSymbol struct { 40 + kind symbolKind 41 + name string 42 + score float64 43 +} 44 + 45 +func searchSymbols(an *analyzer.Analysis, query string) []protocol.WorkspaceSymbol { 46 + 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}) 52 + } 53 + } 54 + for _, name := range an.AccountNames { 55 + add(symbolAccount, name) 56 + } 57 + for name := range an.Commodities { 58 + add(symbolCommodity, name) 59 + } 60 + for _, name := range an.PayeeNames { 61 + add(symbolPayee, name) 62 + } 63 + for _, name := range an.TagNames { 64 + add(symbolTag, name) 65 + } 66 + 67 + sortScoredSymbols(scored) 68 + if len(scored) > maxSymbolResults { 69 + scored = scored[:maxSymbolResults] 70 + } 71 + 72 + symbols := make([]protocol.WorkspaceSymbol, 0, len(scored)) 73 + for _, s := range scored { 74 + loc := definitionLocation(an, s.kind, s.name) 75 + if loc == nil { 76 + continue 77 + } 78 + symbols = append(symbols, protocol.WorkspaceSymbol{ 79 + BaseSymbolInformation: protocol.BaseSymbolInformation{ 80 + Name: s.name, 81 + Kind: s.kind.ToProtocol(), 82 + }, 83 + Location: loc, 84 + }) 85 + } 86 + return symbols 87 +} 88 + 89 +func definitionLocation(an *analyzer.Analysis, kind symbolKind, name string) *protocol.Location { 90 + switch kind { 91 + case symbolAccount: 92 + return findAccountDefinition(an, name) 93 + case symbolCommodity: 94 + return findCommodityDefinition(an, name) 95 + case symbolPayee: 96 + return findPayeeDefinition(an, name) 97 + case symbolTag: 98 + return findTagDefinition(an, name) 99 + } 100 + return nil 101 +} 102 + 103 +func sortScoredSymbols(scored []scoredSymbol) { 104 + sort.Slice(scored, func(i, j int) bool { 105 + if scored[i].score != scored[j].score { 106 + return scored[i].score > scored[j].score 107 + } 108 + return scored[i].name < scored[j].name 109 + }) 110 +}
A
internal/lsp/workspace_symbol_test.go
··· 1 +package lsp 2 + 3 +import ( 4 + "context" 5 + "fmt" 6 + "path/filepath" 7 + "strings" 8 + "testing" 9 + "time" 10 + 11 + "go.lsp.dev/protocol" 12 + "go.lsp.dev/uri" 13 + 14 + "olexsmir.xyz/clerk/internal/testutil/golden" 15 +) 16 + 17 +func TestSortScoredSymbols(t *testing.T) { 18 + scored := []scoredSymbol{ 19 + {symbolAccount, "zz:zz", 0.5}, 20 + {symbolTag, "aa", 1.0}, 21 + {symbolPayee, "bb", 0.5}, 22 + } 23 + sortScoredSymbols(scored) 24 + 25 + want := []string{"aa", "bb", "zz:zz"} 26 + for i, s := range scored { 27 + if s.name != want[i] { 28 + t.Errorf("pos %d = %q, want %q", i, s.name, want[i]) 29 + } 30 + } 31 +} 32 + 33 +func TestServer_Symbols_EmptyQuery(t *testing.T) { 34 + srv := NewServer("test") 35 + u := uri.URI("file:///test.journal") 36 + srv.server.openDoc(u, "account expenses:food\n", 1, "journal") 37 + 38 + res, err := srv.server.Symbols(context.Background(), &protocol.WorkspaceSymbolParams{Query: ""}) 39 + if err != nil { 40 + t.Fatal(err) 41 + } 42 + if res != nil { 43 + t.Errorf("got %v, want nil for empty query", res) 44 + } 45 +} 46 + 47 +func TestGolden_Symbols(t *testing.T) { 48 + ar := golden.Read(t, "workspace-symbol") 49 + t.Run("workspace-symbol", func(t *testing.T) { 50 + h := newTxtarHarness(t, ar) 51 + 52 + // One query per line; a trailing blank line is the empty-query case. 53 + queries := strings.Split(strings.TrimSuffix(string(ar.Get("queries")), "\n"), "\n") 54 + var b strings.Builder 55 + for _, q := range queries { 56 + res, err := h.srv.Symbols(t.Context(), &protocol.WorkspaceSymbolParams{Query: q}) 57 + if err != nil { 58 + t.Fatal(err) 59 + } 60 + if res == nil { 61 + fmt.Fprintf(&b, "%q <none>\n", q) 62 + continue 63 + } 64 + list, ok := res.(protocol.WorkspaceSymbolSlice) 65 + if !ok { 66 + t.Fatalf("Symbols returned %T, want WorkspaceSymbolSlice", res) 67 + } 68 + for _, sym := range list { 69 + loc, ok := sym.Location.(*protocol.Location) 70 + if !ok { 71 + t.Fatalf("Symbol %q location is %T, want *Location", sym.Name, sym.Location) 72 + } 73 + r := loc.Range 74 + fmt.Fprintf(&b, "%q %s %s %s %d:%d-%d:%d\n", q, symbolKindName(sym.Kind), sym.Name, 75 + filepath.Base(loc.URI.Path()), 76 + r.Start.Line, r.Start.Character, r.End.Line, r.End.Character) 77 + } 78 + } 79 + golden.Assert(t, ar, b.String()) 80 + }) 81 +} 82 + 83 +func BenchmarkSymbols(b *testing.B) { 84 + content := openJouranl(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal") 85 + 86 + srv := NewServer("test") 87 + u := uri.URI("file:///test.journal") 88 + srv.server.openDoc(u, content, 1, "journal") 89 + srv.server.analysisFor(u) // warm the per-doc cache 90 + 91 + for tname, tt := range map[string]struct { 92 + query string 93 + want bool // expect non-empty results 94 + }{ 95 + "account prefix": {"1:2", true}, 96 + "all payees": {"transaction", true}, 97 + "mixed kinds": {"B", true}, 98 + "no match": {"xyz", false}, 99 + } { 100 + b.Run(tname, func(b *testing.B) { 101 + params := &protocol.WorkspaceSymbolParams{Query: tt.query} 102 + 103 + // warm up: assert the query matches as expected 104 + res, err := srv.server.Symbols(b.Context(), params) 105 + if err != nil { 106 + b.Fatal(err) 107 + } 108 + if (res != nil) != tt.want { 109 + b.Fatalf("%s: query %q: got res==nil=%v, want %v", tname, tt.query, res == nil, !tt.want) 110 + } 111 + b.ReportAllocs() 112 + b.ResetTimer() 113 + for b.Loop() { 114 + if _, err := srv.server.Symbols(b.Context(), params); err != nil { 115 + b.Fatal(err) 116 + } 117 + } 118 + 119 + // guard: a whole-file re-parse per request (~13ms) would blow past 120 + // this and must be caught 121 + if avg := b.Elapsed() / time.Duration(b.N); avg > 5*time.Millisecond { 122 + b.Fatalf("symbols %v/op: reparse regression", avg) 123 + } 124 + }) 125 + } 126 +} 127 + 128 +func symbolKindName(k protocol.SymbolKind) string { 129 + switch k { 130 + case protocol.SymbolKindClass: 131 + return "class" 132 + case protocol.SymbolKindVariable: 133 + return "variable" 134 + case protocol.SymbolKindObject: 135 + return "object" 136 + case protocol.SymbolKindProperty: 137 + return "property" 138 + } 139 + return "other" 140 +}