all repos

clerk @ f68a2c6

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
lsp: date completion, 1 month 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 := state.lineIdx.Offset(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
	an := s.analysisFor(params.TextDocument.URI)
35
	if an == nil {
36
		return &protocol.CompletionList{}, nil
37
	}
38
	return &protocol.CompletionList{
39
		IsIncomplete: true,
40
		Items:        cmplItems(an, detectedCtx, state.text, state.lineIdx, 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
	cmplDate
54
	cmplTagName
55
	cmplTagValue
56
	cmplDirective
57
)
58
59
var directiveKeywords = []string{
60
	"account", "include", "commodity", "payee", "decimal-mark", "alias",
61
	"apply", "end", "tag", "year", "D", "P", "N", "C", "Y",
62
}
63
64
func detectCompletionCtx(content string, cursor int) (cmplCtx, int) {
65
	toks := lexLine(content, cursor)
66
	lineStart, _ := lineBounds(content, cursor)
67
	if len(toks) == 0 {
68
		return cmplDate, lineStart
69
	}
70
	if m := commentMarker(toks, cursor); m != -1 {
71
		return cmplTagContext(content, toks[m].Span.End.Offset, cursor)
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.ILLEGAL:
80
		// a digit that is not lexed yet, as a date: 20, 2026-0
81
		if len(toks[0].Literal) > 0 && isDigitByte(toks[0].Literal[0]) {
82
			return cmplHeaderCtx(content, cursor, toks)
83
		}
84
		return cmplNone, cursor
85
	case token.ACCOUNT, token.COMMODITY, token.PAYEE, token.TAG:
86
		return cmplDirectiveContext(cursor, lineStart, toks)
87
	case token.TEXT:
88
		return cmplDirective, lineStart // half-typed keyword or unparseable line
89
	default:
90
		return cmplNone, cursor
91
	}
92
}
93
94
func cmplPostingCtx(content string, cursor int, toks []token.Token) (cmplCtx, int) {
95
	if inDirectiveBody(content, cursor) {
96
		return cmplNone, cursor
97
	}
98
	fieldStart := toks[0].Span.End.Offset
99
	i := 1
100
	for i < len(toks) {
101
		switch toks[i].Type {
102
		case token.STAR, token.BANG, token.LPAREN, token.LBRACKET, token.WHITESPACE:
103
			fieldStart = toks[i].Span.End.Offset
104
			i++
105
		default:
106
			goto run
107
		}
108
	}
109
run:
110
	// account run: consecutive account-name segments and colons
111
	fieldEnd := fieldStart
112
	for ; i < len(toks); i++ {
113
		if toks[i].Type != token.TEXT && toks[i].Type != token.COLON {
114
			break
115
		}
116
		fieldEnd = toks[i].Span.End.Offset
117
	}
118
	if cursor <= fieldEnd && cursor >= fieldStart {
119
		return cmplAccount, fieldStart
120
	}
121
	if cursor > fieldEnd {
122
		if t := tokenUnder(toks, cursor); t != nil && (t.Type == token.COMMODITYMARK || t.Type == token.STRING) {
123
			start := t.Span.Start.Offset
124
			if t.Type == token.STRING {
125
				start++ // skip opening quote
126
			}
127
			return cmplCommodity, start
128
		}
129
		if strings.TrimSpace(content[fieldEnd:cursor]) == "" {
130
			return cmplCommodity, cursor
131
		}
132
	}
133
	return cmplNone, cursor
134
}
135
136
func cmplHeaderCtx(content string, cursor int, toks []token.Token) (cmplCtx, int) {
137
	// cursor inside date token
138
	if cursor >= toks[0].Span.Start.Offset && cursor <= toks[0].Span.End.Offset {
139
		return cmplDate, toks[0].Span.Start.Offset
140
	}
141
142
	// skip date, status, code, and whitespace - where the payee beginds
143
	fieldStart := toks[0].Span.End.Offset
144
	fieldEnd := fieldStart
145
	seen := false
146
	for i := 1; i < len(toks); i++ {
147
		t := toks[i]
148
		switch t.Type {
149
		case token.WHITESPACE, token.STAR, token.BANG, token.DATE, token.TIME,
150
			token.EQ, token.EQEQ, token.EQEQEQ:
151
			fieldStart = t.Span.End.Offset
152
			fieldEnd = t.Span.End.Offset
153
		case token.TEXT:
154
			lit := content[t.Span.Start.Offset:t.Span.End.Offset]
155
			if !seen && len(lit) >= 2 && lit[0] == '(' && lit[len(lit)-1] == ')' {
156
				fieldStart = t.Span.End.Offset // parenthesized code
157
				fieldEnd = t.Span.End.Offset
158
				continue
159
			}
160
			if !seen {
161
				fieldStart = t.Span.Start.Offset
162
				seen = true
163
			}
164
			fieldEnd = t.Span.End.Offset
165
			if p := strings.IndexByte(lit, '|'); p >= 0 {
166
				fieldEnd = t.Span.Start.Offset + p // "payee|note" keeps the pipe in the token
167
				return payeeAt(cursor, fieldStart, fieldEnd)
168
			}
169
		case token.STRING:
170
			if !seen {
171
				fieldStart = t.Span.Start.Offset + 1 // skip opening quote
172
				seen = true
173
			}
174
			fieldEnd = t.Span.End.Offset
175
		default: // PIPE, SEMICOLON, ...
176
			return payeeAt(cursor, fieldStart, fieldEnd)
177
		}
178
	}
179
	if seen {
180
		return payeeAt(cursor, fieldStart, fieldEnd)
181
	}
182
	// no payee yet: the payee field is the whitespace after the header meta
183
	if cursor >= fieldStart && strings.TrimSpace(content[fieldStart:cursor]) == "" {
184
		return cmplPayee, cursor
185
	}
186
	return cmplNone, cursor
187
}
188
189
func payeeAt(cursor, start, end int) (cmplCtx, int) {
190
	if cursor >= start && cursor <= end {
191
		return cmplPayee, start
192
	}
193
	return cmplNone, cursor
194
}
195
196
// cmplDirectiveContext classifies a directive line. keyword completion before the keyword ends, symbol completion in the value field after
197
func cmplDirectiveContext(cursor, lineStart int, toks []token.Token) (cmplCtx, int) {
198
	kwEnd := toks[0].Span.End.Offset
199
	if cursor <= kwEnd {
200
		return cmplDirective, lineStart
201
	}
202
	start := kwEnd
203
	for i := 1; i < len(toks); i++ {
204
		if toks[i].Type == token.WHITESPACE || toks[i].Span.End.Offset <= kwEnd {
205
			continue
206
		}
207
		start = toks[i].Span.Start.Offset
208
		if toks[i].Type == token.STRING {
209
			start++ // skip opening quote
210
		}
211
		break
212
	}
213
	if start > cursor {
214
		start = cursor
215
	}
216
	switch toks[0].Type {
217
	case token.ACCOUNT:
218
		return cmplAccount, start
219
	case token.COMMODITY:
220
		return cmplCommodity, start
221
	case token.PAYEE:
222
		return cmplPayee, start
223
	case token.TAG:
224
		return cmplTagName, start
225
	}
226
	return cmplNone, cursor
227
}
228
229
// commentStart completes tag names before ':' of the current tag and tag values after it
230
func cmplTagContext(content string, commentStart, cursor int) (cmplCtx, int) {
231
	prefix := content[commentStart:cursor]
232
	segStart := commentStart
233
	seg := prefix
234
	if comma := strings.LastIndexByte(prefix, ','); comma >= 0 {
235
		segStart = commentStart + comma + 1
236
		seg = prefix[comma+1:]
237
	}
238
	if colon := strings.IndexByte(seg, ':'); colon >= 0 {
239
		start := segStart + colon + 1
240
		for start < cursor && (content[start] == ' ' || content[start] == '\t') {
241
			start++
242
		}
243
		return cmplTagValue, start
244
	}
245
	keyStart := commentStart + lastSeparator(prefix) + 1
246
	return cmplTagName, keyStart
247
}
248
249
// tagKeyAt returns the key of tag whose value region starts at start
250
func tagKeyAt(content string, start int) (string, bool) {
251
	lineStart, _ := lineBounds(content, start)
252
	segStart := lineStart
253
	for i := start - 1; i >= lineStart; i-- {
254
		switch content[i] {
255
		case ',', ';', '#', '%':
256
			segStart = i + 1
257
			i = lineStart - 1 // stop at the separator closest to start
258
		}
259
	}
260
	colon := strings.IndexByte(content[segStart:start], ':')
261
	if colon < 0 {
262
		return "", false
263
	}
264
	colon += segStart
265
	keyStart := lastSeparator(content[segStart:colon]) + 1
266
	key := content[segStart+keyStart : colon]
267
	if key == "" {
268
		return "", false
269
	}
270
	return key, true
271
}
272
273
// commentMarker returns index of the first comment marker token at or before the cursor, or -1.
274
func commentMarker(toks []token.Token, cursor int) int {
275
	for i, t := range toks {
276
		switch t.Type {
277
		case token.SEMICOLON, token.HASH, token.PERCENT:
278
			if t.Span.Start.Offset <= cursor {
279
				return i
280
			}
281
		case token.STAR:
282
			if i == 0 && t.Span.Start.Offset <= cursor {
283
				return i
284
			}
285
		}
286
	}
287
	return -1
288
}
289
290
func inDirectiveBody(content string, cursor int) bool {
291
	lineStart, _ := lineBounds(content, cursor)
292
	if toks := lexLine(content, lineStart); len(toks) == 0 || toks[0].Type != token.INDENT {
293
		return false
294
	}
295
	for lineStart > 0 {
296
		lineStart, _ = lineBounds(content, lineStart-1)
297
		toks := lexLine(content, lineStart)
298
		if len(toks) == 0 || toks[0].Type != token.INDENT {
299
			return len(toks) > 0 && (toks[0].Type == token.ACCOUNT || toks[0].Type == token.COMMODITY)
300
		}
301
	}
302
	return false
303
}
304
305
type cmplCand struct {
306
	label        string
307
	score        float64
308
	count        int
309
	lastUsedDays int64 // days since 1970-01-01; 0 when unset
310
	rank         int   // lower sorts first among equal scores; 0 except for date completions
311
}
312
313
// cmplItems ranks candidates for the content against typed pattern
314
func cmplItems(a *analyzer.Analysis, ctx cmplCtx, content string, li *lsputil.LineIndex, start, cursor int) []protocol.CompletionItem {
315
	pattern := content[start:cursor]
316
317
	var kind protocol.CompletionItemKind
318
	var cands []cmplCand
319
	switch ctx {
320
	case cmplAccount:
321
		kind = protocol.CompletionItemKindClass
322
		cands = make([]cmplCand, 0, len(a.Accounts))
323
		for name, info := range a.Accounts {
324
			cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsedDays: dateToDays(info.LastUsed)})
325
		}
326
	case cmplPayee:
327
		kind = protocol.CompletionItemKindVariable
328
		cands = make([]cmplCand, 0, len(a.Payees))
329
		for name, info := range a.Payees {
330
			cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsedDays: dateToDays(info.LastUsed)})
331
		}
