all repos

clerk @ 83b23d2

missing tooling for ledger/hledger
11 files changed, 668 insertions(+), 7 deletions(-)
lsp: workspace/symbol and textdocument/references
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-08-15 18:59:46 +0300
Authored at: 2026-08-14 12:02:01 +0300
Change ID: mpnqrosztvllyuzsnrqyyqvovptxutok
Parent: 3d2b11f
M internal/lsp/fuzzy/fuzzy.go
···
        1
        1
         package fuzzy

      
        2
        2
         

      
        3
        
        -import "strings"

      
        
        3
        +import (

      
        
        4
        +	"strings"

      
        
        5
        +	"unicode"

      
        
        6
        +)

      
        4
        7
         

      
        5
        8
         // Matcher scores a precompiled pattern against candidate texts; [Compile]

      
        6
        9
         // hoists the lowercasing and rune conversion out of a per-candidate loop.

      ···
        34
        37
         		return 1

      
        35
        38
         	}

      
        36
        39
         	t := []rune(text)

      
        37
        
        -	tl := []rune(strings.ToLower(text))

      
        38
        40
         

      
        39
        41
         	total := 0

      
        40
        42
         	prev := -1

      
        41
        43
         	for i, pr := range p {

      
        42
        44
         		j := prev + 1

      
        43
        45
         		for ; j < len(t); j++ {

      
        44
        
        -			if tl[j] == pr {

      
        
        46
        +			if unicode.ToLower(t[j]) == pr {

      
        45
        47
         				break

      
        46
        48
         			}

      
        47
        49
         		}

      
M internal/lsp/server.go
···
        28
        28
         

      
        29
        29
         	mu         sync.Mutex

      
        30
        30
         	openDocs   map[uri.URI]docState

      
        31
        
        -	current    *analyzer.Analysis

      
        32
        31
         	diagCancel context.CancelFunc

      
        33
        32
         

      
        34
        33
         	cfgMu  sync.RWMutex

      ···
        83
        82
         			DocumentFormattingProvider: &protocol.DocumentFormattingOptions{},

      
        84
        83
         			DefinitionProvider:         protocol.Boolean(true),

      
        85
        84
         			HoverProvider:              protocol.Boolean(true),

      
        
        85
        +			ReferencesProvider:         protocol.Boolean(true),

      
        
        86
        +			WorkspaceSymbolProvider:    protocol.Boolean(true),

      
        86
        87
         			RenameProvider: &protocol.RenameOptions{

      
        87
        88
         				PrepareProvider: new(true),

      
        88
        89
         			},

      
A internal/lsp/testdata/references-include.txtar
···
        
        1
        +-- in.journal --

      
        
        2
        +include sub.journal

      
        
        3
        +

      
        
        4
        +2024-01-16 Another Store

      
        
        5
        +    expenses:fo^od  100.00 EUR

      
        
        6
        +    assets:cash

      
        
        7
        +

      
        
        8
        +-- sub.journal --

      
        
        9
        +account expenses:food

      
        
        10
        +

      
        
        11
        +2024-01-15 Grocery Store

      
        
        12
        +    expenses:food  $50

      
        
        13
        +    assets:cash

      
        
        14
        +

      
        
        15
        +-- expect --

      
        
        16
        +includeDeclaration:false

      
        
        17
        +3:15 sub.journal 3:4-3:17

      
        
        18
        +3:15 in.journal 3:4-3:17

      
        
        19
        +includeDeclaration:true

      
        
        20
        +3:15 sub.journal 3:4-3:17

      
        
        21
        +3:15 in.journal 3:4-3:17

      
        
        22
        +3:15 sub.journal 0:8-0:21

      
A internal/lsp/testdata/references-journal.txtar
···
        
        1
        +-- in.journal --

      
        
        2
        +account ex^penses:food

      
        
        3
        +account assets:cash

      
        
        4
        +

      
        
        5
        +2024-01-15 Gro^cery Store

      
        
        6
        +    expenses:food  ^$50

      
        
        7
        +    assets:cash  $-50

      
        
        8
        +

      
        
        9
        +2024-01-16 Grocery Store ; proj^ect:work

      
        
        10
        +    expenses:food  $30

      
        
        11
        +    assets:cash  $-30

      
        
        12
        +

      
        
        13
        +2024-01-17 Coffee Shop ; project:home

      
        
        14
        +    expenses:food  $5

      
        
        15
        +    assets:cash  $-5

      
        
        16
        +

      
        
        17
        +payee Gro^cery Store

      
        
        18
        +

      
        
        19
        +commodity U^SD

      
        
        20
        +

      
        
        21
        +tag proj^ect

      
        
        22
        +

      
        
        23
        +-- expect --

      
        
        24
        +includeDeclaration:false

      
        
        25
        +0:10 in.journal 4:4-4:17

      
        
        26
        +0:10 in.journal 8:4-8:17

      
        
        27
        +0:10 in.journal 12:4-12:17

      
        
        28
        +3:14 in.journal 3:11-3:24

      
        
        29
        +3:14 in.journal 7:11-7:24

      
        
        30
        +4:19 in.journal 4:19-4:20

      
        
        31
        +4:19 in.journal 5:17-5:18

      
        
        32
        +4:19 in.journal 8:19-8:20

      
        
        33
        +4:19 in.journal 9:17-9:18

      
        
        34
        +4:19 in.journal 12:19-12:20

      
        
        35
        +4:19 in.journal 13:17-13:18

      
        
        36
        +7:31 in.journal 7:27-7:34

      
        
        37
        +7:31 in.journal 11:25-11:32

      
        
        38
        +15:9 in.journal 3:11-3:24

      
        
        39
        +15:9 in.journal 7:11-7:24

      
        
        40
        +17:11 <none>

      
        
        41
        +19:8 <none>

      
        
        42
        +includeDeclaration:true

      
        
        43
        +0:10 in.journal 4:4-4:17

      
        
        44
        +0:10 in.journal 8:4-8:17

      
        
        45
        +0:10 in.journal 12:4-12:17

      
        
        46
        +0:10 in.journal 0:8-0:21

      
        
        47
        +3:14 in.journal 3:11-3:24

      
        
        48
        +3:14 in.journal 7:11-7:24

      
        
        49
        +3:14 in.journal 15:6-15:19

      
        
        50
        +4:19 in.journal 4:19-4:20

      
        
        51
        +4:19 in.journal 5:17-5:18

      
        
        52
        +4:19 in.journal 8:19-8:20

      
        
        53
        +4:19 in.journal 9:17-9:18

      
        
        54
        +4:19 in.journal 12:19-12:20

      
        
        55
        +4:19 in.journal 13:17-13:18

      
        
        56
        +7:31 in.journal 7:27-7:34

      
        
        57
        +7:31 in.journal 11:25-11:32

      
        
        58
        +7:31 in.journal 19:4-19:11

      
        
        59
        +15:9 in.journal 3:11-3:24

      
        
        60
        +15:9 in.journal 7:11-7:24

      
        
        61
        +15:9 in.journal 15:6-15:19

      
        
        62
        +17:11 in.journal 17:10-17:13

      
        
        63
        +19:8 <none>

      
A internal/lsp/testdata/workspace-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
        +payee Grocery Store

      
        
        11
        +payee Coffee Shop

      
        
        12
        +

      
        
        13
        +commodity USD

      
        
        14
        +commodity EUR

      
        
        15
        +

      
        
        16
        +tag project

      
        
        17
        +tag client

      
        
        18
        +^

      
        
        19
        +

      
        
        20
        +-- queries --

      
        
        21
        +food

      
        
        22
        +cash

      
        
        23
        +grocery

      
        
        24
        +EUR

      
        
        25
        +project

      
        
        26
        +xyz

      
        
        27
        +

      
        
        28
        +-- expect --

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

      
        
        30
        +"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

      
        
        34
        +"xyz" <none>

      
        
        35
        +"" <none>

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

      
        102
        102
         }

      
        103
        103
         

      
        104
        
        -func locationForDirective(a *analyzer.Analysis, d ast.Entry, span token.Span) *protocol.Location {

      
        
        104
        +func findTagDefinition(an *analyzer.Analysis, key string) *protocol.Location {

      
        
        105
        +	info := an.Tags[key]

      
        
        106
        +	if info == nil {

      
        
        107
        +		return nil

      
        
        108
        +	}

      
        
        109
        +	if len(info.Directives) > 0 {

      
        
        110
        +		d := info.Directives[0]

      
        
        111
        +		fileIdx := fileIndexForEntry(an, d)

      
        
        112
        +		if fileIdx < 0 {

      
        
        113
        +			return nil

      
        
        114
        +		}

      
        
        115
        +		if span, ok := tagDirectiveSpan(string(an.Files[fileIdx].Src), d); ok {

      
        
        116
        +			return locationFor(an, fileIdx, span)

      
        
        117
        +		}

      
        
        118
        +	}

      
        
        119
        +	if len(info.Usage) > 0 {

      
        
        120
        +		u := info.Usage[0]

      
        
        121
        +		span := tagKeySpan(string(an.Files[u.FileIndex].Src), u.Tag)

      
        
        122
        +		return locationFor(an, u.FileIndex, span)

      
        
        123
        +	}

      
        
        124
        +	return nil

      
        
        125
        +}

      
        
        126
        +

      
        
        127
        +func fileIndexForEntry(a *analyzer.Analysis, d ast.Entry) int {

      
        105
        128
         	for i, pf := range a.Files {

      
        106
        129
         		if slices.Contains(pf.Ast.Entries, d) {

      
        107
        
        -			return locationFor(a, i, span)

      
        
        130
        +			return i

      
        108
        131
         		}

      
        109
        132
         	}

      
        110
        
        -	return nil

      
        
        133
        +	return -1

      
        
        134
        +}

      
        
        135
        +

      
        
        136
        +func locationForDirective(a *analyzer.Analysis, d ast.Entry, span token.Span) *protocol.Location {

      
        
        137
        +	fileIdx := fileIndexForEntry(a, d)

      
        
        138
        +	if fileIdx < 0 {

      
        
        139
        +		return nil

      
        
        140
        +	}

      
        
        141
        +	return locationFor(a, fileIdx, span)

      
        111
        142
         }

      
        112
        143
         

      
        113
        144
         func locationFor(a *analyzer.Analysis, fileIdx int, span token.Span) *protocol.Location {

      
A internal/lsp/textdocument_references.go
···
        
        1
        +package lsp

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +

      
        
        6
        +	"go.lsp.dev/protocol"

      
        
        7
        +

      
        
        8
        +	"olexsmir.xyz/clerk/internal/analyzer"

      
        
        9
        +)

      
        
        10
        +

      
        
        11
        +func (s *server) References(_ context.Context, params *protocol.ReferenceParams) ([]protocol.Location, error) {

      
        
        12
        +	state, ok := s.getDocState(params.TextDocument.URI)

      
        
        13
        +	if !ok {

      
        
        14
        +		return nil, nil

      
        
        15
        +	}

      
        
        16
        +

      
        
        17
        +	an := s.analysisFor(params.TextDocument.URI)

      
        
        18
        +	if an == nil {

      
        
        19
        +		return nil, nil

      
        
        20
        +	}

      
        
        21
        +	cursor := state.lineIdx.Offset(int(params.Position.Line), int(params.Position.Character))

      
        
        22
        +	ref := findSymbolUnderCursor(an, params.TextDocument.URI.Path(), state.text, cursor)

      
        
        23
        +	if ref == nil {

      
        
        24
        +		return nil, nil

      
        
        25
        +	}

      
        
        26
        +	return findReferences(an, ref, params.Context.IncludeDeclaration), nil

      
        
        27
        +}

      
        
        28
        +

      
        
        29
        +func findReferences(an *analyzer.Analysis, ref *symbolRef, includeDeclaration bool) []protocol.Location {

      
        
        30
        +	switch ref.kind {

      
        
        31
        +	case symbolAccount:

      
        
        32
        +		return findAccountReferences(an, ref.name, includeDeclaration)

      
        
        33
        +	case symbolCommodity:

      
        
        34
        +		return findCommodityReferences(an, ref.name, includeDeclaration)

      
        
        35
        +	case symbolPayee:

      
        
        36
        +		return findPayeeReferences(an, ref.name, includeDeclaration)

      
        
        37
        +	case symbolTag:

      
        
        38
        +		return findTagReferences(an, ref.name, includeDeclaration)

      
        
        39
        +	}

      
        
        40
        +	return nil

      
        
        41
        +}

      
        
        42
        +

      
        
        43
        +func findAccountReferences(an *analyzer.Analysis, name string, includeDeclaration bool) []protocol.Location {

      
        
        44
        +	var locations []protocol.Location

      
        
        45
        +	for _, candidate := range an.AccountNames {

      
        
        46
        +		if !accountMatches(candidate, name) {

      
        
        47
        +			continue

      
        
        48
        +		}

      
        
        49
        +		info := an.Accounts[candidate]

      
        
        50
        +		for _, u := range info.Usages {

      
        
        51
        +			appendLocation(&locations, locationFor(an, u.FileIndex, u.Posting.Account.Span))

      
        
        52
        +		}

      
        
        53
        +		if includeDeclaration {

      
        
        54
        +			for _, d := range info.Directives {

      
        
        55
        +				appendLocation(&locations, locationForDirective(an, d, d.Account.Span))

      
        
        56
        +			}

      
        
        57
        +		}

      
        
        58
        +	}

      
        
        59
        +	return locations

      
        
        60
        +}

      
        
        61
        +

      
        
        62
        +func findCommodityReferences(an *analyzer.Analysis, symbol string, includeDeclaration bool) []protocol.Location {

      
        
        63
        +	info := an.Commodities[symbol]

      
        
        64
        +	if info == nil {

      
        
        65
        +		return nil

      
        
        66
        +	}

      
        
        67
        +	var locations []protocol.Location

      
        
        68
        +	for _, u := range info.Usages {

      
        
        69
        +		appendLocation(&locations, locationFor(an, u.FileIndex, u.Amount.CommoditySpan))

      
        
        70
        +	}

      
        
        71
        +	if includeDeclaration {

      
        
        72
        +		for _, d := range info.Directives {

      
        
        73
        +			appendLocation(&locations, locationForDirective(an, d, d.CommoditySpan))

      
        
        74
        +		}

      
        
        75
        +	}

      
        
        76
        +	return locations

      
        
        77
        +}

      
        
        78
        +

      
        
        79
        +func findPayeeReferences(an *analyzer.Analysis, name string, includeDeclaration bool) []protocol.Location {

      
        
        80
        +	info := an.Payees[name]

      
        
        81
        +	if info == nil {

      
        
        82
        +		return nil

      
        
        83
        +	}

      
        
        84
        +	var locations []protocol.Location

      
        
        85
        +	for _, u := range info.Usage {

      
        
        86
        +		appendLocation(&locations, locationFor(an, u.FileIndex, u.Payee.Span))

      
        
        87
        +	}

      
        
        88
        +	if includeDeclaration {

      
        
        89
        +		for _, d := range info.Directives {

      
        
        90
        +			if d.Name != nil {

      
        
        91
        +				appendLocation(&locations, locationForDirective(an, d, d.Name.Span))

      
        
        92
        +			}

      
        
        93
        +		}

      
        
        94
        +	}

      
        
        95
        +	return locations

      
        
        96
        +}

      
        
        97
        +

      
        
        98
        +func findTagReferences(an *analyzer.Analysis, key string, includeDeclaration bool) []protocol.Location {

      
        
        99
        +	info := an.Tags[key]

      
        
        100
        +	if info == nil {

      
        
        101
        +		return nil

      
        
        102
        +	}

      
        
        103
        +	var locations []protocol.Location

      
        
        104
        +	for _, u := range info.Usage {

      
        
        105
        +		span := tagKeySpan(string(an.Files[u.FileIndex].Src), u.Tag)

      
        
        106
        +		appendLocation(&locations, locationFor(an, u.FileIndex, span))

      
        
        107
        +	}

      
        
        108
        +	if includeDeclaration {

      
        
        109
        +		for _, d := range info.Directives {

      
        
        110
        +			fileIdx := fileIndexForEntry(an, d)

      
        
        111
        +			if fileIdx < 0 {

      
        
        112
        +				continue

      
        
        113
        +			}

      
        
        114
        +			if span, ok := tagDirectiveSpan(string(an.Files[fileIdx].Src), d); ok {

      
        
        115
        +				appendLocation(&locations, locationFor(an, fileIdx, span))

      
        
        116
        +			}

      
        
        117
        +		}

      
        
        118
        +	}

      
        
        119
        +	return locations

      
        
        120
        +}

      
        
        121
        +

      
        
        122
        +func appendLocation(locations *[]protocol.Location, loc *protocol.Location) {

      
        
        123
        +	if loc != nil {

      
        
        124
        +		*locations = append(*locations, *loc)

      
        
        125
        +	}

      
        
        126
        +}

      
A internal/lsp/textdocument_references_test.go
···
        
        1
        +package lsp

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"fmt"

      
        
        6
        +	"path/filepath"

      
        
        7
        +	"strings"

      
        
        8
        +	"testing"

      
        
        9
        +	"time"

      
        
        10
        +

      
        
        11
        +	"go.lsp.dev/protocol"

      
        
        12
        +	"go.lsp.dev/uri"

      
        
        13
        +

      
        
        14
        +	"olexsmir.xyz/clerk/internal/lsp/lsputil"

      
        
        15
        +	"olexsmir.xyz/clerk/internal/testutil/golden"

      
        
        16
        +)

      
        
        17
        +

      
        
        18
        +func TestServer_References_DocumentNotFound(t *testing.T) {

      
        
        19
        +	srv := NewServer("test")

      
        
        20
        +	res, err := srv.server.References(context.Background(), &protocol.ReferenceParams{

      
        
        21
        +		TextDocumentPositionParams: protocol.TextDocumentPositionParams{

      
        
        22
        +			TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///nonexistent.journal")},

      
        
        23
        +			Position:     protocol.Position{Line: 0, Character: 0},

      
        
        24
        +		},

      
        
        25
        +	})

      
        
        26
        +	if err != nil {

      
        
        27
        +		t.Fatal(err)

      
        
        28
        +	}

      
        
        29
        +	if res != nil {

      
        
        30
        +		t.Errorf("got %v, want nil", res)

      
        
        31
        +	}

      
        
        32
        +}

      
        
        33
        +

      
        
        34
        +func TestGolden_References(t *testing.T) {

      
        
        35
        +	for _, tt := range []string{"references-journal", "references-include"} {

      
        
        36
        +		ar := golden.Read(t, tt)

      
        
        37
        +		t.Run(tt, func(t *testing.T) {

      
        
        38
        +			h := newTxtarHarness(t, ar)

      
        
        39
        +

      
        
        40
        +			var b strings.Builder

      
        
        41
        +			for _, incl := range []bool{false, true} {

      
        
        42
        +				fmt.Fprintf(&b, "includeDeclaration:%v\n", incl)

      
        
        43
        +				for i := range h.cursors {

      
        
        44
        +					txtDocPos := h.textDocumentPosition(i)

      
        
        45
        +					res, err := h.srv.References(t.Context(), &protocol.ReferenceParams{

      
        
        46
        +						Context:                    protocol.ReferenceContext{IncludeDeclaration: incl},

      
        
        47
        +						TextDocumentPositionParams: txtDocPos,

      
        
        48
        +					})

      
        
        49
        +					if err != nil {

      
        
        50
        +						t.Fatal(err)

      
        
        51
        +					}

      
        
        52
        +					if len(res) == 0 {

      
        
        53
        +						fmt.Fprintf(&b, "%d:%d <none>\n", txtDocPos.Position.Line, txtDocPos.Position.Character)

      
        
        54
        +						continue

      
        
        55
        +					}

      
        
        56
        +					for _, loc := range res {

      
        
        57
        +						r := loc.Range

      
        
        58
        +						fmt.Fprintf(&b, "%d:%d %s %d:%d-%d:%d\n", txtDocPos.Position.Line, txtDocPos.Position.Character,

      
        
        59
        +							filepath.Base(loc.URI.Path()),

      
        
        60
        +							r.Start.Line, r.Start.Character, r.End.Line, r.End.Character)

      
        
        61
        +					}

      
        
        62
        +				}

      
        
        63
        +			}

      
        
        64
        +			golden.Assert(t, ar, b.String())

      
        
        65
        +		})

      
        
        66
        +	}

      
        
        67
        +}

      
        
        68
        +

      
        
        69
        +func BenchmarkReferences(b *testing.B) {

      
        
        70
        +	content := openJouranl(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal")

      
        
        71
        +

      
        
        72
        +	srv := NewServer("test")

      
        
        73
        +	u := uri.URI("file:///test.journal")

      
        
        74
        +	srv.server.openDoc(u, content, 1, "journal")

      
        
        75
        +	srv.server.analysisFor(u) // warm per-doc cache

      
        
        76
        +

      
        
        77
        +	for tname, tt := range map[string]int{

      
        
        78
        +		"1k txns, account":   strings.Index(content, "\n  1:2:3 ") + len("\n  ") + 2,

      
        
        79
        +		"1k txns, payee":     strings.Index(content, "transaction 1") + len("transaction "),

      
        
        80
        +		"1k txns, commodity": strings.Index(content, "2 B @@") + len("2 B"),

      
        
        81
        +		"late tx, payee":     strings.Index(content, "transaction 1000") + len("transaction "),

      
        
        82
        +	} {

      
        
        83
        +		b.Run(tname, func(b *testing.B) {

      
        
        84
        +			line, col := lsputil.LineCol(content, tt)

      
        
        85
        +			params := &protocol.ReferenceParams{

      
        
        86
        +				Context: protocol.ReferenceContext{IncludeDeclaration: true},

      
        
        87
        +				TextDocumentPositionParams: protocol.TextDocumentPositionParams{

      
        
        88
        +					TextDocument: protocol.TextDocumentIdentifier{URI: u},

      
        
        89
        +					Position:     protocol.Position{Line: uint32(line), Character: uint32(col)},

      
        
        90
        +				},

      
        
        91
        +			}

      
        
        92
        +

      
        
        93
        +			// warm up: first request resolves the symbol lazily; assert it found references

      
        
        94
        +			res, err := srv.server.References(b.Context(), params)

      
        
        95
        +			if err != nil {

      
        
        96
        +				b.Fatal(err)

      
        
        97
        +			}

      
        
        98
        +			if len(res) == 0 {

      
        
        99
        +				b.Fatalf("%s: no references", tname)

      
        
        100
        +			}

      
        
        101
        +

      
        
        102
        +			b.ReportAllocs()

      
        
        103
        +			b.ResetTimer()

      
        
        104
        +			for b.Loop() {

      
        
        105
        +				if _, err := srv.server.References(b.Context(), params); err != nil {

      
        
        106
        +					b.Fatal(err)

      
        
        107
        +				}

      
        
        108
        +			}

      
        
        109
        +

      
        
        110
        +			// guard: a whole-file re-parse per request (~13ms) would blow past

      
        
        111
        +			// this and must be caught

      
        
        112
        +			if avg := b.Elapsed() / time.Duration(b.N); avg > 5*time.Millisecond {

      
        
        113
        +				b.Fatalf("references %v/op: reparse regression", avg)

      
        
        114
        +			}

      
        
        115
        +		})

      
        
        116
        +	}

      
        
        117
        +}

      
M internal/lsp/textdocument_rename.go
···
        163
        163
         	symbolTag

      
        164
        164
         )

      
        165
        165
         

      
        
        166
        +func (s symbolKind) ToProtocol() protocol.SymbolKind {

      
        
        167
        +	switch s {

      
        
        168
        +	case symbolAccount:

      
        
        169
        +		return protocol.SymbolKindClass

      
        
        170
        +	case symbolCommodity:

      
        
        171
        +		return protocol.SymbolKindVariable

      
        
        172
        +	case symbolPayee:

      
        
        173
        +		return protocol.SymbolKindObject

      
        
        174
        +	case symbolTag:

      
        
        175
        +		return protocol.SymbolKindProperty

      
        
        176
        +	}

      
        
        177
        +	return protocol.SymbolKindFile

      
        
        178
        +}

      
        
        179
        +

      
        166
        180
         // symbolRef is a symbol under the cursor, ready to be resolved or renamed.

      
        167
        181
         type symbolRef struct {

      
        168
        182
         	kind symbolKind

      
A internal/lsp/workspace_symbol.go
···
        
        1
        +package lsp

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"sort"

      
        
        6
        +

      
        
        7
        +	"go.lsp.dev/protocol"

      
        
        8
        +	"go.lsp.dev/uri"

      
        
        9
        +

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

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

      
        
        12
        +)

      
        
        13
        +

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

      
        
        15
        +	if params.Query == "" {

      
        
        16
        +		return nil, nil

      
        
        17
        +	}

      
        
        18
        +

      
        
        19
        +	s.mu.Lock()

      
        
        20
        +	var u uri.URI

      
        
        21
        +	for u = range s.openDocs {

      
        
        22
        +		break

      
        
        23
        +	}

      
        
        24
        +	s.mu.Unlock()

      
        
        25
        +	an := s.analysisFor(u)

      
        
        26
        +	if an == nil {

      
        
        27
        +		return nil, nil

      
        
        28
        +	}

      
        
        29
        +

      
        
        30
        +	symbols := searchSymbols(an, params.Query)

      
        
        31
        +	if len(symbols) == 0 {

      
        
        32
        +		return nil, nil

      
        
        33
        +	}

      
        
        34
        +	return protocol.WorkspaceSymbolSlice(symbols), nil

      
        
        35
        +}

      
        
        36
        +

      
        
        37
        +const maxSymbolResults = 100

      
        
        38
        +

      
        
        39
        +type scoredSymbol struct {

      
        
        40
        +	kind  symbolKind

      
        
        41
        +	name  string

      
        
        42
        +	score float64

      
        
        43
        +}

      
        
        44
        +

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

      
        
        46
        +	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})

      
        
        52
        +		}

      
        
        53
        +	}

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

      
        
        55
        +		add(symbolAccount, name)

      
        
        56
        +	}

      
        
        57
        +	for name := range an.Commodities {

      
        
        58
        +		add(symbolCommodity, name)

      
        
        59
        +	}

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

      
        
        61
        +		add(symbolPayee, name)

      
        
        62
        +	}

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

      
        
        64
        +		add(symbolTag, name)

      
        
        65
        +	}

      
        
        66
        +

      
        
        67
        +	sortScoredSymbols(scored)

      
        
        68
        +	if len(scored) > maxSymbolResults {

      
        
        69
        +		scored = scored[:maxSymbolResults]

      
        
        70
        +	}

      
        
        71
        +

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

      
        
        73
        +	for _, s := range scored {

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

      
        
        75
        +		if loc == nil {

      
        
        76
        +			continue

      
        
        77
        +		}

      
        
        78
        +		symbols = append(symbols, protocol.WorkspaceSymbol{

      
        
        79
        +			BaseSymbolInformation: protocol.BaseSymbolInformation{

      
        
        80
        +				Name: s.name,

      
        
        81
        +				Kind: s.kind.ToProtocol(),

      
        
        82
        +			},

      
        
        83
        +			Location: loc,

      
        
        84
        +		})

      
        
        85
        +	}

      
        
        86
        +	return symbols

      
        
        87
        +}

      
        
        88
        +

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

      
        
        90
        +	switch kind {

      
        
        91
        +	case symbolAccount:

      
        
        92
        +		return findAccountDefinition(an, name)

      
        
        93
        +	case symbolCommodity:

      
        
        94
        +		return findCommodityDefinition(an, name)

      
        
        95
        +	case symbolPayee:

      
        
        96
        +		return findPayeeDefinition(an, name)

      
        
        97
        +	case symbolTag:

      
        
        98
        +		return findTagDefinition(an, name)

      
        
        99
        +	}

      
        
        100
        +	return nil

      
        
        101
        +}

      
        
        102
        +

      
        
        103
        +func sortScoredSymbols(scored []scoredSymbol) {

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

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

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

      
        
        107
        +		}

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

      
        
        109
        +	})

      
        
        110
        +}

      
