Vectrum
Sign In
109 lines · 3.5 KB · Go
package tui

import (
	"sort"
	"strings"
)

// Fuzzy matching, for pickers whose list is long enough that scrolling it is
// slower than describing what you want. The rule is subsequence: every
// character typed has to appear, in order, somewhere in the candidate — so
// "dkmd" finds "dark-mode" — and the score decides which of the matches is
// offered first.
//
// The scoring is deliberately small. What people actually type is the start of
// a word or a run of letters from one, so those are what earn points; nothing
// here tries to be a ranking function, only to put the obvious answer on top.

// Scores for the three ways a character can land, and the cap on what skipping
// over characters costs. Consecutive beats a word start beats a stray hit.
const (
	fuzzyRun      = 10 // directly after the previous match
	fuzzyBoundary = 8  // the first character of a word
	fuzzyHit      = 1  // anywhere else
	fuzzyGapMax   = 3  // the most one gap can cost
	fuzzyLeadMax  = 4  // the most a late start can cost
)

// fuzzyMatch reports whether pattern appears in s as a subsequence, and how
// good a match it is — higher is better. Matching is case-insensitive, and an
// empty pattern matches everything, which is what an unfiltered picker wants.
func fuzzyMatch(pattern, s string) (int, bool) {
	if pattern == "" {
		return 0, true
	}
	p := []rune(strings.ToLower(pattern))
	t := []rune(strings.ToLower(s))

	score, pi, prev := 0, 0, -1
	for i := 0; i < len(t) && pi < len(p); i++ {
		if t[i] != p[pi] {
			continue
		}
		switch {
		case i == prev+1 && prev >= 0:
			score += fuzzyRun
		case i == 0 || isWordBoundary(t[i-1]):
			score += fuzzyBoundary
		default:
			score += fuzzyHit
		}
		// Skipping over characters to get here is what makes a loose match
		// loose, and the penalty is capped so one long gap does not sink a
		// candidate that is otherwise a good hit.
		if prev >= 0 {
			score -= minInt(fuzzyGapMax, i-prev-1)
		} else {
			// Where the match begins matters too: of two candidates that
			// contain "par" as a word, "parser-rewrite" is the one being asked
			// for and "old-parser" is the one that happens to contain it.
			score -= minInt(fuzzyLeadMax, i)
		}
		prev, pi = i, pi+1
	}
	if pi < len(p) {
		return 0, false
	}
	// Of two candidates matched equally well, the shorter one is the one
	// meant: "api" should offer "api" before "rapid-prototyping".
	return score - len(t)/8, true
}

// isWordBoundary says whether a character separates words, for the purpose of
// deciding that the next one starts a word. Worktree names are slugs and
// branches have slashes in them, so these are the separators that matter.
func isWordBoundary(r rune) bool {
	switch r {
	case '-', '_', '/', ' ', '.', ':', '@':
		return true
	}
	return false
}

// fuzzyRank narrows a list to what matches pattern, best first. Ties keep the
// order they came in, so a list already sorted the way the picker wants it
// stays that way under an empty or undiscriminating filter.
func fuzzyRank[T any](items []T, pattern string, text func(T) string) []T {
	type scored struct {
		item  T
		score int
		at    int
	}
	var hits []scored
	for i, it := range items {
		if score, ok := fuzzyMatch(pattern, text(it)); ok {
			hits = append(hits, scored{item: it, score: score, at: i})
		}
	}
	sort.SliceStable(hits, func(i, j int) bool {
		if hits[i].score != hits[j].score {
			return hits[i].score > hits[j].score
		}
		return hits[i].at < hits[j].at
	})
	out := make([]T, 0, len(hits))
	for _, h := range hits {
		out = append(out, h.item)
	}
	return out
}