332
	case cmplCommodity:
333
		kind = protocol.CompletionItemKindValue
334
		cands = make([]cmplCand, 0, len(a.Commodities))
335
		for name, info := range a.Commodities {
336
			cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsedDays: dateToDays(info.LastUsed)})
337
		}
338
	case cmplTagName:
339
		kind = protocol.CompletionItemKindProperty
340
		cands = make([]cmplCand, 0, len(a.Tags))
341
		for name, info := range a.Tags {
342
			cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsedDays: dateToDays(info.LastUsed)})
343
		}
344
	case cmplTagValue:
345
		kind = protocol.CompletionItemKindProperty
346
		if key, ok := tagKeyAt(content, start); ok {
347
			if info, ok := a.Tags[key]; ok {
348
				for _, v := range info.Values {
349
					cands = append(cands, cmplCand{label: v})
350
				}
351
			}
352
		}
353
	case cmplDirective:
354
		kind = protocol.CompletionItemKindKeyword
355
		for _, name := range directiveKeywords {
356
			cands = append(cands, cmplCand{label: name})
357
		}
358
	case cmplDate:
359
		kind = protocol.CompletionItemKindConstant
360
		now := time.Now()
361
		sep, hasYear := dateStyle(a.DateStrings)
362
		today := renderDate(now, sep, hasYear)
