4 files changed,
268 insertions(+),
0 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-09-02 16:53:38 +0300
Authored at:
2026-09-02 16:24:06 +0300
Change ID:
mzpqsozszpwklotovxzywpusmtlvyqox
Parent:
7596b2a
M
internal/lsp/server.go
··· 108 108 CompletionProvider: &protocol.CompletionOptions{ 109 109 TriggerCharacters: []string{":", "@"}, 110 110 }, 111 + FoldingRangeProvider: protocol.Boolean(true), 111 112 TextDocumentSync: &protocol.TextDocumentSyncOptions{ 112 113 OpenClose: new(true), 113 114 Change: new(protocol.TextDocumentSyncKindFull),
A
internal/lsp/testdata/folding-range.txtar
··· 1 +-- in.journal -- 2 +account assets:cash 3 + type A 4 + note savings 5 + ; indented comment 6 + 7 +; block one 8 +; block one line two 9 + 10 +comment big 11 +some ignored text 12 +end comment 13 + 14 +apply tag work 15 +2024-01-01 t 16 + expenses:food $10 17 + assets:cash 18 +end tag 19 + 20 +2024-01-01 multi 21 + expenses:food $10 22 + assets:cash $-10 23 + 24 +2024-01-01 single 25 + expenses:food $10 26 + 27 +2024-01-02 ~ monthly 28 + expenses:food $10 29 + expenses:something $10 30 + assets:cash $-20 31 + 32 += expr 33 + expenses:food $10 34 + assets:cash $-10 35 +^ 36 + 37 +-- expect -- 38 +1-3 region 39 +5-6 comment 40 +8-10 comment 41 +12-16 region 42 +14-15 region 43 +19-20 region 44 +26-28 region 45 +31-32 region
A
internal/lsp/textdocument_folding_range.go
··· 1 +package lsp 2 + 3 +import ( 4 + "cmp" 5 + "context" 6 + "slices" 7 + "strings" 8 + 9 + "go.lsp.dev/protocol" 10 + 11 + "olexsmir.xyz/clerk/journal/ast" 12 +) 13 + 14 +func (s *server) FoldingRange(_ context.Context, params *protocol.FoldingRangeParams) ([]protocol.FoldingRange, error) { 15 + an := s.analysisFor(params.TextDocument.URI) 16 + if an == nil { 17 + return nil, nil 18 + } 19 + for _, pf := range an.Files { 20 + if pf.Path != params.TextDocument.URI.Path() { 21 + continue 22 + } 23 + ranges := foldingRangesFor(pf.Ast.Entries) 24 + slices.SortFunc(ranges, func(a, b protocol.FoldingRange) int { 25 + return cmp.Compare(a.StartLine, b.StartLine) 26 + }) 27 + return ranges, nil 28 + } 29 + return nil, nil 30 +} 31 + 32 +// foldingRangesFor folds every foldable structure in a journal's top-level entries: 33 +// posting blocks, directive sub-blocks, comment runs, comment blocks, and apply/end pairs. 34 +func foldingRangesFor(entries []ast.Entry) []protocol.FoldingRange { 35 + ranges := make([]protocol.FoldingRange, 0, len(entries)) // each entry folds to at most one range 36 + var ( 37 + comments []*ast.Comment // consecutive comment lines, flushed at the next non-comment entry 38 + applies []*ast.ApplyDirective 39 + ) 40 + 41 + flush := func() { 42 + ranges = appendFold(ranges, commentRunFold(comments)) 43 + comments = nil 44 + } 45 + 46 + for _, entry := range entries { 47 + if c, ok := entry.(*ast.Comment); ok { 48 + comments = append(comments, c) 49 + continue 50 + } 51 + flush() 52 + 53 + switch e := entry.(type) { 54 + case *ast.Transaction: 55 + ranges = appendFold(ranges, postingsFold(e.Postings)) 56 + case *ast.PeriodicTransaction: 57 + ranges = appendFold(ranges, postingsFold(e.Postings)) 58 + case *ast.AutomatedTransaction: 59 + ranges = appendFold(ranges, postingsFold(e.Postings)) 60 + case *ast.AccountDirective: 61 + ranges = appendFold(ranges, accountDirectiveFold(e)) 62 + case *ast.CommentBlockDirective: 63 + ranges = appendFold(ranges, commentBlockDirectiveFold(e)) 64 + case *ast.ApplyDirective: 65 + applies = append(applies, e) 66 + case *ast.EndDirective: 67 + if n := len(applies); n > 0 && applyKeyword(applies[n-1].Expr) == applyKeyword(e.Expr) { 68 + a := applies[n-1] 69 + applies = applies[:n-1] 70 + ranges = appendFold(ranges, applyFold(a, e)) 71 + } 72 + } 73 + } 74 + flush() 75 + return ranges 76 +} 77 + 78 +// appendFold appends r, skipping nil. 79 +func appendFold(ranges []protocol.FoldingRange, r *protocol.FoldingRange) []protocol.FoldingRange { 80 + if r != nil { 81 + ranges = append(ranges, *r) 82 + } 83 + return ranges 84 +} 85 + 86 +// postingsFold folds the posting block of a transaction, leaving the header visible. 87 +func postingsFold(postings []*ast.Posting) *protocol.FoldingRange { 88 + if len(postings) < 2 { 89 + return nil 90 + } 91 + first, last := postings[0], postings[len(postings)-1] 92 + return foldRange( 93 + uint32(first.Span.Start.Line-1), 94 + uint32(last.Span.Start.Line-1), 95 + protocol.FoldingRangeKindRegion, 96 + ) 97 +} 98 + 99 +// accountDirectiveFold folds the indented subdirective block after an "account" line. 100 +func accountDirectiveFold(ad *ast.AccountDirective) *protocol.FoldingRange { 101 + if len(ad.Subdirectives) < 2 { 102 + return nil 103 + } 104 + first, last := ad.Subdirectives[0], ad.Subdirectives[len(ad.Subdirectives)-1] 105 + return foldRange( 106 + uint32(first.NameSpan.Start.Line-1), 107 + uint32(last.NameSpan.Start.Line-1), 108 + protocol.FoldingRangeKindRegion, 109 + ) 110 +} 111 + 112 +// commentBlockDirectiveFold folds a "comment" ... "end comment" block, leaving the header visible. 113 +// The directive span extends past the block, so the end line is derived from the content. 114 +func commentBlockDirectiveFold(cb *ast.CommentBlockDirective) *protocol.FoldingRange { 115 + start := uint32(cb.Span.Start.Line - 1) 116 + end := start + uint32(strings.Count(cb.Content, "\n")) + 1 // +1 for the "end comment" line 117 + return foldRange(start, end, protocol.FoldingRangeKindComment) 118 +} 119 + 120 +// commentRunFold folds consecutive comment lines into a single range. 121 +func commentRunFold(comments []*ast.Comment) *protocol.FoldingRange { 122 + if len(comments) < 2 { 123 + return nil 124 + } 125 + return foldRange( 126 + uint32(comments[0].Span.Start.Line-1), 127 + uint32(comments[len(comments)-1].Span.Start.Line-1), 128 + protocol.FoldingRangeKindComment, 129 + ) 130 +} 131 + 132 +// applyKeyword returns the first word of an apply/end directive expr ("apply tag work" → "tag"). 133 +func applyKeyword(expr string) string { 134 + if i := strings.IndexByte(expr, ' '); i >= 0 { 135 + return expr[:i] 136 + } 137 + return expr 138 +} 139 + 140 +// applyFold folds an "apply" ... "end" block. 141 +func applyFold(a *ast.ApplyDirective, end *ast.EndDirective) *protocol.FoldingRange { 142 + return foldRange( 143 + uint32(a.Span.Start.Line-1), 144 + uint32(end.Span.Start.Line-1), 145 + protocol.FoldingRangeKindRegion, 146 + ) 147 +} 148 + 149 +// foldRange returns nil when the range is not foldable (single line). 150 +func foldRange(startLine, endLine uint32, kind protocol.FoldingRangeKind) *protocol.FoldingRange { 151 + if endLine <= startLine { 152 + return nil 153 + } 154 + return &protocol.FoldingRange{StartLine: startLine, EndLine: endLine, Kind: kind} 155 +}
A
internal/lsp/textdocument_folding_range_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_FoldingRange(t *testing.T) { 16 + ar := golden.Read(t, "folding-range") 17 + h := newTxtarHarness(t, ar) 18 + 19 + res, err := h.srv.FoldingRange(t.Context(), &protocol.FoldingRangeParams{ 20 + TextDocument: protocol.TextDocumentIdentifier{URI: h.uri}, 21 + }) 22 + if err != nil { 23 + t.Fatal(err) 24 + } 25 + 26 + var b strings.Builder 27 + for _, r := range res { 28 + fmt.Fprintf(&b, "%d-%d %s\n", r.StartLine, r.EndLine, r.Kind) 29 + } 30 + golden.Assert(t, ar, b.String()) 31 +} 32 + 33 +func BenchmarkFoldingRange(b *testing.B) { 34 + content := openJournal(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal") 35 + 36 + srv := newServer(b) 37 + u := uri.URI("file:///test.journal") 38 + srv.server.openDoc(u, content, 1, "journal") 39 + srv.server.analysisFor(u) // warm the per-doc cache 40 + 41 + params := &protocol.FoldingRangeParams{ 42 + TextDocument: protocol.TextDocumentIdentifier{URI: u}, 43 + } 44 + 45 + // warm up: assert the ranges are populated 46 + res, err := srv.server.FoldingRange(b.Context(), params) 47 + if err != nil { 48 + b.Fatal(err) 49 + } 50 + if len(res) == 0 { 51 + b.Fatalf("FoldingRange returned 0 ranges") 52 + } 53 + 54 + b.ReportAllocs() 55 + b.ResetTimer() 56 + for b.Loop() { 57 + if _, err := srv.server.FoldingRange(b.Context(), params); err != nil { 58 + b.Fatal(err) 59 + } 60 + } 61 + 62 + // guard: folding iterates the AST once per request; a whole-file 63 + // re-parse or quadratic scan must be caught 64 + if avg := b.Elapsed() / time.Duration(b.N); avg > 5*time.Millisecond { 65 + b.Fatalf("foldingRange %v/op: regression", avg) 66 + } 67 +}