all repos

clerk @ c027d7c5c0081deeabfaa5147338fbc821391093

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
journal: tags support, 2 months ago
1
package lsp
2
3
import (
4
	"context"
5
	"fmt"
6
	"math"
7
	"sort"
8
	"strings"
9
	"time"
10
11
	"go.lsp.dev/protocol"
12
13
	"olexsmir.xyz/clerk/internal/analyzer"
14
	"olexsmir.xyz/clerk/internal/lsp/fuzzy"
15
	"olexsmir.xyz/clerk/internal/lsp/lsputil"
16
	"olexsmir.xyz/clerk/journal/ast"
17
	"olexsmir.xyz/clerk/journal/lexer"
18
	"olexsmir.xyz/clerk/journal/token"
19
)
20
21
func (s *server) Completion(ctx context.Context, params *protocol.CompletionParams) (protocol.CompletionResult, error) {
22
	state, ok := s.getDocState(params.TextDocument.URI)
23
	if !ok {
24
		return &protocol.CompletionList{}, nil
25
	}
26
	cursor := lsputil.Offset(state.text, int(params.Position.Line), int(params.Position.Character))
27
	if cursor > len(state.text) {
28
		return &protocol.CompletionList{}, nil
29
	}
30
	detectedCtx, start := detectCompletionCtx(state.text, cursor)
31
	if detectedCtx == cmplNone {
32
		return &protocol.CompletionList{}, nil
33
	}
34
	a := s.analysis()
35
	if a == nil {
36
		return &protocol.CompletionList{}, nil
37
	}
38
	return &protocol.CompletionList{
39
		IsIncomplete: true,
40
		Items:        cmplItems(a, detectedCtx, state.text, start, cursor),
41
	}, nil
42
}
43
44
const maxCompletionItems = 50
45
46
type cmplCtx int
47
48
const (
49
	cmplNone cmplCtx = iota
50
	cmplAccount
51
	cmplPayee
52
	cmplCommodity
53
	cmplTagName
54
	cmplTagValue
55
	cmplDirective
56
)
57
58
var directiveKeywords = []string{
59
	"account", "include", "commodity", "payee", "decimal-mark", "alias",
60
	"apply", "end", "tag", "year", "D", "P", "N", "C", "Y",
61
}
62
63
func detectCompletionCtx(content string, cursor int) (cmplCtx, int) {
64
	toks := lexLine(content, cursor)
65
	lineStart, _ := lineBounds(content, cursor)
66
67
	if m := commentMarker(toks, cursor); m != -1 {
68
		return cmplTagContext(content, toks[m].Span.End.Offset, cursor)
69
	}
70
	if len(toks) == 0 {
71
		return cmplDirective, lineStart
72
	}
73
74
	switch toks[0].Type {
75
	case token.INDENT:
76
		return cmplPostingCtx(content, cursor, toks)
77
	case token.DATE:
78
		return cmplHeaderCtx(content, cursor, toks)
79
	case token.ACCOUNT, token.COMMODITY, token.PAYEE, token.TAG:
80
		return cmplDirectiveContext(cursor, lineStart, toks)
81
	case token.TEXT:
82
		return cmplDirective, lineStart // half-typed keyword or unparseable line
83
	}
84
	return cmplNone, cursor
85
}
86
87
func cmplPostingCtx(content string, cursor int, toks []token.Token) (cmplCtx, int) {
88
	if inDirectiveBody(content, cursor) {
89
		return cmplNone, cursor
90
	}
91
	fieldStart := toks[0].Span.End.Offset
92
	i := 1
93
	for i < len(toks) {
94
		switch toks[i].Type {
95
		case token.STAR, token.BANG, token.LPAREN, token.LBRACKET, token.WHITESPACE:
96
			fieldStart = toks[i].Span.End.Offset
97
			i++
98
		default:
99
			goto run
100
		}
101
	}
102
run:
103
	// account run: consecutive account-name segments and colons
104
	fieldEnd := fieldStart
105
	for ; i < len(toks); i++ {
106
		if toks[i].Type != token.TEXT && toks[i].Type != token.COLON {
107
			break
108
		}
109
		fieldEnd = toks[i].Span.End.Offset
110
	}
111
	if cursor <= fieldEnd && cursor >= fieldStart {
112
		return cmplAccount, fieldStart
113
	}
114
	if cursor > fieldEnd {
115
		if t := tokenUnder(toks, cursor); t != nil && (t.Type == token.COMMODITYMARK || t.Type == token.STRING) {
116
			start := t.Span.Start.Offset
117
			if t.Type == token.STRING {
118
				start++ // skip opening quote
119
			}
120
			return cmplCommodity, start
121
		}
122
		if strings.TrimSpace(content[fieldEnd:cursor]) == "" {
123
			return cmplCommodity, cursor
124
		}
125
	}
126
	return cmplNone, cursor
127
}
128
129
func cmplHeaderCtx(content string, cursor int, toks []token.Token) (cmplCtx, int) {
130
	// skip date, status, code, and whitespace - where the payee beginds
131
	fieldStart := toks[0].Span.End.Offset
132
	fieldEnd := fieldStart
133
	seen := false
134
	for i := 1; i < len(toks); i++ {
135
		t := toks[i]
136
		switch t.Type {
137
		case token.WHITESPACE, token.STAR, token.BANG, token.DATE, token.TIME,
138
			token.EQ, token.EQEQ, token.EQEQEQ:
139
			fieldStart = t.Span.End.Offset
140
			fieldEnd = t.Span.End.Offset
141
		case token.TEXT:
142
			lit := content[t.Span.Start.Offset:t.Span.End.Offset]
143
			if !seen && len(lit) >= 2 && lit[0] == '(' && lit[len(lit)-1] == ')' {
144
				fieldStart = t.Span.End.Offset // parenthesized code
145
				fieldEnd = t.Span.End.Offset
146
				continue
147
			}
148
			if !seen {
149
				fieldStart = t.Span.Start.Offset
150
				seen = true
151
			}
152
			fieldEnd = t.Span.End.Offset
153
			if p := strings.IndexByte(lit, '|'); p >= 0 {
154
				fieldEnd = t.Span.Start.Offset + p // "payee|note" keeps the pipe in the token
155
				return payeeAt(cursor, fieldStart, fieldEnd)
156
			}
157
		case token.STRING:
158
			if !seen {
159
				fieldStart = t.Span.Start.Offset + 1 // skip opening quote
160
				seen = true
161
			}
162
			fieldEnd = t.Span.End.Offset
163
		default:
164
			// PIPE, SEMICOLON, ...
165
			return payeeAt(cursor, fieldStart, fieldEnd)
166
		}
167
	}
168
	if seen {
169
		return payeeAt(cursor, fieldStart, fieldEnd)
170
	}
171
	// no payee yet: the payee field is the whitespace after the header meta
172
	if cursor >= fieldStart && strings.TrimSpace(content[fieldStart:cursor]) == "" {
173
		return cmplPayee, cursor
174
	}
175
	return cmplNone, cursor
176
}
177
178
func payeeAt(cursor, start, end int) (cmplCtx, int) {
179
	if cursor >= start && cursor <= end {
180
		return cmplPayee, start
181
	}
182
	return cmplNone, cursor
183
}
184
185
// cmplDirectiveContext classifies a directive line. keyword completion before the keyword ends, symbol completion in the value field after
186
func cmplDirectiveContext(cursor, lineStart int, toks []token.Token) (cmplCtx, int) {
187
	kwEnd := toks[0].Span.End.Offset
188
	if cursor <= kwEnd {
189
		return cmplDirective, lineStart
190
	}
191
	start := kwEnd
192
	for i := 1; i < len(toks); i++ {
193
		if toks[i].Type == token.WHITESPACE || toks[i].Span.End.Offset <= kwEnd {
194
			continue
195
		}
196
		start = toks[i].Span.Start.Offset
197
		if toks[i].Type == token.STRING {
198
			start++ // skip opening quote
199
		}
200
		break
201
	}
202
	if start > cursor {
203
		start = cursor
204
	}
205
	switch toks[0].Type {
206
	case token.ACCOUNT:
207
		return cmplAccount, start
208
	case token.COMMODITY:
209
		return cmplCommodity, start
210
	case token.PAYEE:
211
		return cmplPayee, start
212
	case token.TAG:
213
		return cmplTagName, start
214
	}
215
	return cmplNone, cursor
216
}
217
218
// commentStart completes tag names before ':' of the current tag and tag values after it
219
func cmplTagContext(content string, commentStart, cursor int) (cmplCtx, int) {
220
	prefix := content[commentStart:cursor]
221
	segStart := commentStart
222
	seg := prefix
223
	if comma := strings.LastIndexByte(prefix, ','); comma >= 0 {
224
		segStart = commentStart + comma + 1
225
		seg = prefix[comma+1:]
226
	}
227
	if colon := strings.IndexByte(seg, ':'); colon >= 0 {
228
		start := segStart + colon + 1
229
		for start < cursor && (content[start] == ' ' || content[start] == '\t') {
230
			start++
231
		}
232
		return cmplTagValue, start
233
	}
234
	keyStart := commentStart + lastSeparator(prefix) + 1
235
	return cmplTagName, keyStart
236
}
237
238
// tagKeyAt returns the key of tag whose value region starts at start
239
func tagKeyAt(content string, start int) (string, bool) {
240
	lineStart, _ := lineBounds(content, start)
241
	segStart := lineStart
242
	for i := start - 1; i >= lineStart; i-- {
243
		switch content[i] {
244
		case ',', ';', '#', '%':
245
			segStart = i + 1
246
			i = lineStart - 1 // stop at the separator closest to start
247
		}
248
	}
249
	colon := strings.IndexByte(content[segStart:start], ':')
250
	if colon < 0 {
251
		return "", false
252
	}
253
	colon += segStart
254
	keyStart := lastSeparator(content[segStart:colon]) + 1
255
	key := content[segStart+keyStart : colon]
256
	if key == "" {
257
		return "", false
258
	}
259
	return key, true
260
}
261
262
// commentMarker returns index of the first comment marker token at or before the cursor, or -1.
263
func commentMarker(toks []token.Token, cursor int) int {
264
	for i, t := range toks {
265
		switch t.Type {
266
		case token.SEMICOLON, token.HASH, token.PERCENT:
267
			if t.Span.Start.Offset <= cursor {
268
				return i
269
			}
270
		case token.STAR:
271
			if i == 0 && t.Span.Start.Offset <= cursor {
272
				return i
273
			}
274
		}
275
	}
276
	return -1
277
}
278
279
func inDirectiveBody(content string, cursor int) bool {
280
	lineStart, _ := lineBounds(content, cursor)
281
	if toks := lexLine(content, lineStart); len(toks) == 0 || toks[0].Type != token.INDENT {
282
		return false
283
	}
284
	for lineStart > 0 {
285
		lineStart, _ = lineBounds(content, lineStart-1)
286
		toks := lexLine(content, lineStart)
287
		if len(toks) == 0 || toks[0].Type != token.INDENT {
288
			return len(toks) > 0 && (toks[0].Type == token.ACCOUNT || toks[0].Type == token.COMMODITY)
289
		}
290
	}
291
	return false
292
}
293
294
type cmplCand struct {
295
	label    string
296
	score    float64
297
	count    int
298
	lastUsed ast.Date
299
}
300
301
// cmplItems ranks candidates for the content against typed pattern
302
func cmplItems(a *analyzer.Analysis, ctx cmplCtx, content string, start, cursor int) []protocol.CompletionItem {
303
	pattern := content[start:cursor]
304
305
	var kind protocol.CompletionItemKind
306
	var cands []cmplCand
307
	switch ctx {
308
	case cmplAccount:
309
		kind = protocol.CompletionItemKindClass
310
		for name, info := range a.Accounts {
311
			cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsed: info.LastUsed})
312
		}