363
		yesterday := renderDate(now.AddDate(0, 0, -1), sep, hasYear)
364
		twoDaysAgo := renderDate(now.AddDate(0, 0, -2), sep, hasYear)
365
		cands = append(cands,
366
			cmplCand{label: today, rank: 0},
367
			cmplCand{label: yesterday, rank: 1},
368
			cmplCand{label: twoDaysAgo, rank: 2})
369
370
		seen := map[string]bool{today: true, yesterday: true, twoDaysAgo: true}
371
		for i, d := range a.Dates {
372
			if !datePatternMatch(pattern, a.DateStrings[i]) {
373
				continue
374
			}
375
			label := a.DateStrings[i]
376
			if seen[label] {
377
				continue
378
			}
379
			seen[label] = true
380
			cands = append(cands, cmplCand{label: label, rank: 3, lastUsedDays: dateToDays(d)})
381
		}
382
	default:
383
		return nil
384
	}
385
386
	var newest int64 // days since epoch of the newest transaction; 0 when none
387
	if n := len(a.Dates); n > 0 {
388
		newest = dateToDays(a.Dates[n-1])
389
	}
390
	m := fuzzy.Compile(pattern)
391
	ranked := cands[:0]
392
	for i := range cands {
393
		sc := m.Score(cands[i].label)
394
		if sc != 0 {
395
			sc *= 1 + math.Log1p(float64(cands[i].count))
396
			if cands[i].count > 0 && cands[i].lastUsedDays != 0 && newest != 0 {
397
				days := newest - cands[i].lastUsedDays
398
				sc *= 1 + 0.5*max(0, 1-float64(days)/365)
399
			}
400
		}
401
		cands[i].score = sc
402
		if sc != 0 {
403
			ranked = append(ranked, cands[i])
404
		}
405
	}
