all repos

clerk @ 2f348cb

missing tooling for ledger/hledger
5 files changed, 134 insertions(+), 46 deletions(-)
lsp: list transactions in workspace/symbol
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-08-16 12:25:42 +0300
Authored at: 2026-08-15 22:21:40 +0300
Change ID: tyspkpmvvwyxrltsuowssxtqtpkzuwqs
Parent: b238a8c
M internal/lsp/testdata/workspace-symbol.txtar
···
        4
        4
         account assets:cash

      
        5
        5
         

      
        6
        6
         2024-01-15 Grocery Store ; project:work

      
        7
        
        -    expenses:food  $50

      
        8
        
        -    assets:cash  $-50

      
        
        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
        +^

      
        9
        15
         

      
        10
        16
         payee Grocery Store

      
        11
        17
         payee Coffee Shop

      ···
        15
        21
         

      
        16
        22
         tag project

      
        17
        23
         tag client

      
        18
        
        -^

      
        
        24
        +

      
        
        25
        +~ monthly

      
        
        26
        +  expenses:food  $10

      
        
        27
        +

      
        
        28
        += expenses:food

      
        
        29
        + expenses:food  $-1

      
        19
        30
         

      
        20
        31
         -- queries --

      
        21
        32
         food

      ···
        24
        35
         EUR

      
        25
        36
         project

      
        26
        37
         xyz

      
        
        38
        +cappuccino

      
        
        39
        +monthly

      
        
        40
        +2024

      
        
        41
        +expenses:food

      
        27
        42
         

      
        28
        43
         -- expect --

      
        29
        44
         "food" class expenses:food in.journal 0:8-0:21

      
        
        45
        +"food" event = expenses:food in.journal 26:2-26:15

      
        30
        46
         "cash" class assets:cash in.journal 2:8-2:19

      
        31
        
        -"grocery" object Grocery Store in.journal 8:6-8:19

      
        32
        
        -"EUR" variable EUR in.journal 12:10-12:13

      
        33
        
        -"project" property project in.journal 14:4-14:11

      
        
        47
        +"grocery" object Grocery Store in.journal 14:6-14:19

      
        
        48
        +"grocery" event 2024-01-15 Grocery Store in.journal 4:0-4:10

      
        
        49
        +"EUR" variable EUR in.journal 18:10-18:13

      
        
        50
        +"project" property project in.journal 20:4-20:11

      
        34
        51
         "xyz" <none>

      
        
        52
        +"cappuccino" event 2024-01-16 * Coffee Shop | cappuccino in.journal 8:0-8:10

      
        
        53
        +"monthly" event ~ monthly in.journal 23:2-23:9

      
        
        54
        +"2024" event 2024-01-15 Grocery Store in.journal 4:0-4:10

      
        
        55
        +"2024" event 2024-01-16 * Coffee Shop | cappuccino in.journal 8:0-8:10

      
        
        56
        +"expenses:food" class expenses:food in.journal 0:8-0:21

      
        
        57
        +"expenses:food" event = expenses:food in.journal 26:2-26:15

      
        35
        58
         "" <none>

      