A internal/lsp/workspace_symbol_test.go
···
        
        1
        +package lsp

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"fmt"

      
        
        6
        +	"path/filepath"

      
        
        7
        +	"strings"

      
        
        8
        +	"testing"

      
        
        9
        +	"time"

      
        
        10
        +

      
        
        11
        +	"go.lsp.dev/protocol"

      
        
        12
        +	"go.lsp.dev/uri"

      
        
        13
        +

      
        
        14
        +	"olexsmir.xyz/clerk/internal/testutil/golden"

      
        
        15
        +)

      
        
        16
        +

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

      
        
        18
        +	scored := []scoredSymbol{

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

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

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

      
        
        22
        +	}

      
        
        23
        +	sortScoredSymbols(scored)

      
        
        24
        +

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

      
        
        26
        +	for i, s := range scored {

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

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

      
        
        29
        +		}

      
        
        30
        +	}

      
        
        31
        +}

      
        
        32
        +

      
        
        33
        +func TestServer_Symbols_EmptyQuery(t *testing.T) {

      
        
        34
        +	srv := NewServer("test")

      
        
        35
        +	u := uri.URI("file:///test.journal")

      
        
        36
        +	srv.server.openDoc(u, "account expenses:food\n", 1, "journal")

      
        
        37
        +

      
        
        38
        +	res, err := srv.server.Symbols(context.Background(), &protocol.WorkspaceSymbolParams{Query: ""})

      
        
        39
        +	if err != nil {

      
        
        40
        +		t.Fatal(err)

      
        
        41
        +	}

      
        
        42
        +	if res != nil {

      
        
        43
        +		t.Errorf("got %v, want nil for empty query", res)

      
        
        44
        +	}

      
        
        45
        +}

      
        
        46
        +

      
        
        47
        +func TestGolden_Symbols(t *testing.T) {

      
        
        48
        +	ar := golden.Read(t, "workspace-symbol")

      
        
        49
        +	t.Run("workspace-symbol", func(t *testing.T) {

      
        
        50
        +		h := newTxtarHarness(t, ar)

      
        
        51
        +

      
        
        52
        +		// One query per line; a trailing blank line is the empty-query case.

      
        
        53
        +		queries := strings.Split(strings.TrimSuffix(string(ar.Get("queries")), "\n"), "\n")

      
        
        54
        +		var b strings.Builder

      
        
        55
        +		for _, q := range queries {

      
        
        56
        +			res, err := h.srv.Symbols(t.Context(), &protocol.WorkspaceSymbolParams{Query: q})

      
        
        57
        +			if err != nil {

      
        
        58
        +				t.Fatal(err)

      
        
        59
        +			}

      
        
        60
        +			if res == nil {

      
        
        61
        +				fmt.Fprintf(&b, "%q <none>\n", q)

      
        
        62
        +				continue

      
        
        63
        +			}

      
        
        64
        +			list, ok := res.(protocol.WorkspaceSymbolSlice)

      
        
        65
        +			if !ok {

      
        
        66
        +				t.Fatalf("Symbols returned %T, want WorkspaceSymbolSlice", res)

      
        
        67
        +			}

      
        
        68
        +			for _, sym := range list {

      
        
        69
        +				loc, ok := sym.Location.(*protocol.Location)

      
        
        70
        +				if !ok {

      
        
        71
        +					t.Fatalf("Symbol %q location is %T, want *Location", sym.Name, sym.Location)

      
        
        72
        +				}

      
        
        73
        +				r := loc.Range

      
        
        74
        +				fmt.Fprintf(&b, "%q %s %s %s %d:%d-%d:%d\n", q, symbolKindName(sym.Kind), sym.Name,

      
        
        75
        +					filepath.Base(loc.URI.Path()),

      
        
        76
        +					r.Start.Line, r.Start.Character, r.End.Line, r.End.Character)

      
        
        77
        +			}

      
        
        78
        +		}

      
        
        79
        +		golden.Assert(t, ar, b.String())

      
        
        80
        +	})

      
        
        81
        +}

      
        
        82
        +

      
        
        83
        +func BenchmarkSymbols(b *testing.B) {

      
        
        84
        +	content := openJouranl(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal")

      
        
        85
        +

      
        
        86
        +	srv := NewServer("test")

      
        
        87
        +	u := uri.URI("file:///test.journal")

      
        
        88
        +	srv.server.openDoc(u, content, 1, "journal")

      
        
        89
        +	srv.server.analysisFor(u) // warm the per-doc cache

      
        
        90
        +

      
        
        91
        +	for tname, tt := range map[string]struct {

      
        
        92
        +		query string

      
        
        93
        +		want  bool // expect non-empty results

      
        
        94
        +	}{

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

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

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

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

      
        
        99
        +	} {

      
        
        100
        +		b.Run(tname, func(b *testing.B) {

      
        
        101
        +			params := &protocol.WorkspaceSymbolParams{Query: tt.query}

      
        
        102
        +

      
        
        103
        +			// warm up: assert the query matches as expected

      
        
        104
        +			res, err := srv.server.Symbols(b.Context(), params)

      
        
        105
        +			if err != nil {

      
        
        106
        +				b.Fatal(err)

      
        
        107
        +			}

      
        
        108
        +			if (res != nil) != tt.want {

      
        
        109
        +				b.Fatalf("%s: query %q: got res==nil=%v, want %v", tname, tt.query, res == nil, !tt.want)

      
        
        110
        +			}

      
        
        111
        +			b.ReportAllocs()

      
        
        112
        +			b.ResetTimer()

      
        
        113
        +			for b.Loop() {

      
        
        114
        +				if _, err := srv.server.Symbols(b.Context(), params); err != nil {

      
        
        115
        +					b.Fatal(err)

      
        
        116
        +				}

      
        
        117
        +			}

      
        
        118
        +

      
        
        119
        +			// guard: a whole-file re-parse per request (~13ms) would blow past

      
        
        120
        +			// this and must be caught

      
        
        121
        +			if avg := b.Elapsed() / time.Duration(b.N); avg > 5*time.Millisecond {

      
        
        122
        +				b.Fatalf("symbols %v/op: reparse regression", avg)

      
        
        123
        +			}

      
        
        124
        +		})

      
        
        125
        +	}

      
        
        126
        +}

      
        
        127
        +

      
        
        128
        +func symbolKindName(k protocol.SymbolKind) string {

      
        
        129
        +	switch k {

      
        
        130
        +	case protocol.SymbolKindClass:

      
        
        131
        +		return "class"

      
        
        132
        +	case protocol.SymbolKindVariable:

      
        
        133
        +		return "variable"

      
        
        134
        +	case protocol.SymbolKindObject:

      
        
        135
        +		return "object"

      
        
        136
        +	case protocol.SymbolKindProperty:

      
        
        137
        +		return "property"

      
        
        138
        +	}

      
        
        139
        +	return "other"

      
        
        140
        +}