all repos

clerk @ 23d0a0d

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
fix typos and grammar, 10 days 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, s.latinToCyrillicCompletionEnabled()),
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.P:
88
		return cmplPriceContext(cursor, toks)
89
	case token.TEXT:
90
		return cmplDirective, lineStart // half-typed keyword or unparseable line
91
	default:
92
		return cmplNone, cursor
93
	}
94
}
95
96
func cmplPostingCtx(content string, cursor int, toks []token.Token) (cmplCtx, int) {
97
	if inDirectiveBody(content, cursor) {
98
		return cmplNone, cursor
99
	}
100
	fieldStart := toks[0].Span.End.Offset
101
	i := 1
102
	for i < len(toks) {
103
		switch toks[i].Type {
104
		case token.STAR, token.BANG, token.LPAREN, token.LBRACKET, token.WHITESPACE:
105
			fieldStart = toks[i].Span.End.Offset
106
			i++
107
		default:
108
			goto run
109
		}
110
	}
111
run:
112
	// account run: consecutive account-name segments and colons
113
	fieldEnd := fieldStart
114
	for ; i < len(toks); i++ {
115
		if toks[i].Type != token.TEXT && toks[i].Type != token.COLON {
116
			break
117
		}
118
		fieldEnd = toks[i].Span.End.Offset
119
	}
120
	if cursor <= fieldEnd && cursor >= fieldStart {
121
		return cmplAccount, fieldStart
122
	}
123
	if cursor > fieldEnd {
124
		if t := tokenUnder(toks, cursor); t != nil && (t.Type == token.COMMODITYMARK || t.Type == token.STRING) {
125
			start := t.Span.Start.Offset
126
			if t.Type == token.STRING {
127
				start++ // skip opening quote
128
			}
129
			return cmplCommodity, start
130
		}
131
		if strings.TrimSpace(content[fieldEnd:cursor]) == "" {
132
			return cmplCommodity, cursor
133
		}
134
	}
135
	return cmplNone, cursor
136
}
137
138
func cmplHeaderCtx(content string, cursor int, toks []token.Token) (cmplCtx, int) {
139
	if cursor >= toks[0].Span.Start.Offset && cursor <= toks[0].Span.End.Offset {
140
		return cmplDate, toks[0].Span.Start.Offset
141
	}
142
143
	// skip date, status, code, and whitespace - where the payee beginds
144
	fieldStart := toks[0].Span.End.Offset
145
	fieldEnd := fieldStart
146
	seen := false
147
	for _, t := range toks[1:] {
148
		switch t.Type {
149
		case token.WHITESPACE, token.STAR, token.BANG, token.DATE, token.TIME, token.EQ, token.EQEQ, token.EQEQEQ:
150
			if cursor >= t.Span.End.Offset {
151
				fieldStart = t.Span.End.Offset
152
				fieldEnd = t.Span.End.Offset
153
			}
154
		case token.TEXT:
155
			lit := content[t.Span.Start.Offset:t.Span.End.Offset]
156
			if !seen && len(lit) >= 2 && lit[0] == '(' && lit[len(lit)-1] == ')' {
157
				fieldStart = t.Span.End.Offset // parenthesized code
158
				fieldEnd = t.Span.End.Offset
159
				continue
160
			}
161
			if !seen {
162
				fieldStart = t.Span.Start.Offset
163
				seen = true
164
			}
165
			fieldEnd = t.Span.End.Offset
166
			if p := strings.IndexByte(lit, '|'); p >= 0 {
167
				fieldEnd = t.Span.Start.Offset + p // "payee|note" keeps the pipe in the token
168
				return payeeAt(cursor, fieldStart, fieldEnd)
169
			}
170
		case token.STRING:
171
			if !seen {
172
				fieldStart = t.Span.Start.Offset + 1 // skip opening quote
173
				seen = true
174
			}
175
			fieldEnd = t.Span.End.Offset
176
		default: // PIPE, SEMICOLON, ...
177
			return payeeAt(cursor, fieldStart, fieldEnd)
178
		}
179
	}
180
	if seen {
181
		return payeeAt(cursor, fieldStart, fieldEnd)
182
	}
183
	// no payee yet: the payee field is the whitespace after the header meta
184
	if cursor >= fieldStart && strings.TrimSpace(content[fieldStart:cursor]) == "" {
185
		return cmplPayee, cursor
186
	}
187
	return cmplNone, cursor
188
}
189
190
func payeeAt(cursor, start, end int) (cmplCtx, int) {
191
	if cursor >= start && cursor <= end {
192
		return cmplPayee, start
193
	}
194
	return cmplNone, cursor
195
}
196
197
// cmplDirectiveContext classifies a directive line: keyword completion up to the end of
198
// the keyword, symbol completion in the value field after it.
199
func cmplDirectiveContext(cursor, lineStart int, toks []token.Token) (cmplCtx, int) {
200
	kwEnd := toks[0].Span.End.Offset
201
	if cursor <= kwEnd {
202
		return cmplDirective, lineStart
203
	}
204
	start := kwEnd
205
	for i := 1; i < len(toks); i++ {
206
		if toks[i].Type == token.WHITESPACE || toks[i].Span.End.Offset <= kwEnd {
207
			continue
208
		}
209
		start = toks[i].Span.Start.Offset
210
		if toks[i].Type == token.STRING {
211
			start++ // skip opening quote
212
		}
213
		break
214
	}
215
	if start > cursor {
216
		start = cursor
217
	}
218
	switch toks[0].Type {
219
	case token.ACCOUNT:
220
		return cmplAccount, start
221
	case token.COMMODITY:
222
		return cmplCommodity, start
223
	case token.PAYEE:
224
		return cmplPayee, start
225
	case token.TAG:
226
		return cmplTagName, start
227
	}
228
	return cmplNone, cursor
229
}
230
231
func cmplPriceContext(cursor int, toks []token.Token) (cmplCtx, int) {
232
	kwEnd := toks[0].Span.End.Offset
233
	if cursor > toks[0].Span.Start.Offset && cursor < kwEnd {
234
		return cmplNone, cursor // on the P keyword
235
	}
236
237
	dateStart := kwEnd           // start of the date field, for replacing partials whole
238
	dateDone := false            // a DATE, TIME, or symbol has ended the date field
239
	for _, t := range toks[1:] { // skip the P keyword
240
		if t.Type == token.WHITESPACE {
241
			continue
242
		}
243
		if !dateDone && dateStart == kwEnd {
244
			dateStart = t.Span.Start.Offset
245
		}
246
		if cursor > t.Span.End.Offset {
247
			switch t.Type {
248
			case token.DATE, token.TIME, token.COMMODITYMARK, token.STRING:
249
				dateDone = true
250
			}
251
			continue
252
		}
253
		if cursor < t.Span.Start.Offset {
254
			if (t.Type == token.INT || t.Type == token.DECIMAL) && !dateDone {
255
				return cmplDate, dateStart // partial date fragments ahead
256
			}
257
			if t.Type == token.DATE || t.Type == token.TIME {
258
				return cmplNone, cursor
259
			}
260
			return cmplCommodity, cursor // symbol or price-commodity slot
261
		}
262
263
		switch t.Type {
264
		case token.DATE:
265
			return cmplDate, t.Span.Start.Offset
266
		case token.COMMODITYMARK:
267
			return cmplCommodity, t.Span.Start.Offset
268
		case token.STRING:
269
			return cmplCommodity, min(t.Span.Start.Offset+1, cursor) // skip the opening quote
270
		case token.TIME:
271
			return cmplNone, cursor
272
		case token.INT, token.DECIMAL: // a partial date fragment, or the amount quantity
273
			if !dateDone {
274
				return cmplDate, dateStart
275
			}
276
			if cursor == t.Span.End.Offset {
277
				return cmplCommodity, cursor // suffix price-commodity slot
278
			}
279
			return cmplNone, cursor
280
		}
281
	}
282
	// the cursor sits in whitespace after the last token, or only after the keyword
283
	if !dateDone {
284
		start := dateStart
285
		if start == kwEnd {
286
			start = cursor // `P `: the date comes next
287
		}
288
		return cmplDate, start
289
	}
290
	return cmplCommodity, cursor
291
}
292
293
// cmplTagContext completes tag names before the ':' of the current tag, and tag values after it.
294
func cmplTagContext(content string, commentStart, cursor int) (cmplCtx, int) {
295
	prefix := content[commentStart:cursor]
296
	segStart := commentStart
297
	seg := prefix
298
	if comma := strings.LastIndexByte(prefix, ','); comma >= 0 {
299
		segStart = commentStart + comma + 1
300
		seg = prefix[comma+1:]
301
	}
302
	if colon := strings.IndexByte(seg, ':'); colon >= 0 {
303
		start := segStart + colon + 1
304
		for start < cursor && (content[start] == ' ' || content[start] == '\t') {
305
			start++
306
		}
307
		return cmplTagValue, start
308
	}
309
	keyStart := commentStart + lastSeparator(prefix) + 1
310
	return cmplTagName, keyStart
311
}
312
313
// tagKeyAt returns the key of the tag whose value region starts at start.
314
func tagKeyAt(content string, start int) (string, bool) {
315
	lineStart, _ := lineBounds(content, start)
316
	segStart := lineStart
317
	for i := start - 1; i >= lineStart; i-- {
318
		switch content[i] {
319
		case ',', ';', '#', '%':
320
			segStart = i + 1
321
			i = lineStart - 1 // stop at the separator closest to start
322
		}
323
	}
324
	colon := strings.IndexByte(content[segStart:start], ':')
325
	if colon < 0 {
326
		return "", false
327
	}
328
	colon += segStart
329
	keyStart := lastSeparator(content[segStart:colon]) + 1
330
	key := content[segStart+keyStart : colon]
331
	if key == "" {
332
		return "", false
333
	}
334
	return key, true
335
}
336
337
// commentMarker returns index of the first comment marker token at or before the cursor, or -1.
338
func commentMarker(toks []token.Token, cursor int) int {
339
	for i, t := range toks {
340
		switch t.Type {
341
		case token.SEMICOLON, token.HASH, token.PERCENT:
342
			if t.Span.Start.Offset <= cursor {
343
				return i
344
			}
345
		case token.STAR:
346
			if i == 0 && t.Span.Start.Offset <= cursor {
347
				return i
348
			}
349
		}
350
	}
351
	return -1
352
}
353
354
func inDirectiveBody(content string, cursor int) bool {
355
	lineStart, _ := lineBounds(content, cursor)
356
	if toks := lexLine(content, lineStart); len(toks) == 0 || toks[0].Type != token.INDENT {
357
		return false
358
	}
359
	for lineStart > 0 {
360
		lineStart, _ = lineBounds(content, lineStart-1)
361
		toks := lexLine(content, lineStart)
362
		if len(toks) == 0 || toks[0].Type != token.INDENT {
363
			return len(toks) > 0 && (toks[0].Type == token.ACCOUNT || toks[0].Type == token.COMMODITY)
364
		}
365
	}
366
	return false
367
}
368
369
type cmplCand struct {
370
	label        string
371
	score        float64
372
	count        int
373
	declared     bool  // has a declaration directive (account/payee/commodity/tag); sorts before usage-only names
374
	exact        bool  // label equals the typed pattern; phantom exact names are dropped entirely
375
	lastUsedDays int64 // days since 1970-01-01; 0 when unset
376
	rank         int   // lower sorts first among equal scores; 0 except for date completions
377
}
378
379
// cmplItems ranks candidates for the content against the typed pattern.
380
func cmplItems(
381
	a *analyzer.Analysis,
382
	ctx cmplCtx,
383
	content string,
384
	li *lsputil.LineIndex,
385
	start,
386
	cursor int,
387
	transliterate bool,
388
) []protocol.CompletionItem {
389
	pattern := content[start:cursor]
390
391
	var kind protocol.CompletionItemKind
392
	var cands []cmplCand
393
	switch ctx {
394
	case cmplAccount:
395
		kind = protocol.CompletionItemKindClass
396
		cands = make([]cmplCand, 0, len(a.Accounts))
397
		for name, info := range a.Accounts {
398
			cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsedDays: dateToDays(info.LastUsed), declared: len(info.Directives) > 0})
399
		}
