Module: Charming::Components::FuzzyMatcher
- Defined in:
- lib/charming/presentation/components/fuzzy_matcher.rb
Overview
FuzzyMatcher implements fzf-style subsequence matching with contiguity and word-boundary scoring. Used by CommandPalette (and available to any component that filters labels against typed input).
FuzzyMatcher.score("opl", "Open palette") # => positive score
FuzzyMatcher.score("xyz", "Open palette") # => nil (not a subsequence)
FuzzyMatcher.filter("op", commands, &:label)
Constant Summary collapse
- CHAR_SCORE =
Score bonuses: base per matched char, consecutive-run bonus, word-start bonus. The run bonus outweighs the word-start bonus so a literal substring match (“pal” in “Open palette”) beats the same letters scattered across word starts.
1- CONSECUTIVE_BONUS =
4- WORD_START_BONUS =
3
Class Method Summary collapse
- .best_alignment(q, qi, c, ci, consecutive_at_ci, memo) ⇒ Object
-
.filter(query, candidates, &label) ⇒ Object
Filters candidates to those matching query, ordered best-score first (original order breaks ties).
-
.score(query, candidate) ⇒ Object
Returns a relevance score when every character of query appears in order within candidate (case-insensitive), nil otherwise.
-
.word_start?(text, index) ⇒ Boolean
True when the character at index starts a word: position 0 or preceded by a separator (space, underscore, hyphen, slash, dot, colon).
Class Method Details
.best_alignment(q, qi, c, ci, consecutive_at_ci, memo) ⇒ Object
38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
# File 'lib/charming/presentation/components/fuzzy_matcher.rb', line 38 def best_alignment(q, qi, c, ci, consecutive_at_ci, memo) return 0 if qi == q.length key = [qi, ci, consecutive_at_ci] return memo[key] if memo.key?(key) best = nil index = ci while (index = c.index(q[qi], index)) points = CHAR_SCORE points += CONSECUTIVE_BONUS if consecutive_at_ci && index == ci points += WORD_START_BONUS if word_start?(c, index) rest = best_alignment(q, qi + 1, c, index + 1, true, memo) if rest total = points + rest best = total if best.nil? || total > best end index += 1 end memo[key] = best end |
.filter(query, candidates, &label) ⇒ Object
Filters candidates to those matching query, ordered best-score first (original order breaks ties). The optional block extracts the searchable label from each candidate (defaults to to_s).
64 65 66 67 68 69 70 71 72 |
# File 'lib/charming/presentation/components/fuzzy_matcher.rb', line 64 def filter(query, candidates, &label) scored = candidates.each_with_index.filter_map do |candidate, index| text = label ? yield(candidate) : candidate.to_s candidate_score = score(query, text) [candidate_score, index, candidate] if candidate_score end scored.sort_by { |candidate_score, index, _| [-candidate_score, index] }.map(&:last) end |
.score(query, candidate) ⇒ Object
Returns a relevance score when every character of query appears in order within candidate (case-insensitive), nil otherwise. Higher is better: contiguous runs and matches at word starts score above scattered matches. All alignments are considered (memoized), so “pal” finds the contiguous run in “Open palette” rather than the scattered greedy match.
27 28 29 30 31 32 33 |
# File 'lib/charming/presentation/components/fuzzy_matcher.rb', line 27 def score(query, candidate) q = query.to_s.downcase c = candidate.to_s.downcase return 0 if q.empty? best_alignment(q, 0, c, 0, false, {}) end |
.word_start?(text, index) ⇒ Boolean
True when the character at index starts a word: position 0 or preceded by a separator (space, underscore, hyphen, slash, dot, colon).
76 77 78 79 80 |
# File 'lib/charming/presentation/components/fuzzy_matcher.rb', line 76 def word_start?(text, index) return true if index.zero? text[index - 1].match?(%r{[\s_\-/.:]}) end |