all repos

clerk @ 9d057cb

missing tooling for ledger/hledger
5 files changed, 190 insertions(+), 11 deletions(-)
lsp: implement textdocument/documentSymbol
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
        +}