406
	sort.Slice(ranked, func(i, j int) bool {
407
		if ranked[i].score != ranked[j].score {
408
			return ranked[i].score > ranked[j].score
409
		}
410
		if ranked[i].rank != ranked[j].rank {
411
			return ranked[i].rank < ranked[j].rank
412
		}
413
		if ranked[i].lastUsedDays != ranked[j].lastUsedDays {
414
			return ranked[i].lastUsedDays > ranked[j].lastUsedDays
415
		}
416
		return ranked[i].label < ranked[j].label
417
	})
418
	if len(ranked) > maxCompletionItems {
419
		ranked = ranked[:maxCompletionItems]
420
	}
421
422
	end := cursor
423
	if ctx == cmplDate {
424
		end = dateTokenEnd(content, start)
425
	}
426
	items := make([]protocol.CompletionItem, len(ranked))
427
	for i, r := range ranked {
428
		it := protocol.CompletionItem{
429
			Label:      r.label,
430
			Kind:       kind,
431
			SortText:   protocol.NewOptional(fmt.Sprintf("%04d", i)),
432
			FilterText: protocol.NewOptional(r.label),
433
			TextEdit: &protocol.TextEdit{
434
				Range: protocol.Range{
435
					Start: li.Position(start),
436
					End:   li.Position(end),
437
				},
438
				NewText: r.label,
439
			},
440
		}
441
		if r.count > 0 {
442
			it.Detail = protocol.NewOptional(fmt.Sprintf("%d uses", r.count))
443
		}
444
		items[i] = it
445
	}