313
	case cmplPayee:
314
		kind = protocol.CompletionItemKindVariable
315
		for name, info := range a.Payees {
316
			cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsed: info.LastUsed})
317
		}
318
	case cmplCommodity:
319
		kind = protocol.CompletionItemKindValue
320
		for name, info := range a.Commodities {
321
			cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsed: info.LastUsed})
322
		}
323
	case cmplTagName:
324
		kind = protocol.CompletionItemKindProperty
325
		for name, info := range a.Tags {
326
			cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsed: info.LastUsed})
327
		}
328
	case cmplTagValue:
329
		kind = protocol.CompletionItemKindProperty
330
		if key, ok := tagKeyAt(content, start); ok {
331
			if info, ok := a.Tags[key]; ok {
332
				for _, v := range info.Values {
333
					cands = append(cands, cmplCand{label: v})
334
				}
335
			}
336
		}
337
	case cmplDirective:
338
		kind = protocol.CompletionItemKindKeyword
339
		for _, name := range directiveKeywords {
340
			cands = append(cands, cmplCand{label: name})
341
		}
342
	default:
343
		return nil
344
	}
345
346
	var newest ast.Date
347
	if n := len(a.Dates); n > 0 {
348
		newest = a.Dates[n-1]
349
	}
350
	ranked := cands[:0]
