fzy_score

A tiny, dependency-free Ruby port of the fzy fuzzy-matching scoring algorithm — the same family of algorithm used by fzf and fzf-for-js.

Unlike Ruby's existing fuzzy gems (which solve record linkage with Levenshtein/Dice/Jaro, or only answer the boolean "does it match?"), fzy_score returns both a relevance score and the matched character positions — exactly what you need to build:

  • a command palette / quick-open
  • an autocomplete dropdown
  • a CLI picker with highlighted matches

It's pure Ruby, has zero runtime dependencies, and ports fzy's published scoring constants verbatim so ranking matches the reference tool.

Installation

# Gemfile
gem "fzy_score"
gem install fzy_score

Usage

Score a single candidate

require "fzy_score"

FzyScore.score("amf", "app/models/foo.rb")  # => 3.58 (higher is better)
FzyScore.score("zzz", "app/models/foo.rb")  # => -Infinity (no match)
FzyScore.score("abc", "abc")                # => Infinity (exact match)

Get matched positions for highlighting

m = FzyScore.match("amu", "app/models/user.rb")
m.score       # => Float
m.positions   # => [0, 4, 11]   indices into the haystack to highlight
m.matched?    # => true

# Highlight in a terminal:
def highlight(haystack, positions)
  set = positions.to_set
  haystack.each_char.with_index.map { |c, i| set.include?(i) ? "\e[33m#{c}\e[0m" : c }.join
end
puts highlight("app/models/user.rb", m.positions)

Filter and rank a list (best first)

files = ["spec/match_spec.rb", "src/match.rb", "README.md"]

FzyScore.filter("srcmatch", files)
# => [["src/match.rb", 6.97, nil]]   (non-matches dropped, sorted best-first)

# Want positions too?
FzyScore.filter("srcmatch", files, positions: true)
# => [["src/match.rb", 6.97, [0,1,2,4,5,6,7,8]]]

# Filtering objects? Pass a key extractor — the original object comes back.
people = [{ name: "Alice" }, { name: "Bob" }, { name: "Albert" }]
FzyScore.filter("al", people, key: ->(p) { p[:name] })
# => [[{name: "Albert"}, ...], [{name: "Alice"}, ...]]

Cheap pre-filter

If you only need to know whether something matches (no scoring), use the O(n) predicate:

FzyScore.match?("amf", "app/models/foo.rb")  # => true

filter already uses this internally to skip the DP for non-matches.

How scoring works

fzy_score implements fzy's modified Smith–Waterman dynamic program. Matches earn bonuses for landing at "good" places and pay small penalties for gaps:

Situation Bonus / penalty
Consecutive characters +1.0
First char after / (path component) +0.9
First char after -, _, space (word start) +0.8
Uppercase after lowercase (camelCase) +0.7
First char after . (file extension) +0.6
Leading / trailing gap -0.005 per char
Inner gap -0.01 per char

An exact (case-insensitive) match returns Float::INFINITY; a non-match returns -Float::INFINITY so it sorts last. These are the exact constants from fzy's config.def.h.

Comparison with other Ruby gems

Gem What it does Returns a score? Returns positions?
fuzzy_match, amatch record linkage (Levenshtein/Dice/Jaro) similarity no
fuzzyfinder boolean filter no no
fzy_score fzf/fzy-style ranking yes yes

Development

bundle install
bundle exec rake test

License

MIT © Levelbrook Consulting. See LICENSE.

The scoring algorithm and constants are ported from jhawthorn/fzy (MIT).