446
	return items
447
}
448
449
// dateToDays converts a date to days since 1970-01-01; zero dates map to 0.
450
func dateToDays(d ast.Date) int64 {
451
	if d.Year == 0 {
452
		return 0
453
	}
454
	return daysFromCivil(d.Year, d.Month, d.Day)
455
}
456
457
// daysFromCivil converts a proleptic Gregorian date to days since 1970-01-01
458
// (Howard Hinnant's algorithm).
459
func daysFromCivil(y, m, d int) int64 {
460
	if m <= 2 {
461
		y--
462
	}
463
	era := y / 400
464
	yoe := y - era*400
465
	mp := (m + 9) % 12
466
	doy := (153*mp+2)/5 + d - 1
467
	doe := yoe*365 + yoe/4 - yoe/100 + doy
468
	return int64(era)*146097 + int64(doe) - 719468
469
}
470
471
// dateStyle returns reparator and yesr-ness of the most recent history date.
472
// Defualts to '-'/true when history is empty.
473
func dateStyle(history []string) (sep byte, hasYear bool) {
474
	sep, hasYear = '-', true
475
	if n := len(history); n > 0 {
476
		if i := strings.IndexAny(history[n-1], "-/."); i >= 0 {
477
			return history[n-1][i], i == 4
478
		}
479
	}
480
	return
481
}
482
483
func renderDate(t time.Time, sep byte, hasYear bool) string {
484
	d := ast.Date{Month: int(t.Month()), Day: t.Day(), Sep: sep}
485
	if hasYear {
486
		d.Year = t.Year()
487
	}
488
	return d.String()
489
}
490
491
func datePatternMatch(pattern, canonical string) bool {
492
	pi, ci := 0, 0
493
	for pi < len(pattern) && ci < len(canonical) {
494
		if pattern[pi] != canonical[ci] {
495
			ci++
496
			continue
497
		}
498
		pi++
499
		ci++
500
	}
501
	return pi == len(pattern)
502
}
503
504
func isDateSepByte(b byte) bool { return b == '-' || b == '/' || b == '.' }
505
func isDigitByte(b byte) bool   { return b >= '0' && b <= '9' }
506
507
func dateTokenEnd(content string, start int) int {
508
	i := start
509
	for i < len(content) && (isDigitByte(content[i]) || isDateSepByte(content[i])) {
510
		i++
511
	}
512
	return i
513
}
514
515
// lineBounds returns the byte offsets of the line containing cursor
516
func lineBounds(content string, cursor int) (start, end int) {
517
	start = cursor
518
	for start > 0 && content[start-1] != '\n' && content[start-1] != '\r' {
519
		start--
520
	}
521
	end = start
522
	for end < len(content) && content[end] != '\n' && content[end] != '\r' {
523
		end++
524
	}
525
	return start, end
526
}
527
528
func lastSeparator(s string) int {
529
	for i := len(s) - 1; i >= 0; i-- {
530
		switch s[i] {
531
		case ' ', '\t', ',':
532
			return i
533
		}
534
	}
535
	return -1
536
}
537
538
func lexLine(content string, cursor int) []token.Token {
539
	lineStart, lineEnd := lineBounds(content, cursor)
540
	l := lexer.New("", []byte(content[lineStart:lineEnd]))
541
	var out []token.Token
542
	for {
543
		t := l.Next()
544
		if t.Type == token.EOF || t.Type == token.NEWLINE {
545
			break
546
		}
547
		t.Span.Start.Offset += lineStart
548
		t.Span.End.Offset += lineStart
549
		out = append(out, t)
550
	}
551
	return out
552
}
553
554
func tokenUnder(toks []token.Token, cursor int) *token.Token {
555
	for i := range toks {
556
		t := &toks[i]
557
		if t.Span.Start.Offset <= cursor && cursor <= t.Span.End.Offset {
558
			return t
559
		}
560
	}
561
	return nil
562
}