all repos

clerk @ c2e8832

missing tooling for ledger/hledger

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

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