mirror of
https://github.com/peco/peco.git
synced 2026-09-10 07:16:29 -04:00
252 lines
5.2 KiB
Go
252 lines
5.2 KiB
Go
package filter
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/peco/peco/internal/util"
|
|
"github.com/peco/peco/line"
|
|
"github.com/peco/peco/pipeline"
|
|
)
|
|
|
|
// 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.applyFn = ff.applyInternal
|
|
return ff
|
|
}
|
|
|
|
func (ff Fuzzy) SupportsParallel() bool {
|
|
return !ff.sortLongest
|
|
}
|
|
|
|
func (ff Fuzzy) String() string {
|
|
return "Fuzzy"
|
|
}
|
|
|
|
func (ff *Fuzzy) applyInternal(ctx context.Context, lines []line.Line, emit func(line.Line)) 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)
|
|
matched := []fuzzyMatchedItem{}
|
|
|
|
LINE:
|
|
for i, l := range lines {
|
|
if i%1000 == 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
}
|
|
|
|
txt := l.DisplayString()
|
|
|
|
// Check negative terms first — skip if any match
|
|
excluded := false
|
|
for _, rx := range negRegexps {
|
|
if rx.MatchString(txt) {
|
|
excluded = true
|
|
break
|
|
}
|
|
}
|
|
if excluded {
|
|
continue LINE
|
|
}
|
|
|
|
// All-negative query: emit all non-excluded lines with nil indices
|
|
if len(fuzzyQuery) == 0 {
|
|
emit(line.NewMatched(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 the index of the first valid rune in the input line
|
|
txt = l.DisplayString()
|
|
firstRuneOffsets := []int{}
|
|
accum := 0
|
|
var r rune
|
|
var n int
|
|
for len(txt) > 0 {
|
|
txt, r, n = popRune(txt)
|
|
var found bool
|
|
if hasUpper {
|
|
found = r == firstRune
|
|
} else {
|
|
found = unicode.ToUpper(r) == unicode.ToUpper(firstRune)
|
|
}
|
|
if found {
|
|
firstRuneOffsets = append(firstRuneOffsets, accum)
|
|
|
|
if !ff.sortLongest {
|
|
// Old behavior only sees the first match
|
|
break
|
|
}
|
|
}
|
|
accum += n
|
|
}
|
|
if len(firstRuneOffsets) == 0 {
|
|
continue LINE
|
|
}
|
|
|
|
// Find all candidate matches
|
|
candidates := []fuzzyMatchedItem{}
|
|
|
|
OUTER:
|
|
for _, offset := range firstRuneOffsets {
|
|
query := fuzzyQuery
|
|
txt = l.DisplayString()[offset:]
|
|
base := offset
|
|
matches := [][]int{}
|
|
|
|
for len(query) > 0 {
|
|
query, r, n = popRune(query)
|
|
if r == utf8.RuneError {
|
|
// "Silently" ignore
|
|
continue OUTER
|
|
}
|
|
|
|
var i int
|
|
if hasUpper {
|
|
i = strings.IndexRune(txt, r)
|
|
} else {
|
|
i = strings.IndexFunc(txt, util.CaseInsensitiveIndexFunc(r))
|
|
}
|
|
if i == -1 {
|
|
continue OUTER
|
|
}
|
|
|
|
txt = txt[i+n:]
|
|
matches = append(matches, []int{base + i, base + i + n})
|
|
base = base + i + 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 {
|
|
emit(line.NewMatched(matched[i].line, matched[i].matches))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func popRune(s string) (string, rune, int) {
|
|
r, n := utf8.DecodeRuneInString(s)
|
|
return s[n:], r, n
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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())
|
|
}
|