5 files changed,
190 insertions(+),
11 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-08-16 12:25:42 +0300
Authored at:
2026-08-15 22:43:43 +0300
Change ID:
qzrzywpxtovlkrtzvxqmxpnoxorzsrmn
Parent:
cc429b9
M
internal/lsp/server.go
··· 84 84 HoverProvider: protocol.Boolean(true), 85 85 ReferencesProvider: protocol.Boolean(true), 86 86 WorkspaceSymbolProvider: protocol.Boolean(true), 87 + DocumentSymbolProvider: protocol.Boolean(true), 87 88 RenameProvider: &protocol.RenameOptions{ 88 89 PrepareProvider: new(true), 89 90 },
A
internal/lsp/testdata/document-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 +2024-01-16 * Coffee Shop | cappuccino 11 + expenses:taxi $4 12 + assets:cash $-4 13 + 14 +payee Grocery Store 15 +payee Coffee Shop 16 + 17 +commodity USD 18 +commodity EUR 19 + 20 +tag project 21 +tag client 22 + 23 +~ monthly 24 + expenses:food $10 25 + 26 += expenses:food 27 + expenses:food $-1 28 +^ 29 + 30 +-- expect -- 31 +"expenses:food" class 0:0-0:21 0:8-0:21 32 +"expenses:taxi" class 1:0-1:21 1:8-1:21 33 +"assets:cash" class 2:0-2:19 2:8-2:19 34 +"2024-01-15 Grocery Store" event 4:0-6:21 4:0-4:10 35 +"2024-01-16 * Coffee Shop | cappuccino" event 8:0-10:20 8:0-8:10 36 +"Grocery Store" object 12:0-12:19 12:6-12:19 37 +"Coffee Shop" object 13:0-13:17 13:6-13:17 38 +"USD" variable 15:0-15:13 15:10-15:13 39 +"EUR" variable 16:0-16:13 16:10-16:13 40 +"project" property 18:0-18:11 18:4-18:11 41 +"client" property 19:0-19:10 19:4-19:10 42 +"~ monthly" event 21:0-22:22 21:2-21:9 43 +"= expenses:food" event 24:0-25:22 24:2-24:15
M
internal/lsp/textdocument_definition.go
··· 168 168 } 169 169 } 170 170 171 -// spanRangeFromSrc converts a span to a protocol range. Parsed spans carry 172 -// 1-based Line/Col and are converted directly; spans whose end runs into 173 -// trailing whitespace or uses the next-line-start convention (Col == 0) get a 174 -// one-line scan back from the end offset. 175 171 func spanRangeFromSrc(src []byte, span token.Span) protocol.Range { 176 172 if span.Start.Line == 0 || span.End.Line == 0 { 177 - // spans built from offsets without Line/Col: full line index 178 173 return lsputil.NewLineIndex(string(src)).SpanRange(span) 179 174 } 180 175 start := protocol.Position{Line: uint32(span.Start.Line - 1), Character: uint32(span.Start.Col - 1)} ··· 183 178 // the span's stored end position matches its offset 184 179 return protocol.Range{Start: start, End: protocol.Position{Line: uint32(span.End.Line - 1), Character: uint32(span.End.Col - 1)}} 185 180 } 186 - // Trim trailing whitespace back from the end offset; both scans are 181 + // trim trailing whitespace back from the end offset; both scans are 187 182 // bounded by the one line the span ends on. 188 - line := span.End.Line - 1 // 1-based line holding the end 183 + line := span.End.Line - 1 // 0-based line of the end, decremented per newline trimmed 184 + if end < len(src) && src[end] == '\n' { 185 + line-- // end sits on a newline, which the parser records as the next line's start 186 + } 189 187 for end > span.Start.Offset && isSpanSpace(src[end-1]) { 190 188 if src[end-1] == '\n' { 191 189 line-- ··· 199 197 return protocol.Range{ 200 198 Start: start, 201 199 End: protocol.Position{ 202 - Line: uint32(line - 1), 200 + Line: uint32(line), 203 201 Character: uint32(lsputil.Utf16ColBytes(src[lineStart:end])), 204 202 }, 205 203 } ··· 221 219 return span.Start.Offset <= offset && offset <= end 222 220 } 223 221 224 -// entryAt returns the entry whose start offset is at or before cursor, the 225 -// only entry whose tokens can contain it. Entries are stored in file order, 226 -// so a binary search replaces a linear scan for late-cursor requests. 227 222 func entryAt(entries []ast.Entry, cursor int) ast.Entry { 228 223 idx := sort.Search(len(entries), func(i int) bool { return entryStart(entries[i]) > cursor }) - 1 229 224 if idx < 0 {
A
internal/lsp/textdocument_document_symbol.go
··· 1 +package lsp 2 + 3 +import ( 4 + "context" 5 + 6 + "go.lsp.dev/protocol" 7 + 8 + "olexsmir.xyz/clerk/journal/ast" 9 + "olexsmir.xyz/clerk/journal/token" 10 +) 11 + 12 +func (s *server) DocumentSymbol(_ context.Context, params *protocol.DocumentSymbolParams) (protocol.DocumentSymbolResult, error) { 13 + an := s.analysisFor(params.TextDocument.URI) 14 + if an == nil { 15 + return nil, nil 16 + } 17 + for _, pf := range an.Files { 18 + if pf.Path == params.TextDocument.URI.Path() { 19 + symbols := make([]protocol.DocumentSymbol, 0, len(pf.Ast.Entries)/2) 20 + for _, entry := range pf.Ast.Entries { 21 + if symbol, ok := entryDocumentSymbol(entry, pf.Src); ok { 22 + symbols = append(symbols, symbol) 23 + } 24 + } 25 + return protocol.DocumentSymbolSlice(symbols), nil 26 + } 27 + } 28 + return nil, nil 29 +} 30 + 31 +func entryDocumentSymbol(e ast.Entry, src []byte) (protocol.DocumentSymbol, bool) { 32 + var kind symbolKind 33 + var name string 34 + var sel, whole token.Span 35 + switch e := e.(type) { 36 + case *ast.Transaction: 37 + kind, name, sel, whole = symbolTransaction, transactionName(e), e.Date.Span, e.Span 38 + case *ast.PeriodicTransaction: 39 + kind, name, sel, whole = symbolTransaction, transactionName(e), e.Period.Span, e.Span 40 + case *ast.AutomatedTransaction: 41 + kind, name, sel, whole = symbolTransaction, transactionName(e), e.Expr.Span, e.Span 42 + case *ast.AccountDirective: 43 + kind, name, sel, whole = symbolAccount, e.Account.String(), e.Account.Span, e.Span 44 + case *ast.CommodityDirective: 45 + kind, name, sel, whole = symbolCommodity, e.Commodity, e.CommoditySpan, e.Span 46 + case *ast.PayeeDirective: 47 + if e.Name == nil { 48 + return protocol.DocumentSymbol{}, false 49 + } 50 + kind, name, sel, whole = symbolPayee, e.Name.Name, e.Name.Span, e.Span 51 + case *ast.TagDirective: 52 + sp, ok := tagDirectiveSpan(string(src), e) 53 + if !ok { 54 + return protocol.DocumentSymbol{}, false 55 + } 56 + kind, name, sel, whole = symbolTag, e.Name, sp, e.Span 57 + default: 58 + return protocol.DocumentSymbol{}, false 59 + } 60 + return protocol.DocumentSymbol{ 61 + Name: name, 62 + Kind: kind.ToProtocol(), 63 + Range: spanRangeFromSrc(src, whole), 64 + SelectionRange: spanRangeFromSrc(src, sel), 65 + }, true 66 +}
A
internal/lsp/textdocument_document_symbol_test.go
··· 1 +package lsp 2 + 3 +import ( 4 + "fmt" 5 + "strings" 6 + "testing" 7 + "time" 8 + 9 + "go.lsp.dev/protocol" 10 + "go.lsp.dev/uri" 11 + 12 + "olexsmir.xyz/clerk/internal/testutil/golden" 13 +) 14 + 15 +func TestGolden_DocumentSymbols(t *testing.T) { 16 + ar := golden.Read(t, "document-symbol") 17 + h := newTxtarHarness(t, ar) 18 + 19 + res, err := h.srv.DocumentSymbol(t.Context(), &protocol.DocumentSymbolParams{ 20 + TextDocument: protocol.TextDocumentIdentifier{URI: h.uri}, 21 + }) 22 + if err != nil { 23 + t.Fatal(err) 24 + } 25 + list, ok := res.(protocol.DocumentSymbolSlice) 26 + if !ok { 27 + t.Fatalf("DocumentSymbol returned %T, want DocumentSymbolSlice", res) 28 + } 29 + var b strings.Builder 30 + for _, sym := range list { 31 + r, sel := sym.Range, sym.SelectionRange 32 + fmt.Fprintf(&b, "%q %s %d:%d-%d:%d %d:%d-%d:%d\n", sym.Name, symbolKindName(sym.Kind), 33 + r.Start.Line, r.Start.Character, r.End.Line, r.End.Character, 34 + sel.Start.Line, sel.Start.Character, sel.End.Line, sel.End.Character) 35 + } 36 + golden.Assert(t, ar, b.String()) 37 +} 38 + 39 +func BenchmarkDocumentSymbol(b *testing.B) { 40 + content := openJournal(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal") 41 + 42 + srv := NewServer("test") 43 + u := uri.URI("file:///test.journal") 44 + srv.server.openDoc(u, content, 1, "journal") 45 + srv.server.analysisFor(u) // warm the per-doc cache 46 + 47 + params := &protocol.DocumentSymbolParams{ 48 + TextDocument: protocol.TextDocumentIdentifier{URI: u}, 49 + } 50 + 51 + // warm up: assert the outline is populated 52 + res, err := srv.server.DocumentSymbol(b.Context(), params) 53 + if err != nil { 54 + b.Fatal(err) 55 + } 56 + list, ok := res.(protocol.DocumentSymbolSlice) 57 + if !ok || len(list) == 0 { 58 + b.Fatalf("DocumentSymbol returned %T with %d symbols, want non-empty slice", res, len(list)) 59 + } 60 + 61 + b.ReportAllocs() 62 + b.ResetTimer() 63 + for b.Loop() { 64 + if _, err := srv.server.DocumentSymbol(b.Context(), params); err != nil { 65 + b.Fatal(err) 66 + } 67 + } 68 + 69 + // guard: documentSymbol maps every entry per request; a whole-file 70 + // re-parse or quadratic scan must be caught 71 + if avg := b.Elapsed() / time.Duration(b.N); avg > 5*time.Millisecond { 72 + b.Fatalf("documentSymbol %v/op: regression", avg) 73 + } 74 +}