all repos

clerk @ a2ef079

missing tooling for ledger/hledger
4 files changed, 268 insertions(+), 0 deletions(-)
lsp: textdocument/foldingRange
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
        +}