M internal/lsp/textdocument_definition.go
···
        67
        67
         	return nil

      
        68
        68
         }

      
        69
        69
         

      
        
        70
        +func findTransactionDefinition(an *analyzer.Analysis, e ast.Entry) *protocol.Location {

      
        
        71
        +	fileIdx := fileIndexForEntry(an, e)

      
        
        72
        +	if fileIdx < 0 {

      
        
        73
        +		return nil

      
        
        74
        +	}

      
        
        75
        +	var span token.Span

      
        
        76
        +	switch e := e.(type) {

      
        
        77
        +	case *ast.Transaction:

      
        
        78
        +		span = e.Date.Span

      
        
        79
        +	case *ast.PeriodicTransaction:

      
        
        80
        +		span = e.Period.Span

      
        
        81
        +	case *ast.AutomatedTransaction:

      
        
        82
        +		span = e.Expr.Span

      
        
        83
        +	default:

      
        
        84
        +		return nil

      
        
        85
        +	}

      
        
        86
        +	return locationFor(an, fileIdx, span)

      
        
        87
        +}

      
        
        88
        +

      
        70
        89
         func findCommodityDefinition(an *analyzer.Analysis, symbol string) *protocol.Location {

      
        71
        90
         	info := an.Commodities[symbol]

      
        72
        91
         	if info == nil {

      
M internal/lsp/textdocument_rename.go
···
        158
        158
         

      
        159
        159
         const (

      
        160
        160
         	symbolAccount symbolKind = iota

      
        
        161
        +	symbolTransaction

      
        161
        162
         	symbolCommodity

      
        162
        163
         	symbolPayee

      
        163
        164
         	symbolTag

      ···
        167
        168
         	switch s {

      
        168
        169
         	case symbolAccount:

      
        169
        170
         		return protocol.SymbolKindClass

      
        
        171
        +	case symbolTransaction:

      
        
        172
        +		return protocol.SymbolKindEvent

      
        170
        173
         	case symbolCommodity:

      
        171
        174
         		return protocol.SymbolKindVariable

      
        172
        175
         	case symbolPayee:

      ···
        182
        185
         	kind symbolKind

      
        183
        186
         	name string

      
        184
        187
         	span token.Span

      
        185
        
        -}

      
        186
        
        -

      
        187
        
        -// renameTo returns the replacement rext for an occurrence of the nodeKind with the given name.

      
        188
        
        -func (ref *symbolRef) renameTo(nodeKind symbolKind, name, newName string) (text string, renamed bool) {

      
        189
        
        -	if ref.kind != nodeKind {

      
        190
        
        -		return "", false

      
        191
        
        -	}

      
        192
        
        -	if ref.kind == symbolAccount {

      
        193
        
        -		if !accountMatches(name, ref.name) {

      
        194
        
        -			return "", false

      
        195
        
        -		}

      
        196
        
        -		return newName + strings.TrimPrefix(name, ref.name), true

      
        197
        
        -	}

      
        198
        
        -	if name != ref.name {

      
        199
        
        -		return "", false

      
        200
        
        -	}

      
        201
        
        -	return newName, true

      
        202
        188
         }

      
        203
        189
         

      
        204
        190
         func accountMatches(name, old string) bool {

      
M internal/lsp/workspace_symbol.go
···
        3
        3
         import (

      
        4
        4
         	"context"

      
        5
        5
         	"sort"

      
        
        6
        +	"strings"

      
        6
        7
         

      
        7
        8
         	"go.lsp.dev/protocol"

      
        8
        9
         	"go.lsp.dev/uri"

      
        9
        10
         

      
        10
        11
         	"olexsmir.xyz/clerk/internal/analyzer"

      
        11
        12
         	"olexsmir.xyz/clerk/internal/lsp/fuzzy"

      
        
        13
        +	"olexsmir.xyz/clerk/journal/ast"

      
        12
        14
         )

      
        13
        15
         

      
        14
        16
         func (s *server) Symbols(_ context.Context, params *protocol.WorkspaceSymbolParams) (protocol.WorkspaceSymbolResult, error) {

      ···
        37
        39
         const maxSymbolResults = 100

      
        38
        40
         

      
        39
        41
         type scoredSymbol struct {

      
        40
        
        -	kind  symbolKind

      
        41
        
        -	name  string

      
        42
        
        -	score float64

      
        
        42
        +	kind     symbolKind

      
        
        43
        +	name     string

      
        
        44
        +	score    float64

      
        
        45
        +	tnxEntry ast.Entry // set if kind == symbolTransaction, used for location resolution

      
        43
        46
         }

      
        44
        47
         

      
        45
        48
         func searchSymbols(an *analyzer.Analysis, query string) []protocol.WorkspaceSymbol {

      
        46
        49
         	matcher := fuzzy.Compile(query)

      
        47
        
        -

      
        48
        
        -	scored := make([]scoredSymbol, 0, len(an.AccountNames)+len(an.Commodities)+len(an.PayeeNames)+len(an.TagNames))

      
        49
        
        -	add := func(kind symbolKind, name string) {

      
        50
        
        -		if score := matcher.Score(name); score > 0 {

      
        51
        
        -			scored = append(scored, scoredSymbol{kind, name, score})

      
        
        50
        +	scored := make([]scoredSymbol, 0)

      
        
        51
        +	add := func(s scoredSymbol) {

      
        
        52
        +		if score := matcher.Score(s.name); score > 0 {

      
        
        53
        +			s.score = score

      
        
        54
        +			scored = append(scored, s)

      
        52
        55
         		}

      
        53
        56
         	}

      
        54
        57
         	for _, name := range an.AccountNames {

      
        55
        
        -		add(symbolAccount, name)

      
        
        58
        +		add(scoredSymbol{kind: symbolAccount, name: name})

      
        56
        59
         	}

      
        57
        60
         	for name := range an.Commodities {

      
        58
        
        -		add(symbolCommodity, name)

      
        
        61
        +		add(scoredSymbol{kind: symbolCommodity, name: name})

      
        59
        62
         	}

      
        60
        63
         	for _, name := range an.PayeeNames {

      
        61
        
        -		add(symbolPayee, name)

      
        
        64
        +		add(scoredSymbol{kind: symbolPayee, name: name})

      
        62
        65
         	}

      
        63
        66
         	for _, name := range an.TagNames {

      
        64
        
        -		add(symbolTag, name)

      
        
        67
        +		add(scoredSymbol{kind: symbolTag, name: name})

      
        
        68
        +	}

      
        
        69
        +	for _, tx := range an.Transactions {

      
        
        70
        +		add(scoredSymbol{kind: symbolTransaction, name: transactionName(tx), tnxEntry: tx})

      
        
        71
        +	}

      
        
        72
        +	for _, tx := range an.PeriodicTransactions {

      
        
        73
        +		add(scoredSymbol{kind: symbolTransaction, name: transactionName(tx), tnxEntry: tx})

      
        
        74
        +	}

      
        
        75
        +	for _, tx := range an.AutomatedTransactions {

      
        
        76
        +		add(scoredSymbol{kind: symbolTransaction, name: transactionName(tx), tnxEntry: tx})

      
        65
        77
         	}

      
        66
        78
         

      
        67
        79
         	sortScoredSymbols(scored)

      ···
        71
        83
         

      
        72
        84
         	symbols := make([]protocol.WorkspaceSymbol, 0, len(scored))

      
        73
        85
         	for _, s := range scored {

      
        74
        
        -		loc := definitionLocation(an, s.kind, s.name)

      
        
        86
        +		loc := definitionLocation(an, s)

      
        75
        87
         		if loc == nil {

      
        76
        88
         			continue

      
        77
        89
         		}

      ···
        86
        98
         	return symbols

      
        87
        99
         }

      
        88
        100
         

      
        89
        
        -func definitionLocation(an *analyzer.Analysis, kind symbolKind, name string) *protocol.Location {

      
        90
        
        -	switch kind {

      
        
        101
        +func definitionLocation(an *analyzer.Analysis, s scoredSymbol) *protocol.Location {

      
        
        102
        +	switch s.kind {

      
        91
        103
         	case symbolAccount:

      
        92
        
        -		return findAccountDefinition(an, name)

      
        
        104
        +		return findAccountDefinition(an, s.name)

      
        
        105
        +	case symbolTransaction:

      
        
        106
        +		return findTransactionDefinition(an, s.tnxEntry)

      
        93
        107
         	case symbolCommodity:

      
        94
        
        -		return findCommodityDefinition(an, name)

      
        
        108
        +		return findCommodityDefinition(an, s.name)

      
        95
        109
         	case symbolPayee:

      
        96
        
        -		return findPayeeDefinition(an, name)

      
        
        110
        +		return findPayeeDefinition(an, s.name)

      
        97
        111
         	case symbolTag:

      
        98
        
        -		return findTagDefinition(an, name)

      
        
        112
        +		return findTagDefinition(an, s.name)

      
        99
        113
         	}

      
        100
        114
         	return nil

      
        101
        115
         }

      
        102
        116
         

      
        
        117
        +func transactionName(e ast.Entry) string {

      
        
        118
        +	var b strings.Builder

      
        
        119
        +	switch e := e.(type) {

      
        
        120
        +	case *ast.Transaction:

      
        
        121
        +		b.Grow(24)

      
        
        122
        +		b.WriteString(e.Date.String())

      
        
        123
        +		if s := e.Status.Value.String(); s != "" {

      
        
        124
        +			b.WriteByte(' ')

      
        
        125
        +			b.WriteString(s)

      
        
        126
        +		}

      
        
        127
        +		if e.Payee != nil {

      
        
        128
        +			b.WriteByte(' ')

      
        
        129
        +			b.WriteString(e.Payee.Name)

      
        
        130
        +		}

      
        
        131
        +		if e.Note != nil {

      
        
        132
        +			b.WriteString(" | ")

      
        
        133
        +			b.WriteString(e.Note.Value)

      
        
        134
        +		}

      
        
        135
        +	case *ast.PeriodicTransaction:

      
        
        136
        +		b.WriteByte('~')

      
        
        137
        +		if s := e.Status.Value.String(); s != "" {

      
        
        138
        +			b.WriteByte(' ')

      
        
        139
        +			b.WriteString(s)

      
        
        140
        +		}

      
        
        141
        +		b.WriteByte(' ')

      
        
        142
        +		b.WriteString(e.Period.Raw)

      
        
        143
        +		if e.Description != nil {

      
        
        144
        +			b.WriteString(" | ")

      
        
        145
        +			b.WriteString(e.Description.Value)

      
        
        146
        +		}

      
        
        147
        +	case *ast.AutomatedTransaction:

      
        
        148
        +		b.WriteByte('=')

      
        
        149
        +		b.WriteByte(' ')

      
        
        150
        +		b.WriteString(e.Expr.Value)

      
        
        151
        +	}

      
        
        152
        +	return b.String()

      
        
        153
        +}

      
        
        154
        +

      
        103
        155
         func sortScoredSymbols(scored []scoredSymbol) {

      
        104
        156
         	sort.Slice(scored, func(i, j int) bool {

      
        105
        157
         		if scored[i].score != scored[j].score {

      
        106
        158
         			return scored[i].score > scored[j].score

      
        
        159
        +		}

      
        
        160
        +		if len(scored[i].name) != len(scored[j].name) {

      
        
        161
        +			return len(scored[i].name) < len(scored[j].name)

      
        107
        162
         		}

      
        108
        163
         		return scored[i].name < scored[j].name

      
        109
        164
         	})

      
M internal/lsp/workspace_symbol_test.go
···
        16
        16
         

      
        17
        17
         func TestSortScoredSymbols(t *testing.T) {

      
        18
        18
         	scored := []scoredSymbol{

      
        19
        
        -		{symbolAccount, "zz:zz", 0.5},

      
        20
        
        -		{symbolTag, "aa", 1.0},

      
        21
        
        -		{symbolPayee, "bb", 0.5},

      
        
        19
        +		{symbolAccount, "zz:zz", 0.5, nil},

      
        
        20
        +		{symbolTag, "aa", 1.0, nil},

      
        
        21
        +		{symbolPayee, "bb", 0.5, nil},

      
        
        22
        +		{symbolTag, "ccc", 0.5, nil},

      
        
        23
        +		{symbolPayee, "bb", 0.5, nil},

      
        22
        24
         	}

      
        23
        25
         	sortScoredSymbols(scored)

      
        24
        26
         

      
        25
        
        -	want := []string{"aa", "bb", "zz:zz"}

      
        
        27
        +	want := []string{"aa", "bb", "bb", "ccc", "zz:zz"}

      
        26
        28
         	for i, s := range scored {

      
        27
        29
         		if s.name != want[i] {

      
        28
        30
         			t.Errorf("pos %d = %q, want %q", i, s.name, want[i])

      ···
        94
        96
         	}{

      
        95
        97
         		"account prefix": {"1:2", true},

      
        96
        98
         		"all payees":     {"transaction", true},

      
        
        99
        +		"txn dates":      {"2000", true},

      
        97
        100
         		"mixed kinds":    {"B", true},

      
        98
        101
         		"no match":       {"xyz", false},

      
        99
        102
         	} {

      ···
        135
        138
         		return "object"

      
        136
        139
         	case protocol.SymbolKindProperty:

      
        137
        140
         		return "property"

      
        
        141
        +	case protocol.SymbolKindEvent:

      
        
        142
        +		return "event"

      
        138
        143
         	}

      
        139
        144
         	return "other"

      
        140
        145
         }