400
	case cmplPayee:
401
		kind = protocol.CompletionItemKindVariable
402
		cands = make([]cmplCand, 0, len(a.Payees))
403
		for name, info := range a.Payees {
404
			cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsedDays: dateToDays(info.LastUsed), declared: len(info.Directives) > 0})
405
		}
406
	case cmplCommodity:
407
		kind = protocol.CompletionItemKindValue
408
		cands = make([]cmplCand, 0, len(a.Commodities))
409
		for name, info := range a.Commodities {
410
			cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsedDays: dateToDays(info.LastUsed), declared: len(info.Directives) > 0})
411
		}
412
	case cmplTagName:
413
		kind = protocol.CompletionItemKindProperty
414
		cands = make([]cmplCand, 0, len(a.Tags))
415
		for name, info := range a.Tags {
416
			cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsedDays: dateToDays(info.LastUsed), declared: len(info.Directives) > 0})
417
		}
418
	case cmplTagValue:
419
		kind = protocol.CompletionItemKindProperty
420
		if key, ok := tagKeyAt(content, start); ok {
421
			if info, ok := a.Tags[key]; ok {
422
				for _, v := range info.Values {
423
					cands = append(cands, cmplCand{label: v})
424
				}
425
			}
426
		}
427
	case cmplDirective:
428
		kind = protocol.CompletionItemKindKeyword
429
		for _, name := range directiveKeywords {
430
			cands = append(cands, cmplCand{label: name})
431
		}
432
	case cmplDate:
433
		kind = protocol.CompletionItemKindConstant
434
		now := time.Now()
435
		sep, hasYear := dateStyle(a.DateStrings)
436
		today := renderDate(now, sep, hasYear)
437
		yesterday := renderDate(now.AddDate(0, 0, -1), sep, hasYear)
438
		twoDaysAgo := renderDate(now.AddDate(0, 0, -2), sep, hasYear)
439
		cands = append(cands,
440
			cmplCand{label: today, rank: 0},
441
			cmplCand{label: yesterday, rank: 1},
442
			cmplCand{label: twoDaysAgo, rank: 2})
443
444
		seen := map[string]bool{today: true, yesterday: true, twoDaysAgo: true}
445
		for i, d := range a.Dates {
446
			if !datePatternMatch(pattern, a.DateStrings[i]) {
447
				continue
448
			}
449
			label := a.DateStrings[i]
450
			if seen[label] {
451
				continue
452
			}
453
			seen[label] = true
454
			cands = append(cands, cmplCand{label: label, rank: 3, lastUsedDays: dateToDays(d)})
455
		}