351
	for i := range cands {
352
		sc := fuzzy.Score(pattern, cands[i].label)
353
		if sc != 0 {
354
			sc *= 1 + math.Log1p(float64(cands[i].count))
355
			if cands[i].count > 0 && cands[i].lastUsed.Year != 0 && newest.Year != 0 {
356
				days := daysBetween(cands[i].lastUsed, newest)
357
				sc *= 1 + 0.5*max(0, 1-float64(days)/365)
358
			}
359
		}
360
		cands[i].score = sc
361
		if sc != 0 {
362
			ranked = append(ranked, cands[i])
363
		}
364
	}
365
	sort.Slice(ranked, func(i, j int) bool {
366
		if ranked[i].score != ranked[j].score {
367
			return ranked[i].score > ranked[j].score
368
		}
369
		if ranked[i].lastUsed != ranked[j].lastUsed {
370
			return ranked[i].lastUsed.Compare(ranked[j].lastUsed) > 0
371
		}
372
		return ranked[i].label < ranked[j].label
373
	})
374
	if len(ranked) > maxCompletionItems {
375
		ranked = ranked[:maxCompletionItems]
376
	}
377
378
	replace := protocol.Range{
379
		Start: lsputil.Position(content, start),
380
		End:   lsputil.Position(content, cursor),
381
	}
