all repos

clerk @ 1925839

missing tooling for ledger/hledger
2 files changed, 67 insertions(+), 29 deletions(-)
perf: lsp: cut completion ranking allocations

Ranking 1k candidates allocated ~350KB per request:
pattern lowercasing and rune conversion ran per candidate, the sort compared
whole ast.Date structs (including span file strings), and cmplCand carried a full Date.
Hoist the pattern into a fuzzy.Matcher, store
last-used as epoch days (Hinnant days-from-civil), pre-size the
candidate slice, and resolve the replace range through the cached line index.
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