456
	default:
457
		return nil
458
	}
459
460
	var newest int64 // days since epoch of the newest transaction; 0 when none
461
	if n := len(a.Dates); n > 0 {
462
		newest = dateToDays(a.Dates[n-1])
463
	}
464
465
	matcher := fuzzy.Compile(pattern)
466
	translMatcher, hasTransl := fuzzy.Matcher{}, false
467
	if transliterate {
468
		if t := latinToCyrillic(pattern); t != pattern {
469
			translMatcher, hasTransl = fuzzy.Compile(t), true
470
		}
471
	}
472
	ranked := cands[:0]
473
	dropExact := ctx == cmplAccount || ctx == cmplPayee || ctx == cmplCommodity || ctx == cmplTagName || ctx == cmplTagValue
474
	for i := range cands {
475
		cands[i].exact = pattern != "" && strings.EqualFold(cands[i].label, pattern)
476
		if dropExact && cands[i].exact && !cands[i].declared {
477
			continue // the typed name is not declared; re-offering it is noise
478
		}
479
		sc := matcher.Score(cands[i].label)
480
		if hasTransl {
481
			sc = max(sc, translMatcher.Score(cands[i].label))
482
		}
483
		if sc != 0 {
484
			sc *= 1 + math.Log1p(float64(cands[i].count))
485
			if cands[i].count > 0 && cands[i].lastUsedDays != 0 && newest != 0 {
486
				days := newest - cands[i].lastUsedDays
487
				sc *= 1 + 0.5*max(0, 1-float64(days)/365)
488
			}
489
		}
490
		cands[i].score = sc
491
		if sc != 0 {
492
			ranked = append(ranked, cands[i])
493
		}
494
	}
