all repos

clerk @ 6106d588c3cc8b0aa2d551af7c80a0e8268ae14a

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
lsp: completion, 19 days ago
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
}