peco.peco/filter/fuzzy.go
Daisuke Maki a5c51bcc4b fix: pool Matched structs with sync.Pool to reduce GC pressure
Benchmark (10k lines x 3 cycles):
  pooled vs unpooled: 107KiB vs 562KiB (-81%), 2 vs 10k allocs
  end-to-end filter: 10.71MiB -> 10.14MiB (-5%), 150.6k -> 121.5k allocs (-19%)
2026-02-22 00:26:22 +09:00

266 lines
6.2 KiB
Go

package filter
import (
"context"
"fmt"
"math"
"regexp"
"sort"
"strings"
"unicode/utf8"
"github.com/peco/peco/internal/util"
"github.com/peco/peco/line"
"github.com/peco/peco/pipeline"
)
// Fuzzy is a filter that performs fuzzy matching.
type Fuzzy struct {
baseFilter
sortLongest bool
}
// NewFuzzy builds a fuzzy-finder type of filter.
// In effect, this uses a smart case filter, and for q query
// like "ABC" it matches the equivalent of "A(.*)B(.*)C(.*)".
//
// With sortLongest = true, Fuzzy filter outputs the result
// sorted in the following precedence:
// 1. Longer match
// 2. Earlier match
// 3. Shorter line length
func NewFuzzy(sortLongest bool) *Fuzzy {
ff := &Fuzzy{
sortLongest: sortLongest,
}
ff.impl = ff
return ff
}
func (ff Fuzzy) SupportsParallel() bool {
return !ff.sortLongest
}
func (ff Fuzzy) String() string {
return "Fuzzy"
}
// applyInternal performs fuzzy matching on each line, emitting matches with
// their character-level match indices for highlighting.
func (ff *Fuzzy) applyInternal(ctx context.Context, lines []line.Line, em LineEmitter) error {
originalQuery := pipeline.QueryFromContext(ctx)
// Parse negative terms and compile them as case-insensitive regexps
posTerms, negTerms := SplitQueryTerms(originalQuery)
var negRegexps []*regexp.Regexp
for _, t := range negTerms {
re, err := regexpFor(t, []string{"i"}, true)
if err != nil {
return fmt.Errorf("failed to compile negative term regexp '%s': %w", t, err)
}
negRegexps = append(negRegexps, re)
}
// Reconstruct the fuzzy query from positive terms joined together
fuzzyQuery := strings.Join(posTerms, "")
hasUpper := util.ContainsUpper(fuzzyQuery)
var matched []fuzzyMatchedItem
LINE:
for i, l := range lines {
if err := checkCancelled(ctx, i); err != nil {
return err
}
txt := l.DisplayString()
// Check negative terms first — skip if any match
if isExcluded(negRegexps, txt) {
continue LINE
}
// All-negative query: emit all non-excluded lines with nil indices
if len(fuzzyQuery) == 0 {
em.Emit(ctx, line.GetMatched(l, nil))
continue LINE
}
// Find the first valid rune of the query
firstRune := utf8.RuneError
for _, r := range fuzzyQuery {
if r != utf8.RuneError {
firstRune = r
break
}
}
if firstRune == utf8.RuneError {
return fmt.Errorf("the query has no valid character")
}
// Find all byte offsets where the first query rune appears,
// reusing txt from line 77 instead of re-fetching DisplayString().
var firstRuneOffsets []int
remaining := txt
base := 0
for len(remaining) > 0 {
var idx int
if hasUpper {
idx = strings.IndexRune(remaining, firstRune)
} else {
idx = util.CaseInsensitiveIndex(remaining, firstRune)
}
if idx == -1 {
break
}
firstRuneOffsets = append(firstRuneOffsets, base+idx)
if !ff.sortLongest {
break
}
_, n := utf8.DecodeRuneInString(remaining[idx:])
remaining = remaining[idx+n:]
base += idx + n
}
if len(firstRuneOffsets) == 0 {
continue LINE
}
// Find all candidate matches
var candidates []fuzzyMatchedItem
// Pre-allocate backing array for match pairs to avoid per-match
// heap allocations. Each match is a [start, end] pair, so we need
// 2 ints per query rune (upper bound).
queryRuneCount := utf8.RuneCountInString(fuzzyQuery)
matchBacking := make([]int, 0, queryRuneCount*2)
OUTER:
for _, offset := range firstRuneOffsets {
q := fuzzyQuery
candidateTxt := txt[offset:]
candidateBase := offset
matchBacking = matchBacking[:0]
matches := make([][]int, 0, queryRuneCount)
for len(q) > 0 {
var r rune
var n int
q, r, n = popRune(q)
if r == utf8.RuneError {
continue OUTER
}
var idx int
if hasUpper {
idx = strings.IndexRune(candidateTxt, r)
} else {
idx = util.CaseInsensitiveIndex(candidateTxt, r)
}
if idx == -1 {
continue OUTER
}
candidateTxt = candidateTxt[idx+n:]
start := candidateBase + idx
matchBacking = append(matchBacking, start, start+n)
matches = append(matches, matchBacking[len(matchBacking)-2:len(matchBacking):len(matchBacking)])
candidateBase = start + n
}
candidates = append(candidates, newFuzzyMatchedItem(l, matches))
}
if len(candidates) == 0 {
continue
}
if ff.sortLongest {
// Sort the candidate matches of a line and pick the best one
sort.SliceStable(candidates, less(candidates))
}
matched = append(matched, candidates[0])
}
if ff.sortLongest {
// Sort all matched lines
sort.SliceStable(matched, less(matched))
}
for i := range matched {
em.Emit(ctx, line.GetMatched(matched[i].line, matched[i].matches))
}
return nil
}
// popRune decodes and removes the first rune from s, returning the remainder,
// the decoded rune, and its byte width.
func popRune(s string) (string, rune, int) {
r, n := utf8.DecodeRuneInString(s)
return s[n:], r, n
}
// less returns a comparison function that orders fuzzy matches by longest
// contiguous match, earliest position, then shortest line length.
func less(s []fuzzyMatchedItem) func(i, j int) bool {
return func(i, j int) bool {
if s[i].longest != s[j].longest {
// Longer match is better
return s[i].longest > s[j].longest
}
if s[i].earliest != s[j].earliest {
// Earlier match is better
return s[i].earliest < s[j].earliest
}
// Shorter line is better
return s[i].Len() < s[j].Len()
}
}
type fuzzyMatchedItem struct {
line line.Line
matches [][]int
longest int
earliest int
}
// newFuzzyMatchedItem creates a fuzzyMatchedItem, computing the longest
// contiguous match length and earliest match position from the given indices.
func newFuzzyMatchedItem(line line.Line, matches [][]int) fuzzyMatchedItem {
longest := 0
count := 0
lastEnd := 0
earliest := math.MaxInt
for i := range matches {
length := matches[i][1] - matches[i][0]
if matches[i][0] == lastEnd {
count += length
} else {
count = length
}
if count > longest {
longest = count
}
lastEnd = matches[i][1]
if matches[i][0] < earliest {
earliest = matches[i][0]
}
}
return fuzzyMatchedItem{
line: line,
matches: matches,
longest: longest,
earliest: earliest,
}
}
func (f fuzzyMatchedItem) Len() int {
return len(f.line.DisplayString())
}