495
	sort.Slice(ranked, func(i, j int) bool {
496
		if ranked[i].declared != ranked[j].declared {
497
			return ranked[i].declared
498
		}
499
		if ranked[i].score != ranked[j].score {
500
			return ranked[i].score > ranked[j].score
501
		}
502
		if ranked[i].rank != ranked[j].rank {
503
			return ranked[i].rank < ranked[j].rank
504
		}
505
		if ranked[i].lastUsedDays != ranked[j].lastUsedDays {
506
			return ranked[i].lastUsedDays > ranked[j].lastUsedDays
507
		}
508
		return ranked[i].label < ranked[j].label
509
	})
510
	if len(ranked) > maxCompletionItems {
511
		ranked = ranked[:maxCompletionItems]
512
	}
513
514
	end := cursor
515
	switch ctx {
516
	case cmplDate:
517
		end = dateTokenEnd(content, start)
518
	case cmplAccount:
519
		end = fieldTokenEnd(content, end, " \t\r\n;#%)]$=")
520
	case cmplPayee:
521
		end = fieldTokenEnd(content, end, " \t\r\n;|")
522
	case cmplCommodity:
523
		end = fieldTokenEnd(content, end, " \t\r\n;[]")
524
	case cmplTagName:
525
		end = fieldTokenEnd(content, end, " \t\r\n:,;")
526
	case cmplTagValue:
527
		end = fieldTokenEnd(content, end, " \t\r\n,;")
528
	case cmplDirective:
529
		end = fieldTokenEnd(content, end, " \t\r\n;")
530
	}
531
532
	items := make([]protocol.CompletionItem, len(ranked))
533
	for i, r := range ranked {
534
		// nvim re-filters against the typed prefix, which a transliterated label never starts with; present the typed pattern as filterText.
535
		filterText := r.label
536
		if hasTransl && translMatcher.Score(r.label) > 0 {
537
			filterText = pattern
538
		}
539
		it := protocol.CompletionItem{
540
			Label:      r.label,
541
			Kind:       kind,
542
			SortText:   protocol.NewOptional(fmt.Sprintf("%04d", i)),
543
			FilterText: protocol.NewOptional(filterText),
544
			TextEdit: &protocol.TextEdit{
545
				Range: protocol.Range{
546
					Start: li.Position(start),
547
					End:   li.Position(end),
548
				},
549
				NewText: r.label,
550
			},
551
		}
552
		if r.count > 0 {
553
			it.Detail = protocol.NewOptional(fmt.Sprintf("%d uses", r.count))
554
		}
555
		items[i] = it
556
	}
557
	return items
