Skip to content

Fuzzy means in order

Level: 201 · you have typed four letters into fzf and wondered why that line came first

One line: A fuzzy query matches a line when its characters occur in that line in the same order, however far apart: abc finds a_b_c and xaxbxcx, but not acb. The order of the matches is a score. A character that starts a word scores more, and how much more depends on the character before it: in the default scheme a space beats : and /, those beat ., _ and -, and all of them beat a camelCase capital. A tie goes to the shorter line. --scheme=path and --scheme=history move those bonuses, --exact turns the fuzziness off, and * is not a wildcard.

Measured

The inputs are four small files: letters.txt, bonus.txt, bar.txt and paths.txt.

Verified output of fzfuzzy_sh.sh, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ cat letters.txt
xaxbxcx
a_b_c
cab
a b c
bac
acb
abc

$ fzf --filter abc < letters.txt
abc
a b c
a_b_c
xaxbxcx

$ fzf --filter abc --exact < letters.txt
abc

$ cat bonus.txt
fooxbar
fooxBar
foo.bar
foo_bar
foo-bar
foo:bar
foo/bar
foo bar

$ fzf --filter fb < bonus.txt
foo bar
foo:bar
foo/bar
foo.bar
foo_bar
foo-bar
fooxBar
fooxbar

$ sort -r bonus.txt | fzf --filter fb
foo bar
foo:bar
foo/bar
foo_bar
foo.bar
foo-bar
fooxBar
fooxbar

$ fzf --filter bar < bar.txt
bar
barbecue
bxxaxxr
xbarx

$ fzf --filter bar --tiebreak=index < bar.txt
barbecue
bar
bxxaxxr
xbarx

$ fzf --filter bar --tiebreak=index,length < bar.txt; echo "status $?"
index should be the last criterion
status 2

$ fzf --filter doc < paths.txt
docs/report.txt
My Documents/report.txt
old docs/report final.txt
src/doc_parser.go
notes/reports/doc.md

$ fzf --filter doc --scheme=path < paths.txt
src/doc_parser.go
notes/reports/doc.md
docs/report.txt
My Documents/report.txt
old docs/report final.txt

$ fzf --filter fb --scheme=path < bonus.txt
foo/bar
foo.bar
foo_bar
foo-bar
foo:bar
foo bar
fooxBar
fooxbar

$ fzf --filter fb --scheme=history < bonus.txt
foo.bar
foo_bar
foo-bar
foo:bar
foo/bar
foo bar
fooxBar
fooxbar

$ fzf --filter "a*c" < letters.txt; echo "status $?"
status 1

$ fzf --filter a*c < letters.txt; echo "status $?"
status 1
  • In order, not adjacent. abc matched the four lines where a, b and c occur from left to right. It matched none of cab, bac and acb, which hold the same letters in another order. --exact wants the three letters side by side, and leaves only abc.
  • Starting a word scores. The eight lines of bonus.txt are all seven characters long, with the b four characters after the f, so only the character in front of the b differs. The space ranked first. Next came : and /, then ., _ and -, then the camelCase B. Last came fooxbar, whose b sits inside a word and still matched.
  • Equal scores keep the file's order. sort -r puts foo_bar before foo.bar, and fzf did the same: those two scored equally, and the tie went to the line that came first. The : and / lines are in the same order in both files, so this measurement cannot tell whether they tie.
  • Where a match starts can outweigh adjacent letters. In bar.txt, bxxaxxr ranked above xbarx. The b that starts the line, with the a and r far behind it, beat three adjacent letters that start no word.
  • A tie goes to the shorter line. bar and barbecue both start with bar. The shorter one came first even though it comes later in the file. --tiebreak=index puts the file's order first instead. index is allowed only at the end of the list, and fzf says so with status 2.
  • A scheme changes the bonuses. With --scheme=path, foo/bar came first and the other separators, the space included, fell into the file's order. paths.txt shows what that scheme is for. With doc, the default scheme ranked docs/report.txt first, while --scheme=path ranked first the two lines whose file name, after the last /, contains doc. The manual gives that scheme the tiebreak pathname,length. --scheme=history put all six separator lines in the file's order, with the camelCase and mid-word lines still last. That is the scheme fzf's Ctrl-R uses, and its input is in newest-first order (Ctrl-R is a pipeline).
  • * is a character. fzf has no wildcards. "a*c" looks for an a, then a *, then a c, and no line contains a *. Unquoted, bash found no file in demo/ to match a*c, so it passed the word on as typed and fzf saw the same query.

On a Mac

Nothing changes: the key is shared. fzf computes the ranking, and fzf is 0.67.0 on both machines. The only other tool is sort -r, and under the runner's LC_ALL=C the Mac's sort and GNU sort put these lines in the same order.

In zsh and fish

zsh prints the same lines in the same order for all three schemes. Its difference is the unquoted *:

Verified output of fzfuzzy_zsh.zsh, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ for s in default path history; do echo "-- $s"; fzf --filter fb --scheme=$s < bonus.txt; done
-- default
foo bar
foo:bar
foo/bar
foo.bar
foo_bar
foo-bar
fooxBar
fooxbar
-- path
foo/bar
foo.bar
foo_bar
foo-bar
foo:bar
foo bar
fooxBar
fooxbar
-- history
foo.bar
foo_bar
foo-bar
foo:bar
foo/bar
foo bar
fooxBar
fooxbar

$ fzf --filter "a*c" < letters.txt; echo "status $?"
status 1

$ fzf --filter a*c < letters.txt; echo "status $?"
(eval):1: no matches found: a*c
status 1

With no file to match, zsh refused the word a*c with no matches found instead of passing it on. The (eval):1: in front comes from the example's eval, not from fzf.

fish prints the same rankings too, and refuses the same word in its own way:

Verified output of fzfuzzy_fish.fish, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ for s in default path history; echo "-- $s"; fzf --filter fb --scheme=$s < bonus.txt; end
-- default
foo bar
foo:bar
foo/bar
foo.bar
foo_bar
foo-bar
fooxBar
fooxbar
-- path
foo/bar
foo.bar
foo_bar
foo-bar
foo:bar
foo bar
fooxBar
fooxbar
-- history
foo.bar
foo_bar
foo-bar
foo:bar
foo/bar
foo bar
fooxBar
fooxbar

$ fzf --filter "a*c" < letters.txt; echo "status $status"
status 1

$ fzf --filter a*c < letters.txt
fish: No matches for wildcard 'a*c'. See `help language#wildcards-globbing`.
fzf --filter a*c < letters.txt
             ^~^
(exit status 124)

fish stopped the command with No matches for wildcard 'a*c' and exit status 124, and pointed at the word.

unquoted a*c, no file matches bash zsh fish
what happens passed to fzf as typed no matches found; fzf does not run No matches for wildcard, status 124; fzf does not run

An unquoted [:lower:] is a glob is the same three-way split, measured for tr. Quoting the query settles it in all three shells.

If you are coming from another library

  • Rust. Fuzzy finding ↗ walks through Ctrl-T, where typing optvs finds option_vs_result.rs deep in a tree. This page is the rule that makes that work, measured with --filter.

See also