all repos

clerk @ ed6cfe6e32c6b1ff0a6e73bff077324b22452afa

missing tooling for ledger/hledger

clerk/internal/lsp/textdocument_folding_range.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
lsp: textdocument/selectionRange..., 1 month ago
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
	pf := parsedFileFor(an, params.TextDocument.URI.Path())
20
	if pf == nil {
21
		return nil, nil
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
30
// foldingRangesFor folds every foldable structure in a journal's top-level entries:
31
// posting blocks, directive sub-blocks, comment runs, comment blocks, and apply/end pairs.
32
func foldingRangesFor(entries []ast.Entry) []protocol.FoldingRange {
33
	ranges := make([]protocol.FoldingRange, 0, len(entries)) // each entry folds to at most one range
34
	var (
35
		comments []*ast.Comment // consecutive comment lines, flushed at the next non-comment entry
36
		applies  []*ast.ApplyDirective
37
	)
38
39
	flush := func() {
40
		ranges = appendFold(ranges, commentRunFold(comments))
41
		comments = nil
42
	}
43
44
	for _, entry := range entries {
45
		if c, ok := entry.(*ast.Comment); ok {
46
			comments = append(comments, c)
47
			continue
48
		}
49
		flush()
50
51
		switch e := entry.(type) {
52
		case *ast.Transaction:
53
			ranges = appendFold(ranges, postingsFold(e.Postings))
54
		case *ast.PeriodicTransaction:
55
			ranges = appendFold(ranges, postingsFold(e.Postings))
56
		case *ast.AutomatedTransaction:
57
			ranges = appendFold(ranges, postingsFold(e.Postings))
58
		case *ast.AccountDirective:
59
			ranges = appendFold(ranges, accountDirectiveFold(e))
60
		case *ast.CommentBlockDirective:
61
			ranges = appendFold(ranges, commentBlockDirectiveFold(e))
62
		case *ast.ApplyDirective:
63
			applies = append(applies, e)
64
		case *ast.EndDirective:
65
			if n := len(applies); n > 0 && applyKeyword(applies[n-1].Expr) == applyKeyword(e.Expr) {
66
				a := applies[n-1]
67
				applies = applies[:n-1]
68
				ranges = appendFold(ranges, applyFold(a, e))
69
			}
70
		}
71
	}
72
	flush()
73
	return ranges
74
}
75
76
// appendFold appends r, skipping nil.
77
func appendFold(ranges []protocol.FoldingRange, r *protocol.FoldingRange) []protocol.FoldingRange {
78
	if r != nil {
79
		ranges = append(ranges, *r)
80
	}
81
	return ranges
82
}
83
84
// postingsFold folds the posting block of a transaction, leaving the header visible.
85
func postingsFold(postings []ast.Posting) *protocol.FoldingRange {
86
	if len(postings) < 2 {
87
		return nil
88
	}
89
	first, last := postings[0], postings[len(postings)-1]
90
	return foldRange(
91
		uint32(first.Span.Start.Line-1),
92
		uint32(last.Span.Start.Line-1),
93
		protocol.FoldingRangeKindRegion,
94
	)
95
}
96
97
// accountDirectiveFold folds the indented subdirective block after an "account" line.
98
func accountDirectiveFold(ad *ast.AccountDirective) *protocol.FoldingRange {
99
	if len(ad.Subdirectives) < 2 {
100
		return nil
101
	}
102
	first, last := ad.Subdirectives[0], ad.Subdirectives[len(ad.Subdirectives)-1]
103
	return foldRange(
104
		uint32(first.NameSpan.Start.Line-1),
105
		uint32(last.NameSpan.Start.Line-1),
106
		protocol.FoldingRangeKindRegion,
107
	)
108
}
109
110
// commentBlockDirectiveFold folds a "comment" ... "end comment" block, leaving the header visible.
111
// The directive span extends past the block, so the end line is derived from the content.
112
func commentBlockDirectiveFold(cb *ast.CommentBlockDirective) *protocol.FoldingRange {
113
	start := uint32(cb.Span.Start.Line - 1)
114
	end := start + uint32(strings.Count(cb.Content, "\n")) + 1 // +1 for the "end comment" line
115
	return foldRange(start, end, protocol.FoldingRangeKindComment)
116
}
117
118
// commentRunFold folds consecutive comment lines into a single range.
119
func commentRunFold(comments []*ast.Comment) *protocol.FoldingRange {
120
	if len(comments) < 2 {
121
		return nil
122
	}
123
	return foldRange(
124
		uint32(comments[0].Span.Start.Line-1),
125
		uint32(comments[len(comments)-1].Span.Start.Line-1),
126
		protocol.FoldingRangeKindComment,
127
	)
128
}
129
130
// applyKeyword returns the first word of an apply/end directive expr ("apply tag work" → "tag").
131
func applyKeyword(expr string) string {
132
	if i := strings.IndexByte(expr, ' '); i >= 0 {
133
		return expr[:i]
134
	}
135
	return expr
136
}
137
138
// applyFold folds an "apply" ... "end" block.
139
func applyFold(a *ast.ApplyDirective, end *ast.EndDirective) *protocol.FoldingRange {
140
	return foldRange(
141
		uint32(a.Span.Start.Line-1),
142
		uint32(end.Span.Start.Line-1),
143
		protocol.FoldingRangeKindRegion,
144
	)
145
}
146
147
// foldRange returns nil when the range is not foldable (single line).
148
func foldRange(startLine, endLine uint32, kind protocol.FoldingRangeKind) *protocol.FoldingRange {
149
	if endLine <= startLine {
150
		return nil
151
	}
152
	return &protocol.FoldingRange{StartLine: startLine, EndLine: endLine, Kind: kind}
153
}