382
	items := make([]protocol.CompletionItem, len(ranked))
383
	for i, r := range ranked {
384
		it := protocol.CompletionItem{
385
			Label:      r.label,
386
			Kind:       kind,
387
			SortText:   protocol.NewOptional(fmt.Sprintf("%04d", i)),
388
			FilterText: protocol.NewOptional(r.label),
389
			TextEdit: &protocol.TextEdit{
390
				Range:   replace,
391
				NewText: r.label,
392
			},
393
		}
394
		if r.count > 0 {
395
			it.Detail = protocol.NewOptional(fmt.Sprintf("%d uses", r.count))
396
		}
397
		items[i] = it
398
	}
399
	return items
400
}
401
402
func daysBetween(a, b ast.Date) int {
403
	return int(time.Date(b.Year, time.Month(b.Month), b.Day, 0, 0, 0, 0, time.UTC).
404
		Sub(time.Date(a.Year, time.Month(a.Month), a.Day, 0, 0, 0, 0, time.UTC)).
405
		Hours() / 24)
406
}
407
408
// lineBounds returns the byte offsets of the line containing cursor
409
func lineBounds(content string, cursor int) (start, end int) {
410
	start = cursor
411
	for start > 0 && content[start-1] != '\n' && content[start-1] != '\r' {
412
		start--
413
	}
414
	end = start
415
	for end < len(content) && content[end] != '\n' && content[end] != '\r' {
416
		end++
417
	}
418
	return start, end
419
}
420
421
func lastSeparator(s string) int {
422
	for i := len(s) - 1; i >= 0; i-- {
423
		switch s[i] {
424
		case ' ', '\t', ',':
425
			return i
426
		}
427
	}
428
	return -1
429
}
430
431
func lexLine(content string, cursor int) []token.Token {
432
	lineStart, lineEnd := lineBounds(content, cursor)
433
	l := lexer.New("", []byte(content[lineStart:lineEnd]))
434
	var out []token.Token
435
	for {
436
		t := l.Next()
437
		if t.Type == token.EOF || t.Type == token.NEWLINE {
438
			break
439
		}
440
		t.Span.Start.Offset += lineStart
441
		t.Span.End.Offset += lineStart
442
		out = append(out, t)
443
	}
444
	return out
445
}
446
447
func tokenUnder(toks []token.Token, cursor int) *token.Token {
448
	for i := range toks {
449
		t := &toks[i]
450
		if t.Span.Start.Offset <= cursor && cursor <= t.Span.End.Offset {
451
			return t
452
		}
453
	}
454
	return nil
455
}