all repos

clerk @ 6106d588c3cc8b0aa2d551af7c80a0e8268ae14a

missing tooling for ledger/hledger
3 files changed, 293 insertions(+), 6 deletions(-)
lsp: completion
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-07-11 12:58:15 +0300
Authored at: 2026-06-29 22:16:31 +0300
Change ID: poppzyupyorxyvvmprymntqslvnynnkr
Parent: 9369172
A internal/lsp/completion.go
···
        
        1
        +package lsp

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"slices"

      
        
        6
        +	"strings"

      
        
        7
        +

      
        
        8
        +	"go.lsp.dev/protocol"

      
        
        9
        +

      
        
        10
        +	"olexsmir.xyz/clerk/journal/semantic"

      
        
        11
        +)

      
        
        12
        +

      
        
        13
        +func (s *server) Completion(ctx context.Context, params *protocol.CompletionParams) (protocol.CompletionResult, error) {

      
        
        14
        +	text, ok := s.getDocText(params.TextDocument.URI)

      
        
        15
        +	if !ok {

      
        
        16
        +		return &protocol.CompletionList{IsIncomplete: false}, nil

      
        
        17
        +	}

      
        
        18
        +

      
        
        19
        +	lines := strings.Split(text, "\n")

      
        
        20
        +	lineIdx := int(params.Position.Line)

      
        
        21
        +	if lineIdx >= len(lines) {

      
        
        22
        +		lineIdx = len(lines) - 1

      
        
        23
        +	}

      
        
        24
        +	if lineIdx < 0 {

      
        
        25
        +		lineIdx = 0

      
        
        26
        +	}

      
        
        27
        +

      
        
        28
        +	currentLine := lines[lineIdx]

      
        
        29
        +	col := min(params.Position.Character, uint32(len(currentLine)))

      
        
        30
        +

      
        
        31
        +	prefix := currentLine[:col]

      
        
        32
        +	kind, word := detectKind(prefix)

      
        
        33
        +

      
        
        34
        +	s.semaMu.RLock()

      
        
        35
        +	sema := s.semanticCtx

      
        
        36
        +	s.semaMu.RUnlock()

      
        
        37
        +

      
        
        38
        +	var items []protocol.CompletionItem

      
        
        39
        +	switch kind {

      
        
        40
        +	case complAccount:

      
        
        41
        +		items = completeAccounts(sema, word)

      
        
        42
        +	case complPayee:

      
        
        43
        +		items = completePayees(sema, word)

      
        
        44
        +	case complCommodity:

      
        
        45
        +		items = completeCommodities(sema, word)

      
        
        46
        +	case complKeyword:

      
        
        47
        +		items = completeKeywords(word)

      
        
        48
        +	}

      
        
        49
        +	if items == nil {

      
        
        50
        +		items = []protocol.CompletionItem{}

      
        
        51
        +	}

      
        
        52
        +

      
        
        53
        +	return &protocol.CompletionList{

      
        
        54
        +		IsIncomplete: false,

      
        
        55
        +		Items:        items,

      
        
        56
        +	}, nil

      
        
        57
        +}

      
        
        58
        +

      
        
        59
        +type completionKind int

      
        
        60
        +

      
        
        61
        +const (

      
        
        62
        +	complNone completionKind = iota

      
        
        63
        +	complAccount

      
        
        64
        +	complPayee

      
        
        65
        +	complCommodity

      
        
        66
        +	complKeyword

      
        
        67
        +)

      
        
        68
        +

      
        
        69
        +func detectKind(prefix string) (completionKind, string) {

      
        
        70
        +	if prefix == "" {

      
        
        71
        +		return complNone, ""

      
        
        72
        +	}

      
        
        73
        +

      
        
        74
        +	trimmed := strings.TrimLeft(prefix, " \t")

      
        
        75
        +	if trimmed == "" {

      
        
        76
        +		return complNone, ""

      
        
        77
        +	}

      
        
        78
        +

      
        
        79
        +	c := prefix[0]

      
        
        80
        +

      
        
        81
        +	// posting line: starts with indentation

      
        
        82
        +	if c == ' ' || c == '\t' {

      
        
        83
        +		content := strings.TrimLeft(trimmed, "!* ([")

      
        
        84
        +		if _, after, ok := strings.Cut(content, "  "); ok {

      
        
        85
        +			// after := strings.TrimLeft(after, " \t")

      
        
        86
        +			if n := strings.LastIndexAny(after, " \t"); n >= 0 { // last field (after whitespace)

      
        
        87
        +				return complCommodity, after[n+1:]

      
        
        88
        +			}

      
        
        89
        +			return complCommodity, after

      
        
        90
        +		}

      
        
        91
        +		return complAccount, content

      
        
        92
        +	}

      
        
        93
        +

      
        
        94
        +	// directive keywords at line start

      
        
        95
        +	if kw, rest := matchKeyword(trimmed); kw != "" {

      
        
        96
        +		switch kw {

      
        
        97
        +		case "account", "alias":

      
        
        98
        +			return complAccount, rest

      
        
        99
        +		case "payee":

      
        
        100
        +			return complPayee, rest

      
        
        101
        +		case "commodity", "D", "P", "N":

      
        
        102
        +			return complCommodity, rest

      
        
        103
        +		}

      
        
        104
        +		return complNone, ""

      
        
        105
        +	}

      
        
        106
        +

      
        
        107
        +	// after date, transaction header

      
        
        108
        +	if c >= '0' && c <= '9' {

      
        
        109
        +		return complPayee, skipTransactionPreamble(trimmed)

      
        
        110
        +	}

      
        
        111
        +

      
        
        112
        +	// partial keyword on unknown line

      
        
        113
        +	if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') {

      
        
        114
        +		return complKeyword, trimmed

      
        
        115
        +	}

      
        
        116
        +

      
        
        117
        +	return complNone, ""

      
        
        118
        +}

      
        
        119
        +

      
        
        120
        +func matchKeyword(trimmed string) (keyword, rest string) {

      
        
        121
        +	i := 0

      
        
        122
        +	for i < len(trimmed) && trimmed[i] != ' ' && trimmed[i] != '\t' {

      
        
        123
        +		i++

      
        
        124
        +	}

      
        
        125
        +	if i == 0 {

      
        
        126
        +		return "", ""

      
        
        127
        +	}

      
        
        128
        +	word := trimmed[:i]

      
        
        129
        +	switch word {

      
        
        130
        +	case "account", "alias", "payee", "commodity",

      
        
        131
        +		"include", "tag", "apply", "end",

      
        
        132
        +		"year", "Y", "decimal-mark",

      
        
        133
        +		"D", "P", "N", "C", "comment":

      
        
        134
        +	default:

      
        
        135
        +		return "", ""

      
        
        136
        +	}

      
        
        137
        +	rest = strings.TrimLeft(trimmed[i:], " \t")

      
        
        138
        +	return word, rest

      
        
        139
        +}

      
        
        140
        +

      
        
        141
        +func skipTransactionPreamble(trimmed string) string {

      
        
        142
        +	// Skip date (digits and date separators)

      
        
        143
        +	i := 0

      
        
        144
        +	for i < len(trimmed) {

      
        
        145
        +		c := trimmed[i]

      
        
        146
        +		if (c >= '0' && c <= '9') || c == '/' || c == '-' || c == '.' {

      
        
        147
        +			i++

      
        
        148
        +		} else {

      
        
        149
        +			break

      
        
        150
        +		}

      
        
        151
        +	}

      
        
        152
        +	after := trimmed[i:]

      
        
        153
        +

      
        
        154
        +	// Skip whitespace, status markers, and code in parens

      
        
        155
        +	for {

      
        
        156
        +		before := after

      
        
        157
        +		after = strings.TrimLeft(after, " \t")

      
        
        158
        +		if len(after) > 0 && (after[0] == '*' || after[0] == '!') {

      
        
        159
        +			after = after[1:]

      
        
        160
        +			continue

      
        
        161
        +		}

      
        
        162
        +		if len(after) > 0 && after[0] == '(' {

      
        
        163
        +			end := strings.IndexByte(after, ')')

      
        
        164
        +			if end >= 0 {

      
        
        165
        +				after = after[end+1:]

      
        
        166
        +				continue

      
        
        167
        +			}

      
        
        168
        +		}

      
        
        169
        +		if after == before {

      
        
        170
        +			break

      
        
        171
        +		}

      
        
        172
        +	}

      
        
        173
        +	return after

      
        
        174
        +}

      
        
        175
        +

      
        
        176
        +func completeAccounts(sema *semantic.Context, prefix string) []protocol.CompletionItem {

      
        
        177
        +	if sema == nil || prefix == "" {

      
        
        178
        +		return nil

      
        
        179
        +	}

      
        
        180
        +	var items []protocol.CompletionItem

      
        
        181
        +	for name := range sema.Accounts {

      
        
        182
        +		if strings.HasPrefix(name, prefix) {

      
        
        183
        +			items = append(items, protocol.CompletionItem{

      
        
        184
        +				Label: name,

      
        
        185
        +				Kind:  protocol.CompletionItemKindVariable,

      
        
        186
        +				// Detail: protocol.NewOptional("account"),

      
        
        187
        +			})

      
        
        188
        +		}

      
        
        189
        +	}

      
        
        190
        +	slices.SortFunc(items, func(a, b protocol.CompletionItem) int {

      
        
        191
        +		return strings.Compare(a.Label, b.Label)

      
        
        192
        +	})

      
        
        193
        +	return items

      
        
        194
        +}

      
        
        195
        +

      
        
        196
        +func completePayees(sema *semantic.Context, prefix string) []protocol.CompletionItem {

      
        
        197
        +	if sema == nil {

      
        
        198
        +		return nil

      
        
        199
        +	}

      
        
        200
        +	var items []protocol.CompletionItem

      
        
        201
        +	for name := range sema.Payees {

      
        
        202
        +		if prefix != "" && !strings.HasPrefix(name, prefix) {

      
        
        203
        +			continue

      
        
        204
        +		}

      
        
        205
        +		items = append(items, protocol.CompletionItem{

      
        
        206
        +			Label: name,

      
        
        207
        +			Kind:  protocol.CompletionItemKindText,

      
        
        208
        +			// Detail: protocol.NewOptional("payee"),

      
        
        209
        +		})

      
        
        210
        +	}

      
        
        211
        +	slices.SortFunc(items, func(a, b protocol.CompletionItem) int {

      
        
        212
        +		return strings.Compare(a.Label, b.Label)

      
        
        213
        +	})

      
        
        214
        +	return items

      
        
        215
        +}

      
        
        216
        +

      
        
        217
        +func completeCommodities(sema *semantic.Context, prefix string) []protocol.CompletionItem {

      
        
        218
        +	if sema == nil {

      
        
        219
        +		return nil

      
        
        220
        +	}

      
        
        221
        +	var items []protocol.CompletionItem

      
        
        222
        +	for name := range sema.Commodities {

      
        
        223
        +		if prefix != "" && !strings.HasPrefix(name, prefix) {

      
        
        224
        +			continue

      
        
        225
        +		}

      
        
        226
        +		items = append(items, protocol.CompletionItem{

      
        
        227
        +			Label: name,

      
        
        228
        +			Kind:  protocol.CompletionItemKindConstant,

      
        
        229
        +			// Detail: protocol.NewOptional("commodity"),

      
        
        230
        +		})

      
        
        231
        +	}

      
        
        232
        +	slices.SortFunc(items, func(a, b protocol.CompletionItem) int {

      
        
        233
        +		return strings.Compare(a.Label, b.Label)

      
        
        234
        +	})

      
        
        235
        +	return items

      
        
        236
        +}

      
        
        237
        +

      
        
        238
        +// keywords

      
        
        239
        +

      
        
        240
        +type keywordInfo struct {

      
        
        241
        +	label, detail string

      
        
        242
        +}

      
        
        243
        +

      
        
        244
        +var directiveKeywords = []keywordInfo{

      
        
        245
        +	{"account", "define an account"},

      
        
        246
        +	{"alias", "alias an account name"},

      
        
        247
        +	{"apply", "apply a directive"},

      
        
        248
        +	{"comment", "comment block"},

      
        
        249
        +	{"commodity", "define a commodity"},

      
        
        250
        +	{"decimal-mark", "set decimal mark"},

      
        
        251
        +	{"end", "end a scoped directive"},

      
        
        252
        +	{"include", "include another file"},

      
        
        253
        +	{"payee", "define a payee"},

      
        
        254
        +	{"tag", "define a tag"},

      
        
        255
        +	{"year", "set default year"},

      
        
        256
        +	{"C", "commodity conversion"},

      
        
        257
        +	{"D", "set default commodity"},

      
        
        258
        +	{"N", "ignored price"},

      
        
        259
        +	{"P", "market price"},

      
        
        260
        +	{"Y", "set default year"},

      
        
        261
        +}

      
        
        262
        +

      
        
        263
        +func completeKeywords(prefix string) []protocol.CompletionItem {

      
        
        264
        +	var items []protocol.CompletionItem

      
        
        265
        +	for _, kw := range directiveKeywords {

      
        
        266
        +		if !strings.HasPrefix(kw.label, prefix) {

      
        
        267
        +			continue

      
        
        268
        +		}

      
        
        269
        +		items = append(items, protocol.CompletionItem{

      
        
        270
        +			Label:  kw.label,

      
        
        271
        +			Kind:   protocol.CompletionItemKindKeyword,

      
        
        272
        +			Detail: protocol.NewOptional(kw.detail),

      
        
        273
        +		})

      
        
        274
        +	}

      
        
        275
        +	return items

      
        
        276
        +}

      
