2 files changed,
67 insertions(+),
29 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-08-13 13:40:52 +0300
Authored at:
2026-08-13 13:24:07 +0300
Change ID:
wtovqzqvzsyxumksrlsyokzwxwqlvsvk
Parent:
97e9bd7
M
internal/lsp/fuzzy/fuzzy.go
··· 2 2 3 3 import "strings" 4 4 5 -// Score scores how well pattern matches text as a subsequence. 5 +// Matcher scores a precompiled pattern against candidate texts; [Compile] 6 +// hoists the lowercasing and rune conversion out of a per-candidate loop. 7 +type Matcher struct { 8 + p []rune // lowercased pattern runes; empty matches everything 9 +} 10 + 11 +// Compile builds a matcher for pattern. The empty pattern matches every 12 +// text with score 1. 13 +func Compile(pattern string) Matcher { 14 + if pattern == "" { 15 + return Matcher{} 16 + } 17 + return Matcher{p: []rune(strings.ToLower(pattern))} 18 +} 19 + 20 +// Score scores how well the pattern matches text as a subsequence. 6 21 // It returns 0 if pattern is not a case-insensitive subsequence of text, 7 22 // otherwise a score in (0, 1], where 1 is a perfect contiguous match at a 8 23 // segment boundary. An empty pattern matches everything with score 1. ··· 13 28 // exactly. The total is normalized by 4L+1, the maximum score of a perfect 14 29 // match of length L, so exact segment matches score 1 regardless of length 15 30 // ("food" and "expenses" both match "expenses:food" at 1.0). 16 -func Score(pattern, text string) float64 { 17 - if pattern == "" { 31 +func (m Matcher) Score(text string) float64 { 32 + p := m.p 33 + if len(p) == 0 { 18 34 return 1 19 35 } 20 - p := []rune(strings.ToLower(pattern)) 21 36 t := []rune(text) 22 37 tl := []rune(strings.ToLower(text)) 23 38 ··· 52 67 } 53 68 return score 54 69 } 70 + 71 +// Score scores pattern against text; see [Matcher.Score]. 72 +func Score(pattern, text string) float64 { return Compile(pattern).Score(text) } 55 73 56 74 // isFuzzySep reports whether r is a segment boundary for fuzzy matching: 57 75 // account-name separators and word boundaries.
M
internal/lsp/textdocument_completion.go
··· 6 6 "math" 7 7 "sort" 8 8 "strings" 9 - "time" 10 9 11 10 "go.lsp.dev/protocol" 12 11 ··· 23 22 if !ok { 24 23 return &protocol.CompletionList{}, nil 25 24 } 26 - cursor := lsputil.Offset(state.text, int(params.Position.Line), int(params.Position.Character)) 25 + cursor := state.lineIdx.Offset(int(params.Position.Line), int(params.Position.Character)) 27 26 if cursor > len(state.text) { 28 27 return &protocol.CompletionList{}, nil 29 28 } ··· 37 36 } 38 37 return &protocol.CompletionList{ 39 38 IsIncomplete: true, 40 - Items: cmplItems(a, detectedCtx, state.text, start, cursor), 39 + Items: cmplItems(a, detectedCtx, state.text, state.lineIdx, start, cursor), 41 40 }, nil 42 41 } 43 42 ··· 292 291 } 293 292 294 293 type cmplCand struct { 295 - label string 296 - score float64 297 - count int 298 - lastUsed ast.Date 294 + label string 295 + score float64 296 + count int 297 + lastUsedDays int64 // days since 1970-01-01; 0 when unset 299 298 } 300 299 301 300 // cmplItems ranks candidates for the content against typed pattern 302 -func cmplItems(a *analyzer.Analysis, ctx cmplCtx, content string, start, cursor int) []protocol.CompletionItem { 301 +func cmplItems(a *analyzer.Analysis, ctx cmplCtx, content string, li *lsputil.LineIndex, start, cursor int) []protocol.CompletionItem { 303 302 pattern := content[start:cursor] 304 303 305 304 var kind protocol.CompletionItemKind ··· 307 306 switch ctx { 308 307 case cmplAccount: 309 308 kind = protocol.CompletionItemKindClass 309 + cands = make([]cmplCand, 0, len(a.Accounts)) 310 310 for name, info := range a.Accounts { 311 - cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsed: info.LastUsed}) 311 + cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsedDays: dateToDays(info.LastUsed)}) 312 312 } 313 313 case cmplPayee: 314 314 kind = protocol.CompletionItemKindVariable 315 + cands = make([]cmplCand, 0, len(a.Payees)) 315 316 for name, info := range a.Payees { 316 - cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsed: info.LastUsed}) 317 + cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsedDays: dateToDays(info.LastUsed)}) 317 318 } 318 319 case cmplCommodity: 319 320 kind = protocol.CompletionItemKindValue 321 + cands = make([]cmplCand, 0, len(a.Commodities)) 320 322 for name, info := range a.Commodities { 321 - cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsed: info.LastUsed}) 323 + cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsedDays: dateToDays(info.LastUsed)}) 322 324 } 323 325 case cmplTagName: 324 326 kind = protocol.CompletionItemKindProperty 327 + cands = make([]cmplCand, 0, len(a.Tags)) 325 328 for name, info := range a.Tags { 326 - cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsed: info.LastUsed}) 329 + cands = append(cands, cmplCand{label: name, count: info.UsedCount, lastUsedDays: dateToDays(info.LastUsed)}) 327 330 } 328 331 case cmplTagValue: 329 332 kind = protocol.CompletionItemKindProperty ··· 343 346 return nil 344 347 } 345 348 346 - var newest ast.Date 349 + var newest int64 // days since epoch of the newest transaction; 0 when none 347 350 if n := len(a.Dates); n > 0 { 348 - newest = a.Dates[n-1] 351 + newest = dateToDays(a.Dates[n-1]) 349 352 } 353 + m := fuzzy.Compile(pattern) 350 354 ranked := cands[:0] 351 355 for i := range cands { 352 - sc := fuzzy.Score(pattern, cands[i].label) 356 + sc := m.Score(cands[i].label) 353 357 if sc != 0 { 354 358 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) 359 + if cands[i].count > 0 && cands[i].lastUsedDays != 0 && newest != 0 { 360 + days := newest - cands[i].lastUsedDays 357 361 sc *= 1 + 0.5*max(0, 1-float64(days)/365) 358 362 } 359 363 } ··· 366 370 if ranked[i].score != ranked[j].score { 367 371 return ranked[i].score > ranked[j].score 368 372 } 369 - if ranked[i].lastUsed != ranked[j].lastUsed { 370 - return ranked[i].lastUsed.Compare(ranked[j].lastUsed) > 0 373 + if ranked[i].lastUsedDays != ranked[j].lastUsedDays { 374 + return ranked[i].lastUsedDays > ranked[j].lastUsedDays 371 375 } 372 376 return ranked[i].label < ranked[j].label 373 377 }) ··· 376 380 } 377 381 378 382 replace := protocol.Range{ 379 - Start: lsputil.Position(content, start), 380 - End: lsputil.Position(content, cursor), 383 + Start: li.Position(start), 384 + End: li.Position(cursor), 381 385 } 382 386 items := make([]protocol.CompletionItem, len(ranked)) 383 387 for i, r := range ranked { ··· 399 403 return items 400 404 } 401 405 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 +// 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 406 426 } 407 427 408 428 // lineBounds returns the byte offsets of the line containing cursor