558
}
559
560
// dateToDays converts a date to days since 1970-01-01; zero dates map to 0.
561
func dateToDays(d ast.Date) int64 {
562
	if d.Year == 0 {
563
		return 0
564
	}
565
	return daysFromCivil(d.Year, d.Month, d.Day)
566
}
567
568
// daysFromCivil converts a proleptic Gregorian date to days since 1970-01-01
569
// (Howard Hinnant's algorithm).
570
func daysFromCivil(y, m, d int) int64 {
571
	if m <= 2 {
572
		y--
573
	}
574
	era := y / 400
575
	yoe := y - era*400
576
	mp := (m + 9) % 12
577
	doy := (153*mp+2)/5 + d - 1
578
	doe := yoe*365 + yoe/4 - yoe/100 + doy
579
	return int64(era)*146097 + int64(doe) - 719468
580
}
581
582
// dateStyle returns the separator and year-ness of the most recent history date.
583
// Defaults to '-' with a year when history is empty.
584
func dateStyle(history []string) (sep byte, hasYear bool) {
585
	sep, hasYear = '-', true
586
	if n := len(history); n > 0 {
587
		if i := strings.IndexAny(history[n-1], "-/."); i >= 0 {
588
			return history[n-1][i], i == 4
589
		}
590
	}
591
	return
592
}
593
594
func renderDate(t time.Time, sep byte, hasYear bool) string {
595
	d := ast.Date{Month: int(t.Month()), Day: t.Day(), Sep: sep}
596
	if hasYear {
597
		d.Year = t.Year()
598
	}
599
	return d.String()
600
}
601
602
func datePatternMatch(pattern, canonical string) bool {
603
	pi, ci := 0, 0
604
	for pi < len(pattern) && ci < len(canonical) {
605
		if pattern[pi] != canonical[ci] {
606
			ci++
607
			continue
608
		}
609
		pi++
610
		ci++
611
	}
612
	return pi == len(pattern)
613
}
614
615
func isDateSepByte(b byte) bool { return b == '-' || b == '/' || b == '.' }
616
func isDigitByte(b byte) bool   { return b >= '0' && b <= '9' }
617
618
func dateTokenEnd(content string, start int) int {
619
	i := start
620
	for i < len(content) && (isDigitByte(content[i]) || isDateSepByte(content[i])) {
621
		i++
622
	}
623
	return i
624
}
625
626
func fieldTokenEnd(content string, end int, stops string) int {
627
	for end < len(content) && !strings.ContainsRune(stops, rune(content[end])) {
628
		end++
629
	}
630
	return end
631
}
632
633
// lineBounds returns the byte offsets of the line containing cursor
634
func lineBounds(content string, cursor int) (start, end int) {
635
	start = cursor
636
	for start > 0 && content[start-1] != '\n' && content[start-1] != '\r' {
637
		start--
638
	}
639
	end = start
640
	for end < len(content) && content[end] != '\n' && content[end] != '\r' {
641
		end++
642
	}
643
	return start, end
644
}
645
646
func lastSeparator(s string) int {
647
	for i := len(s) - 1; i >= 0; i-- {
648
		switch s[i] {
649
		case ' ', '\t', ',':
650
			return i
651
		}
652
	}
653
	return -1
654
}
655
656
func lexLine(content string, cursor int) []token.Token {
657
	lineStart, lineEnd := lineBounds(content, cursor)
658
	l := lexer.New("", []byte(content[lineStart:lineEnd]))
659
	var out []token.Token
660
	for {
661
		t := l.Next()
662
		if t.Type == token.EOF || t.Type == token.NEWLINE {
663
			break
664
		}
665
		t.Span.Start.Offset += lineStart
666
		t.Span.End.Offset += lineStart
667
		out = append(out, t)
668
	}
669
	return out
670
}
671
672
func tokenUnder(toks []token.Token, cursor int) *token.Token {
673
	for i := range toks {
674
		t := &toks[i]
675
		if t.Span.Start.Offset <= cursor && cursor <= t.Span.End.Offset {
676
			return t
677
		}
678
	}
679
	return nil
680
}