M internal/lsp/diagnostics.go
···
        39
        39
         		return

      
        40
        40
         	}

      
        41
        41
         

      
        
        42
        +	var allFiles []*journal.ParsedFile

      
        42
        43
         	var finds []linter.Find

      
        43
        44
         	for _, root := range roots {

      
        44
        
        -		finds = append(finds, s.linter.Run(semantic.Build(journal.CollectFiles(root)))...)

      
        
        45
        +		files := journal.CollectFiles(root)

      
        
        46
        +		c := semantic.Build(files)

      
        
        47
        +		finds = append(finds, s.linter.Run(c)...)

      
        
        48
        +		allFiles = append(allFiles, files...)

      
        45
        49
         	}

      
        46
        50
         	finds = dedupFinds(finds)

      
        
        51
        +

      
        
        52
        +	s.semaMu.Lock()

      
        
        53
        +	s.semanticCtx = semantic.Build(allFiles)

      
        
        54
        +	s.semaMu.Unlock()

      
        47
        55
         

      
        48
        56
         	diagsByFile := groupFindsByFile(finds)

      
        49
        57
         	activeFiles := activeFileSet(s.openDocs, s.loader.Ordered())

      
M internal/lsp/server.go
···
        12
        12
         	"olexsmir.xyz/clerk/internal/linter"

      
        13
        13
         	"olexsmir.xyz/clerk/journal"

      
        14
        14
         	"olexsmir.xyz/clerk/journal/printer"

      
        
        15
        +	"olexsmir.xyz/clerk/journal/semantic"

      
        15
        16
         )

      
        16
        17
         

      
        17
        18
         const name = "clerk"

      ···
        36
        37
         	linter    *linter.Linter

      
        37
        38
         	debouncer *time.Timer

      
        38
        39
         

      
        39
        
        -	mu       sync.Mutex

      
        40
        
        -	openDocs map[uri.URI]docState

      
        
        40
        +	mu          sync.Mutex

      
        
        41
        +	openDocs    map[uri.URI]docState

      
        
        42
        +	semanticCtx *semantic.Context

      
        
        43
        +	semaMu      sync.RWMutex

      
        41
        44
         }

      
        42
        45
         

      
        43
        46
         func (s *server) Initialize(ctx context.Context, params *protocol.InitializeParams) (*protocol.InitializeResult, error) {

      ···
        52
        55
         				Change:    new(protocol.TextDocumentSyncKindFull),

      
        53
        56
         			},

      
        54
        57
         			DocumentFormattingProvider: &protocol.DocumentFormattingOptions{},

      
        55
        
        -			// CompletionProvider: &protocol.CompletionOptions{

      
        56
        
        -			// 	TriggerCharacters: []string{" ", ":", "/"},

      
        57
        
        -			// },

      
        
        58
        +			CompletionProvider: &protocol.CompletionOptions{

      
        
        59
        +				TriggerCharacters: []string{":", "/"},

      
        
        60
        +			},

      
        58
        61
         		},

      
        59
        62
         	}, nil

      
        60
        63
         }