fzy_score 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE +40 -0
- data/README.md +126 -0
- data/lib/fzy_score/match.rb +18 -0
- data/lib/fzy_score/version.rb +5 -0
- data/lib/fzy_score.rb +261 -0
- metadata +85 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a6f6146dd91063312cd225fe2d7c73f76fa5a3ddb4859138a5a37740320ce1d6
|
|
4
|
+
data.tar.gz: 3ab14c673a173547b8e8e41a1f6678468394da630a38cb84572121aa5ad809d1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 46e4486951489bc057893837456250c2f3e17e4c2f4b48ad3768bf2e04cee2419b7140f298120ba772ae6363ff60e3eb013a171bae7a052655c3112994d50712
|
|
7
|
+
data.tar.gz: bc864f1b8639a15d3ee41820e16dfd93c9bff85d47d127fe237adc4918949466f55c0a405d31330595b158e095e56ff3f01046a516c49235a743b9100cf6cd38
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-05-28
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Initial release.
|
|
14
|
+
- `FzyScore.score(needle, haystack)` — relevance score (higher is better).
|
|
15
|
+
- `FzyScore.match(needle, haystack, positions:)` — score plus matched positions
|
|
16
|
+
for highlighting, returned as a `FzyScore::Match`.
|
|
17
|
+
- `FzyScore.match?(needle, haystack)` — O(n) boolean pre-filter.
|
|
18
|
+
- `FzyScore.filter(needle, candidates, positions:, key:)` — rank a list,
|
|
19
|
+
best-first, with stable tie-breaking and an optional key extractor.
|
|
20
|
+
- Faithful port of fzy's scoring constants from `config.def.h`.
|
|
21
|
+
|
|
22
|
+
[Unreleased]: https://github.com/tachyurgy/fzy_score/compare/v0.1.0...HEAD
|
|
23
|
+
[0.1.0]: https://github.com/tachyurgy/fzy_score/releases/tag/v0.1.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Levelbrook Consulting
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
The scoring algorithm and constants in this gem are ported from fzy
|
|
26
|
+
(https://github.com/jhawthorn/fzy), which is also MIT licensed:
|
|
27
|
+
|
|
28
|
+
Copyright (c) 2014 John Hawthorn
|
|
29
|
+
|
|
30
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
31
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
32
|
+
in the Software without restriction, including without limitation the rights
|
|
33
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
34
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
35
|
+
furnished to do so, subject to the following conditions:
|
|
36
|
+
|
|
37
|
+
The above copyright notice and this permission notice shall be included in all
|
|
38
|
+
copies or substantial portions of the Software.
|
|
39
|
+
|
|
40
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
data/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# fzy_score
|
|
2
|
+
|
|
3
|
+
A tiny, dependency-free Ruby port of the [fzy](https://github.com/jhawthorn/fzy)
|
|
4
|
+
fuzzy-matching **scoring** algorithm — the same family of algorithm used by
|
|
5
|
+
[fzf](https://github.com/junegunn/fzf) and [fzf-for-js](https://github.com/ajitid/fzf-for-js).
|
|
6
|
+
|
|
7
|
+
Unlike Ruby's existing fuzzy gems (which solve *record linkage* with
|
|
8
|
+
Levenshtein/Dice/Jaro, or only answer the boolean "does it match?"),
|
|
9
|
+
`fzy_score` returns **both a relevance score and the matched character
|
|
10
|
+
positions** — exactly what you need to build:
|
|
11
|
+
|
|
12
|
+
- a command palette / quick-open
|
|
13
|
+
- an autocomplete dropdown
|
|
14
|
+
- a CLI picker with highlighted matches
|
|
15
|
+
|
|
16
|
+
It's pure Ruby, has zero runtime dependencies, and ports fzy's published
|
|
17
|
+
scoring constants verbatim so ranking matches the reference tool.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
# Gemfile
|
|
23
|
+
gem "fzy_score"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
gem install fzy_score
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
### Score a single candidate
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
require "fzy_score"
|
|
36
|
+
|
|
37
|
+
FzyScore.score("amf", "app/models/foo.rb") # => 3.58 (higher is better)
|
|
38
|
+
FzyScore.score("zzz", "app/models/foo.rb") # => -Infinity (no match)
|
|
39
|
+
FzyScore.score("abc", "abc") # => Infinity (exact match)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Get matched positions for highlighting
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
m = FzyScore.match("amu", "app/models/user.rb")
|
|
46
|
+
m.score # => Float
|
|
47
|
+
m.positions # => [0, 4, 11] indices into the haystack to highlight
|
|
48
|
+
m.matched? # => true
|
|
49
|
+
|
|
50
|
+
# Highlight in a terminal:
|
|
51
|
+
def highlight(haystack, positions)
|
|
52
|
+
set = positions.to_set
|
|
53
|
+
haystack.each_char.with_index.map { |c, i| set.include?(i) ? "\e[33m#{c}\e[0m" : c }.join
|
|
54
|
+
end
|
|
55
|
+
puts highlight("app/models/user.rb", m.positions)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Filter and rank a list (best first)
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
files = ["spec/match_spec.rb", "src/match.rb", "README.md"]
|
|
62
|
+
|
|
63
|
+
FzyScore.filter("srcmatch", files)
|
|
64
|
+
# => [["src/match.rb", 6.97, nil]] (non-matches dropped, sorted best-first)
|
|
65
|
+
|
|
66
|
+
# Want positions too?
|
|
67
|
+
FzyScore.filter("srcmatch", files, positions: true)
|
|
68
|
+
# => [["src/match.rb", 6.97, [0,1,2,4,5,6,7,8]]]
|
|
69
|
+
|
|
70
|
+
# Filtering objects? Pass a key extractor — the original object comes back.
|
|
71
|
+
people = [{ name: "Alice" }, { name: "Bob" }, { name: "Albert" }]
|
|
72
|
+
FzyScore.filter("al", people, key: ->(p) { p[:name] })
|
|
73
|
+
# => [[{name: "Albert"}, ...], [{name: "Alice"}, ...]]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Cheap pre-filter
|
|
77
|
+
|
|
78
|
+
If you only need to know *whether* something matches (no scoring), use the
|
|
79
|
+
O(n) predicate:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
FzyScore.match?("amf", "app/models/foo.rb") # => true
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`filter` already uses this internally to skip the DP for non-matches.
|
|
86
|
+
|
|
87
|
+
## How scoring works
|
|
88
|
+
|
|
89
|
+
`fzy_score` implements fzy's modified Smith–Waterman dynamic program. Matches
|
|
90
|
+
earn bonuses for landing at "good" places and pay small penalties for gaps:
|
|
91
|
+
|
|
92
|
+
| Situation | Bonus / penalty |
|
|
93
|
+
|---|---|
|
|
94
|
+
| Consecutive characters | `+1.0` |
|
|
95
|
+
| First char after `/` (path component) | `+0.9` |
|
|
96
|
+
| First char after `-`, `_`, space (word start) | `+0.8` |
|
|
97
|
+
| Uppercase after lowercase (camelCase) | `+0.7` |
|
|
98
|
+
| First char after `.` (file extension) | `+0.6` |
|
|
99
|
+
| Leading / trailing gap | `-0.005` per char |
|
|
100
|
+
| Inner gap | `-0.01` per char |
|
|
101
|
+
|
|
102
|
+
An exact (case-insensitive) match returns `Float::INFINITY`; a non-match
|
|
103
|
+
returns `-Float::INFINITY` so it sorts last. These are the exact constants
|
|
104
|
+
from fzy's `config.def.h`.
|
|
105
|
+
|
|
106
|
+
## Comparison with other Ruby gems
|
|
107
|
+
|
|
108
|
+
| Gem | What it does | Returns a score? | Returns positions? |
|
|
109
|
+
|---|---|---|---|
|
|
110
|
+
| `fuzzy_match`, `amatch` | record linkage (Levenshtein/Dice/Jaro) | similarity | no |
|
|
111
|
+
| `fuzzyfinder` | boolean filter | no | no |
|
|
112
|
+
| **`fzy_score`** | **fzf/fzy-style ranking** | **yes** | **yes** |
|
|
113
|
+
|
|
114
|
+
## Development
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
bundle install
|
|
118
|
+
bundle exec rake test
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT © Levelbrook Consulting. See [LICENSE](LICENSE).
|
|
124
|
+
|
|
125
|
+
The scoring algorithm and constants are ported from
|
|
126
|
+
[jhawthorn/fzy](https://github.com/jhawthorn/fzy) (MIT).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FzyScore
|
|
4
|
+
# Result of a scored fuzzy match.
|
|
5
|
+
#
|
|
6
|
+
# +score+ Float relevance score. Higher is better. {SCORE_MAX} for an
|
|
7
|
+
# exact (case-insensitive) match, {SCORE_MIN} for no match / empty
|
|
8
|
+
# needle / oversized candidate.
|
|
9
|
+
# +positions+ Array<Integer> of the indices in +haystack+ that the needle
|
|
10
|
+
# matched, suitable for highlighting. +nil+ unless positions were
|
|
11
|
+
# requested.
|
|
12
|
+
Match = Struct.new(:score, :positions) do
|
|
13
|
+
# @return [Boolean] true when the candidate actually matched.
|
|
14
|
+
def matched?
|
|
15
|
+
score > SCORE_MIN
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/fzy_score.rb
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "fzy_score/version"
|
|
4
|
+
require_relative "fzy_score/match"
|
|
5
|
+
|
|
6
|
+
# FzyScore is a faithful, dependency-free Ruby port of the {https://github.com/jhawthorn/fzy
|
|
7
|
+
# fzy} fuzzy-matching scoring algorithm (the same family of algorithm used by
|
|
8
|
+
# {https://github.com/junegunn/fzf fzf} and {https://github.com/ajitid/fzf-for-js fzf-for-js}).
|
|
9
|
+
#
|
|
10
|
+
# Unlike Ruby's existing fuzzy gems (which do Levenshtein/Dice record linkage, or a
|
|
11
|
+
# boolean "does it match" filter), FzyScore returns BOTH a relevance *score* and the
|
|
12
|
+
# matched character *positions* — exactly what you need to build a command palette,
|
|
13
|
+
# quick-open, autocomplete, or CLI picker with highlighting.
|
|
14
|
+
#
|
|
15
|
+
# @example Quick scoring
|
|
16
|
+
# FzyScore.score("amf", "app/models/foo.rb") # => Float
|
|
17
|
+
#
|
|
18
|
+
# @example Ranking candidates
|
|
19
|
+
# FzyScore.filter("srcmtch", ["src/match.rb", "spec/match_spec.rb", "README.md"])
|
|
20
|
+
# # => [["src/match.rb", <score>, [0,1,2,4,5,6,7]], ...] (best first)
|
|
21
|
+
#
|
|
22
|
+
# @example Highlighting
|
|
23
|
+
# m = FzyScore.match("mr", "app/models/user.rb", positions: true)
|
|
24
|
+
# m.positions # => indices to highlight
|
|
25
|
+
module FzyScore
|
|
26
|
+
# Scoring constants, taken verbatim from fzy's config.def.h so that ranking
|
|
27
|
+
# matches the reference implementation.
|
|
28
|
+
SCORE_GAP_LEADING = -0.005
|
|
29
|
+
SCORE_GAP_TRAILING = -0.005
|
|
30
|
+
SCORE_GAP_INNER = -0.01
|
|
31
|
+
SCORE_MATCH_CONSECUTIVE = 1.0
|
|
32
|
+
SCORE_MATCH_SLASH = 0.9
|
|
33
|
+
SCORE_MATCH_WORD = 0.8
|
|
34
|
+
SCORE_MATCH_CAPITAL = 0.7
|
|
35
|
+
SCORE_MATCH_DOT = 0.6
|
|
36
|
+
|
|
37
|
+
SCORE_MAX = Float::INFINITY
|
|
38
|
+
SCORE_MIN = -Float::INFINITY
|
|
39
|
+
|
|
40
|
+
# Candidates longer than this are not scored with the full DP (treated as
|
|
41
|
+
# SCORE_MIN), matching fzy's behaviour of not penalising the rest of the UI
|
|
42
|
+
# for one unreasonably large entry.
|
|
43
|
+
MATCH_MAX_LEN = 1024
|
|
44
|
+
|
|
45
|
+
# Bonus awarded to a character based on the character that precedes it.
|
|
46
|
+
# Mirrors fzy's bonus_states table.
|
|
47
|
+
WORD_BREAK = { "-" => SCORE_MATCH_WORD, "_" => SCORE_MATCH_WORD, " " => SCORE_MATCH_WORD }.freeze
|
|
48
|
+
|
|
49
|
+
module_function
|
|
50
|
+
|
|
51
|
+
# Does +needle+ fuzzily match +haystack+ at all (case-insensitive, in order)?
|
|
52
|
+
#
|
|
53
|
+
# This is the cheap O(n) pre-filter; it does not compute a score.
|
|
54
|
+
#
|
|
55
|
+
# @param needle [String]
|
|
56
|
+
# @param haystack [String]
|
|
57
|
+
# @return [Boolean]
|
|
58
|
+
def match?(needle, haystack)
|
|
59
|
+
n = needle.downcase
|
|
60
|
+
h = haystack.downcase
|
|
61
|
+
j = 0
|
|
62
|
+
n.each_char do |ch|
|
|
63
|
+
j = h.index(ch, j)
|
|
64
|
+
return false if j.nil?
|
|
65
|
+
|
|
66
|
+
j += 1
|
|
67
|
+
end
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Score how well +needle+ matches +haystack+. Returns {SCORE_MIN} when there
|
|
72
|
+
# is no match (so it sorts last). Higher is better.
|
|
73
|
+
#
|
|
74
|
+
# @param needle [String]
|
|
75
|
+
# @param haystack [String]
|
|
76
|
+
# @return [Float]
|
|
77
|
+
def score(needle, haystack)
|
|
78
|
+
do_match(needle, haystack, positions: false).score
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Score +needle+ against +haystack+ and (optionally) return the matched
|
|
82
|
+
# positions for highlighting.
|
|
83
|
+
#
|
|
84
|
+
# @param needle [String]
|
|
85
|
+
# @param haystack [String]
|
|
86
|
+
# @param positions [Boolean] also compute the matched indices (slightly more work)
|
|
87
|
+
# @return [FzyScore::Match]
|
|
88
|
+
def match(needle, haystack, positions: true)
|
|
89
|
+
do_match(needle, haystack, positions: positions)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Filter and rank a list of candidates against +needle+, best first.
|
|
93
|
+
#
|
|
94
|
+
# Each returned row is +[candidate, score, positions]+. Candidates that do
|
|
95
|
+
# not match are dropped. Sorting is stable on ties (preserves input order),
|
|
96
|
+
# matching the intuition users expect from a picker.
|
|
97
|
+
#
|
|
98
|
+
# @param needle [String]
|
|
99
|
+
# @param candidates [Array<#to_s>]
|
|
100
|
+
# @param positions [Boolean] include matched positions in each row
|
|
101
|
+
# @param key [Proc, nil] extract the string to match from each candidate
|
|
102
|
+
# @return [Array<Array>] rows of [candidate, score, positions_or_nil]
|
|
103
|
+
def filter(needle, candidates, positions: false, key: nil)
|
|
104
|
+
rows = []
|
|
105
|
+
candidates.each_with_index do |candidate, idx|
|
|
106
|
+
str = key ? key.call(candidate) : candidate.to_s
|
|
107
|
+
next unless match?(needle, str)
|
|
108
|
+
|
|
109
|
+
m = do_match(needle, str, positions: positions)
|
|
110
|
+
rows << [candidate, m.score, m.positions, idx]
|
|
111
|
+
end
|
|
112
|
+
# Stable sort: higher score first, original index breaks ties.
|
|
113
|
+
rows.sort_by! { |row| [-row[1], row[3]] }
|
|
114
|
+
rows.map { |candidate, sc, pos, _| [candidate, sc, pos] }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# --- internal -------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
# @api private
|
|
120
|
+
def do_match(needle, haystack, positions:)
|
|
121
|
+
return Match.new(SCORE_MIN, nil) if needle.nil? || needle.empty?
|
|
122
|
+
return Match.new(SCORE_MIN, nil) unless match?(needle, haystack)
|
|
123
|
+
|
|
124
|
+
n = needle.length
|
|
125
|
+
m = haystack.length
|
|
126
|
+
|
|
127
|
+
if m > MATCH_MAX_LEN || n > m
|
|
128
|
+
return Match.new(SCORE_MIN, nil)
|
|
129
|
+
elsif n == m
|
|
130
|
+
# Same length AND it matched => identical (case-insensitive).
|
|
131
|
+
return Match.new(SCORE_MAX, positions ? (0...n).to_a : nil)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
lower_needle = needle.downcase
|
|
135
|
+
lower_haystack = haystack.downcase
|
|
136
|
+
match_bonus = precompute_bonus(haystack)
|
|
137
|
+
|
|
138
|
+
if positions
|
|
139
|
+
compute_with_positions(lower_needle, lower_haystack, match_bonus, n, m)
|
|
140
|
+
else
|
|
141
|
+
Match.new(compute_score(lower_needle, lower_haystack, match_bonus, n, m), nil)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Per-position bonus based on the *preceding* character (word starts, slashes,
|
|
146
|
+
# dots, camelCase transitions). The first character is treated as if preceded
|
|
147
|
+
# by a slash, matching fzy.
|
|
148
|
+
# @api private
|
|
149
|
+
def precompute_bonus(haystack)
|
|
150
|
+
bonus = Array.new(haystack.length, 0.0)
|
|
151
|
+
last_ch = "/"
|
|
152
|
+
haystack.each_char.with_index do |ch, i|
|
|
153
|
+
bonus[i] = compute_bonus(last_ch, ch)
|
|
154
|
+
last_ch = ch
|
|
155
|
+
end
|
|
156
|
+
bonus
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @api private
|
|
160
|
+
def compute_bonus(last_ch, ch)
|
|
161
|
+
# Bonus only applies to alphanumeric current characters.
|
|
162
|
+
return 0.0 unless ch.match?(/[a-z0-9]/i)
|
|
163
|
+
|
|
164
|
+
case last_ch
|
|
165
|
+
when "/"
|
|
166
|
+
SCORE_MATCH_SLASH
|
|
167
|
+
when "-", "_", " "
|
|
168
|
+
SCORE_MATCH_WORD
|
|
169
|
+
when "."
|
|
170
|
+
SCORE_MATCH_DOT
|
|
171
|
+
else
|
|
172
|
+
# camelCase: an uppercase current char after a lowercase previous char.
|
|
173
|
+
if ch.match?(/[A-Z]/) && last_ch.match?(/[a-z]/)
|
|
174
|
+
SCORE_MATCH_CAPITAL
|
|
175
|
+
else
|
|
176
|
+
0.0
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Score-only DP. Two rolling rows (D and M), matching fzy's match().
|
|
182
|
+
# @api private
|
|
183
|
+
def compute_score(needle, haystack, match_bonus, n, m)
|
|
184
|
+
d_last = Array.new(m, SCORE_MIN)
|
|
185
|
+
m_last = Array.new(m, SCORE_MIN)
|
|
186
|
+
d_curr = Array.new(m, SCORE_MIN)
|
|
187
|
+
m_curr = Array.new(m, SCORE_MIN)
|
|
188
|
+
|
|
189
|
+
n.times do |i|
|
|
190
|
+
match_row(i, n, needle, haystack, match_bonus, m, d_curr, m_curr, d_last, m_last)
|
|
191
|
+
d_curr, d_last = d_last, d_curr
|
|
192
|
+
m_curr, m_last = m_last, m_curr
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# After the swap, the last computed row is in *_last.
|
|
196
|
+
m_last[m - 1]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Full DP keeping every row so positions can be backtracked. Mirrors fzy's
|
|
200
|
+
# match_positions().
|
|
201
|
+
# @api private
|
|
202
|
+
def compute_with_positions(needle, haystack, match_bonus, n, m)
|
|
203
|
+
d = Array.new(n) { Array.new(m, SCORE_MIN) }
|
|
204
|
+
mm = Array.new(n) { Array.new(m, SCORE_MIN) }
|
|
205
|
+
|
|
206
|
+
match_row(0, n, needle, haystack, match_bonus, m, d[0], mm[0], d[0], mm[0])
|
|
207
|
+
(1...n).each do |i|
|
|
208
|
+
match_row(i, n, needle, haystack, match_bonus, m, d[i], mm[i], d[i - 1], mm[i - 1])
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
positions = Array.new(n, 0)
|
|
212
|
+
match_required = false
|
|
213
|
+
j = m - 1
|
|
214
|
+
i = n - 1
|
|
215
|
+
while i >= 0
|
|
216
|
+
while j >= 0
|
|
217
|
+
if d[i][j] != SCORE_MIN && (match_required || d[i][j] == mm[i][j])
|
|
218
|
+
match_required =
|
|
219
|
+
i.positive? && j.positive? &&
|
|
220
|
+
mm[i][j] == d[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE
|
|
221
|
+
positions[i] = j
|
|
222
|
+
j -= 1
|
|
223
|
+
break
|
|
224
|
+
end
|
|
225
|
+
j -= 1
|
|
226
|
+
end
|
|
227
|
+
i -= 1
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
Match.new(mm[n - 1][m - 1], positions)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Compute one row of the DP. Faithful translation of fzy's match_row().
|
|
234
|
+
# @api private
|
|
235
|
+
def match_row(i, n, needle, haystack, match_bonus, m, d_curr, m_curr, d_last, m_last)
|
|
236
|
+
prev_score = SCORE_MIN
|
|
237
|
+
gap_score = i == n - 1 ? SCORE_GAP_TRAILING : SCORE_GAP_INNER
|
|
238
|
+
needle_ch = needle[i]
|
|
239
|
+
|
|
240
|
+
j = 0
|
|
241
|
+
while j < m
|
|
242
|
+
if needle_ch == haystack[j]
|
|
243
|
+
score = SCORE_MIN
|
|
244
|
+
if i.zero?
|
|
245
|
+
score = (j * SCORE_GAP_LEADING) + match_bonus[j]
|
|
246
|
+
elsif j.positive?
|
|
247
|
+
consecutive = d_last[j - 1] + SCORE_MATCH_CONSECUTIVE
|
|
248
|
+
via_bonus = m_last[j - 1] + match_bonus[j]
|
|
249
|
+
score = via_bonus > consecutive ? via_bonus : consecutive
|
|
250
|
+
end
|
|
251
|
+
d_curr[j] = score
|
|
252
|
+
candidate = prev_score + gap_score
|
|
253
|
+
m_curr[j] = prev_score = score > candidate ? score : candidate
|
|
254
|
+
else
|
|
255
|
+
d_curr[j] = SCORE_MIN
|
|
256
|
+
m_curr[j] = prev_score = prev_score + gap_score
|
|
257
|
+
end
|
|
258
|
+
j += 1
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: fzy_score
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Levelbrook Team
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-04 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rake
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '13.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '13.0'
|
|
41
|
+
description: |-
|
|
42
|
+
A tiny, dependency-free fuzzy matcher that returns both a relevance score and the
|
|
43
|
+
matched character positions (for highlighting) — the same algorithm family used by
|
|
44
|
+
fzy, fzf, and fzf-for-js. Unlike Ruby's record-linkage fuzzy gems, fzy_score is built
|
|
45
|
+
for command palettes, quick-open, autocomplete, and CLI pickers.
|
|
46
|
+
email:
|
|
47
|
+
- levelbrookteam@gmail.com
|
|
48
|
+
executables: []
|
|
49
|
+
extensions: []
|
|
50
|
+
extra_rdoc_files: []
|
|
51
|
+
files:
|
|
52
|
+
- CHANGELOG.md
|
|
53
|
+
- LICENSE
|
|
54
|
+
- README.md
|
|
55
|
+
- lib/fzy_score.rb
|
|
56
|
+
- lib/fzy_score/match.rb
|
|
57
|
+
- lib/fzy_score/version.rb
|
|
58
|
+
homepage: https://consulting.levelbrook.com
|
|
59
|
+
licenses:
|
|
60
|
+
- MIT
|
|
61
|
+
metadata:
|
|
62
|
+
homepage_uri: https://consulting.levelbrook.com
|
|
63
|
+
source_code_uri: https://github.com/tachyurgy/fzy_score
|
|
64
|
+
changelog_uri: https://github.com/tachyurgy/fzy_score/blob/main/CHANGELOG.md
|
|
65
|
+
rubygems_mfa_required: 'true'
|
|
66
|
+
post_install_message:
|
|
67
|
+
rdoc_options: []
|
|
68
|
+
require_paths:
|
|
69
|
+
- lib
|
|
70
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: 3.0.0
|
|
75
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '0'
|
|
80
|
+
requirements: []
|
|
81
|
+
rubygems_version: 3.5.22
|
|
82
|
+
signing_key:
|
|
83
|
+
specification_version: 4
|
|
84
|
+
summary: Faithful Ruby port of the fzy/fzf fuzzy-matching scoring algorithm.
|
|
85
|
+
test_files: []
|