Skip to main content

yuru_core/
matcher.rs

1use crate::{query::key_kind_allowed, QueryVariant, SearchKey};
2use nucleo_matcher::{chars, Config as NucleoConfig, Matcher, Utf32Str};
3use std::borrow::Cow;
4use unicode_normalization::UnicodeNormalization;
5
6const SCORE_MATCH: i64 = 160;
7const SCORE_GAP_START: i64 = -30;
8const SCORE_GAP_EXTENSION: i64 = -10;
9const BONUS_BOUNDARY: i64 = 80;
10const BONUS_BOUNDARY_WHITE: i64 = 100;
11const BONUS_BOUNDARY_DELIMITER: i64 = 90;
12const BONUS_CAMEL_OR_NUMBER: i64 = 70;
13const BONUS_CONSECUTIVE: i64 = 40;
14/// Bonus for a case-insensitive match whose characters are the ones the query was typed
15/// with, so that a candidate spelled the way the user spelled it wins a tie.
16///
17/// Awarded once per match rather than per matched character: per character it would scale
18/// with query length, and a long query would swamp every boundary bonus. Sized to sit
19/// between [`BONUS_CAMEL_OR_NUMBER`] and [`BONUS_BOUNDARY`], so a literally spelled match
20/// outranks a camelCase hump but never outranks a real word boundary. Inert when the search
21/// is case-sensitive, where every match is exact by construction.
22///
23/// This states, and weakens, a preference v0.1.11 got by accident: case-insensitive matching
24/// used to reach mixed-case text through the lower-weighted [`crate::KeyKind::Normalized`]
25/// key (2800) while a literal match came from the [`crate::KeyKind::Original`] key (3000), so
26/// the literal spelling won by 200 points. Folding case inside the matcher retired that
27/// accident - together with the boundary bonuses the lowercased key was destroying - and left
28/// nothing preferring the spelling the user typed. 75 points is what is left of the 200.
29///
30/// The extended-query path awards the same bonus on the same terms, from
31/// `fzf_query::case_exact_bonus`, so `'foo` and `--exact foo` order case variants alike.
32pub(crate) const BONUS_CASE_EXACT: i64 = 75;
33const _: () = assert!(
34    BONUS_CASE_EXACT > BONUS_CAMEL_OR_NUMBER && BONUS_CASE_EXACT < BONUS_BOUNDARY,
35    "the exact-case bonus must break a camelCase tie without outranking a word boundary"
36);
37const BONUS_FIRST_CHAR_MULTIPLIER: i64 = 2;
38const START_POSITION_PENALTY: i64 = 2;
39const TEXT_LENGTH_PENALTY_DIVISOR: i64 = 8;
40
41/// Pluggable matcher that scores a pattern against one searchable text.
42///
43/// Case handling belongs to the implementation: [`crate::search`] builds its matcher from
44/// [`crate::SearchConfig::case_sensitive`], and a matcher passed to
45/// [`crate::search_with_stats`] carries whatever case policy it was built with. Search only
46/// assumes a case policy where the implementation states one through [`Self::folds_case`].
47pub trait MatcherBackend {
48    /// Returns a score when `pattern` matches `text`.
49    fn score(&mut self, pattern: &str, text: &str) -> Option<i64>;
50
51    /// Returns whether [`Self::score`] folds case with the same mapping the index used,
52    /// so that a [`crate::SearchKey`] which is only a case-folded copy of the display text
53    /// cannot match where the display text does not.
54    ///
55    /// The mapping is `fold_case_char`: one character in, one character out, `char`'s
56    /// simple lowercase mapping where that is a single character and the character as
57    /// written otherwise. Returning `true` promises that
58    /// `score(pattern, fold(text)).is_some()` implies `score(pattern, text).is_some()` for
59    /// every pattern, which lets search skip keys flagged
60    /// [`crate::SearchKey::case_fold_only`] and score only the higher-weighted original key.
61    ///
62    /// The default is `false`: a matcher that never says otherwise is offered every key,
63    /// including the case-folded one, so a matcher that is case-sensitive by construction
64    /// still finds case-insensitive matches through it. Return `!case_sensitive` only if the
65    /// implementation really folds with that mapping - a matcher with its own folding table
66    /// must keep the default, because a table that differs anywhere would silently drop the
67    /// matches only the folded key can reach.
68    fn folds_case(&self) -> bool {
69        false
70    }
71}
72
73/// Greedy subsequence matcher used by the default search path.
74#[derive(Clone, Copy, Debug, Default)]
75pub struct GreedyMatcher {
76    /// Compares characters as written instead of case-folding them.
77    pub case_sensitive: bool,
78}
79
80/// Exact substring matcher used by exact mode.
81#[derive(Clone, Copy, Debug, Default)]
82pub struct ExactMatcher {
83    /// Compares characters as written instead of case-folding them.
84    pub case_sensitive: bool,
85}
86
87impl GreedyMatcher {
88    /// Creates a greedy matcher with the given case policy.
89    pub fn new(case_sensitive: bool) -> Self {
90        Self { case_sensitive }
91    }
92}
93
94impl ExactMatcher {
95    /// Creates an exact matcher with the given case policy.
96    pub fn new(case_sensitive: bool) -> Self {
97        Self { case_sensitive }
98    }
99}
100
101/// Wrapper around `nucleo-matcher` with reusable UTF-32 buffers.
102///
103/// The case policy lives in the wrapped `nucleo_matcher::Config`, so the *haystack* side is
104/// compared by nucleo itself rather than pre-folded here: folding the text before handing it
105/// over would move the match positions and the boundary bonuses off the text as written.
106/// The *needle* side is the caller's job - see [`MatcherBackend::score`] below.
107/// [`Default`] is case-insensitive, matching `nucleo_matcher::Config::DEFAULT`.
108#[derive(Clone, Debug)]
109pub struct NucleoMatcher {
110    matcher: Matcher,
111    pattern_buf: Vec<char>,
112    text_buf: Vec<char>,
113    folded: FoldedPattern,
114}
115
116impl NucleoMatcher {
117    /// Creates a nucleo matcher with the given case policy.
118    pub fn new(case_sensitive: bool) -> Self {
119        let mut config = NucleoConfig::DEFAULT;
120        config.ignore_case = !case_sensitive;
121        Self {
122            matcher: Matcher::new(config),
123            pattern_buf: Vec::new(),
124            text_buf: Vec::new(),
125            folded: FoldedPattern::default(),
126        }
127    }
128}
129
130/// One-entry memo of the case-folded form of the last pattern [`NucleoMatcher`] was asked for.
131///
132/// A search reuses one matcher across every candidate and scores only a handful of distinct
133/// patterns (the query variants), so a single slot hits essentially always. `source` doubles
134/// as the cache key and `text` holds the fold, empty when the pattern needed none, which is
135/// the common case for a lowercase query.
136#[derive(Clone, Debug, Default)]
137struct FoldedPattern {
138    source: String,
139    text: String,
140    needed: bool,
141}
142
143impl FoldedPattern {
144    /// Returns `pattern` folded with nucleo's own case-folding table, or `pattern` itself when
145    /// nucleo's table has nothing to fold in it.
146    #[inline]
147    fn pattern<'a>(&'a mut self, pattern: &'a str) -> &'a str {
148        if self.source != pattern {
149            self.source.clear();
150            self.source.push_str(pattern);
151            self.needed = pattern.chars().any(chars::is_upper_case);
152            if self.needed {
153                self.text.clear();
154                self.text.extend(pattern.chars().map(chars::to_lower_case));
155            }
156        }
157
158        if self.needed {
159            &self.text
160        } else {
161            pattern
162        }
163    }
164}
165
166impl Default for NucleoMatcher {
167    fn default() -> Self {
168        Self::new(false)
169    }
170}
171
172impl MatcherBackend for GreedyMatcher {
173    fn score(&mut self, pattern: &str, text: &str) -> Option<i64> {
174        score_text(pattern, text, self.case_sensitive)
175    }
176
177    /// [`score_text`] compares characters through `fold_case_char`, which is the mapping
178    /// [`crate::SearchKey::case_fold_only`] is computed with. Its rewritten retry keeps that
179    /// promise - see [`retry_with_rewritten_multi_char_lowercase`].
180    fn folds_case(&self) -> bool {
181        !self.case_sensitive
182    }
183}
184
185impl MatcherBackend for ExactMatcher {
186    fn score(&mut self, pattern: &str, text: &str) -> Option<i64> {
187        score_exact_text(pattern, text, self.case_sensitive)
188    }
189
190    /// [`score_exact_text`] compares characters through `fold_case_char`, which is the
191    /// mapping [`crate::SearchKey::case_fold_only`] is computed with. Its rewritten retry
192    /// keeps that promise - see [`retry_with_rewritten_multi_char_lowercase`].
193    fn folds_case(&self) -> bool {
194        !self.case_sensitive
195    }
196}
197
198impl MatcherBackend for NucleoMatcher {
199    /// `nucleo_matcher::Matcher` documents that the needle "must always be normalized by the
200    /// caller (unicode normalization and case folding)", so an `ignore_case` matcher has to be
201    /// handed an already-folded pattern: its haystack comparison folds only the haystack and
202    /// then expects the needle to already be lower case. An unfolded needle does not merely
203    /// miss - with an all-ASCII needle and haystack, nucleo's prefilter and its match matrix
204    /// disagree and `fuzzy_optimal.rs` panics with "should have been caught by prefilter".
205    ///
206    /// Folded with nucleo's own `to_lower_case` so the two sides use one table. This is the
207    /// needle only; the haystack still reaches nucleo as written.
208    ///
209    /// A search calls this once per key per candidate with only a handful of distinct
210    /// patterns, so the decision is memoized on the pattern rather than recomputed: deciding
211    /// it costs a table binary search per character, which measured 1.24x on a 500k search
212    /// when paid on every call.
213    fn score(&mut self, pattern: &str, text: &str) -> Option<i64> {
214        let Self {
215            matcher,
216            pattern_buf,
217            text_buf,
218            folded,
219        } = self;
220
221        let pattern = if matcher.config.ignore_case {
222            folded.pattern(pattern)
223        } else {
224            pattern
225        };
226
227        let pattern = Utf32Str::new(pattern, pattern_buf);
228        let text = Utf32Str::new(text, text_buf);
229        matcher.fuzzy_match(text, pattern).map(i64::from)
230    }
231
232    /// Keeps the conservative default even when this matcher is configured case-insensitive:
233    /// `nucleo-matcher` folds with its own simple-case-folding table, which disagrees with
234    /// `fold_case_char` for 55 characters (`Ɤ` U+A7CB, `Ᲊ` U+1C89, the Garay block, ...) that
235    /// its table does not know. Claiming to fold case would drop the only key that reaches
236    /// those characters - `yuru --algo v2 --filter ɤ` over `Ɤx` must still match.
237    ///
238    /// Deliberately not `!case_sensitive`: this is a claim about the folding *mapping*, not
239    /// about whether folding happens at all, and the mapping is wrong either way. A
240    /// case-sensitive nucleo matcher wants `false` too, since it folds nothing.
241    fn folds_case(&self) -> bool {
242        false
243    }
244}
245
246/// Character positions selected for highlighting a match.
247#[derive(Clone, Debug, Eq, PartialEq)]
248pub struct MatchPositions {
249    /// Zero-based character indices in the original display text.
250    pub char_indices: Vec<usize>,
251}
252
253impl MatchPositions {
254    /// Returns true when no positions were selected.
255    pub fn is_empty(&self) -> bool {
256        self.char_indices.is_empty()
257    }
258}
259
260/// Scores one query variant against one search key after compatibility checks.
261pub fn score_key(variant: &QueryVariant, key: &SearchKey, case_sensitive: bool) -> Option<i64> {
262    if !key_kind_allowed(variant, key.kind) {
263        return None;
264    }
265
266    score_text(&variant.text, &key.text, case_sensitive)
267        .map(|score| score + i64::from(key.weight + variant.weight))
268}
269
270/// Scores a fuzzy subsequence match between `pattern` and `text`.
271///
272/// With `case_sensitive` false both sides are case-folded while comparing, while
273/// boundary and camel-case bonuses still read the text as written, and a match that needed
274/// no folding at all collects [`BONUS_CASE_EXACT`].
275pub fn score_text(pattern: &str, text: &str, case_sensitive: bool) -> Option<i64> {
276    if pattern.is_empty() {
277        return Some(0);
278    }
279
280    if pattern.is_ascii() && text.is_ascii() {
281        // ASCII cannot hold `MULTI_CHAR_LOWERCASE`, so the retry below cannot apply and this
282        // path stays exactly as cheap as it was.
283        return if case_sensitive {
284            score_ascii_text::<true>(pattern, text)
285        } else {
286            score_ascii_text::<false>(pattern, text)
287        };
288    }
289
290    if case_sensitive {
291        return score_unicode_text::<true>(pattern, text);
292    }
293
294    score_unicode_text::<false>(pattern, text).or_else(|| {
295        retry_with_rewritten_multi_char_lowercase(pattern, text, score_rewritten_unicode_text)
296    })
297}
298
299/// Folds one character for comparison, leaving it as written when case matters.
300///
301/// Folding is deliberately one character in, one character out: every caller compares
302/// characters pairwise and reports positions in the unfolded text, so an expansion would
303/// desynchronize both. A character whose full lowercase mapping is several characters
304/// (`İ` lowercases to `i` followed by U+0307 COMBINING DOT ABOVE) therefore stays as
305/// written rather than folding to its first character, which would make `İ` compare equal
306/// to a bare `i` and match patterns the character does not contain.
307///
308/// Refusing to fold it here is not the same as not folding it: case-insensitive matching
309/// reaches the full lowercase form through [`retry_with_rewritten_multi_char_lowercase`],
310/// which rewrites copies of both sides into one spelling before comparing them, and through
311/// the [`crate::KeyKind::Normalized`] key, whose text already carries the expansion.
312fn fold_char<const CASE_SENSITIVE: bool>(ch: char) -> char {
313    if CASE_SENSITIVE {
314        ch
315    } else if ch.is_ascii() {
316        ch.to_ascii_lowercase()
317    } else {
318        let mut lower = ch.to_lowercase();
319        match (lower.len(), lower.next()) {
320            (1, Some(lower)) => lower,
321            _ => ch,
322        }
323    }
324}
325
326/// Folds one character the way case-insensitive matching compares it.
327pub(crate) fn fold_case_char(ch: char) -> char {
328    fold_char::<false>(ch)
329}
330
331/// The only character whose full lowercase mapping is longer than one character, and so the
332/// only one [`fold_char`] refuses to fold.
333///
334/// `İ` U+0130 LATIN CAPITAL LETTER I WITH DOT ABOVE lowercases to `i` followed by U+0307
335/// COMBINING DOT ABOVE. An exhaustive walk of `char::to_lowercase` over the whole scalar
336/// range finds no second one; `only_one_character_has_a_multi_character_lowercase_mapping`
337/// pins that so a future Unicode table update cannot quietly add one.
338pub(crate) const MULTI_CHAR_LOWERCASE: char = 'İ';
339
340/// [`MULTI_CHAR_LOWERCASE`]'s full lowercase mapping, written out.
341pub(crate) const MULTI_CHAR_LOWERCASE_EXPANSION: &str = "i\u{307}";
342
343/// First UTF-8 byte of [`MULTI_CHAR_LOWERCASE`], which is what
344/// [`expand_multi_char_lowercase`] looks for.
345///
346/// A lead byte, never a continuation byte, so a `memchr` for it does not keep stopping on
347/// unrelated multi-byte characters - the trailing `0xB0` is a perfectly ordinary
348/// continuation byte and searching for that instead cost CJK searches 3-7%.
349const MULTI_CHAR_LOWERCASE_LEAD_BYTE: u8 = 0xC4;
350const _: () = assert!(
351    (MULTI_CHAR_LOWERCASE as u32) >= 0x80
352        && (MULTI_CHAR_LOWERCASE as u32) < 0x800
353        && MULTI_CHAR_LOWERCASE_LEAD_BYTE == 0xC0 | ((MULTI_CHAR_LOWERCASE as u32) >> 6) as u8,
354    "the lead byte must be the one a two-byte UTF-8 encoding of the character starts with"
355);
356
357/// Retries a case-insensitive comparison that failed, with the two spellings of
358/// [`MULTI_CHAR_LOWERCASE`] rewritten onto one another, and returns `None` when neither side
359/// spells it as one character.
360///
361/// This is how case-insensitive matching folds the one character [`fold_char`] cannot:
362/// pairwise folding must stay one character in and one character out to keep every caller's
363/// indices lined up with the text as written, so the rewrite happens *before* comparing
364/// instead, on copies, where both sides spell the character alike and every remaining fold is
365/// 1:1 again.
366///
367/// Two rewrites, tried in this order, and the order is the whole point:
368///
369/// 1. **Compose** the written-out mapping back into the single character, on whichever side
370///    spells it out. One character then faces one character, so the comparison earns exactly
371///    the [`SCORE_MATCH`], boundary and [`BONUS_CONSECUTIVE`] terms a one-character match
372///    earns - which is what makes the resulting score comparable with the score of a
373///    candidate that spells the character the way the query does.
374/// 2. **Write it out** on both sides, which is where composing cannot help: a pattern that
375///    matches only *part* of the mapping (`i` against `İ`) has nothing to compose.
376///
377/// Writing it out first, as v0.2.0 did, scores a two-character copy: the written-out
378/// candidate collects a second [`SCORE_MATCH`] and a [`BONUS_CONSECUTIVE`] that the
379/// one-character spelling can never collect, which outranks the literally spelled candidate
380/// and inverts the preference [`BONUS_CASE_EXACT`] exists to state.
381///
382/// Trying composition first cannot change *which* pairs match, only what they score: a
383/// composed comparison rewrites one spelling into the other on one side only, so wherever it
384/// matches, writing both sides out afterwards matches too - and where it does not match, the
385/// written-out comparison still runs. The set of matches is exactly the one writing it out
386/// alone would produce.
387///
388/// `score` must never award [`BONUS_CASE_EXACT`]: it is handed rewritten copies, in which a
389/// character can be spelled the way the query spells it without the candidate being spelled
390/// that way at all. See [`score_rewritten_unicode_text`] and [`score_rewritten_exact_text`].
391///
392/// Deliberately a retry rather than a pre-pass. Every text that already matched keeps the
393/// score it had, since the rewritten comparison is only reached when the as-written one found
394/// nothing at all, so this can only turn a false negative into a match. It also keeps the
395/// cost off the hot path: nothing here runs until a comparison has already failed, and then
396/// only two `memchr`s, which for the overwhelmingly common text without a `İ` in it is all
397/// that happens - a text that holds none never reaches the substring searches composition
398/// needs.
399///
400/// The indices the rewritten comparison computes internally are indices into the rewritten
401/// copies, which is why only score-only entry points may use it. [`score_text`] and
402/// [`score_exact_text`] both return nothing but a score. [`match_positions`], which does
403/// report indices into its argument, handles the expansion itself by carrying the unexpanded
404/// character index alongside each expanded character.
405///
406/// [`MatcherBackend::folds_case`] stays true for the matchers that do this. Its promise is
407/// that a hit against the 1:1-folded text implies a hit against the text as written, and the
408/// rewrites preserve it: [`fold_char`] leaves [`MULTI_CHAR_LOWERCASE`] alone, so rewriting a
409/// spelling and folding 1:1 commute, and the rewritten copy of a folded text is the 1:1 fold
410/// of the rewritten text - the same comparison either way.
411fn retry_with_rewritten_multi_char_lowercase(
412    pattern: &str,
413    text: &str,
414    score: fn(&str, &str) -> Option<i64>,
415) -> Option<i64> {
416    let pattern_composed = contains_multi_char_lowercase(pattern);
417    let text_composed = contains_multi_char_lowercase(text);
418    if !pattern_composed && !text_composed {
419        return None;
420    }
421
422    let composed_pattern = compose_multi_char_lowercase(pattern);
423    let composed_text = compose_multi_char_lowercase(text);
424    if composed_pattern.is_some() || composed_text.is_some() {
425        let composed = score(
426            composed_pattern.as_deref().unwrap_or(pattern),
427            composed_text.as_deref().unwrap_or(text),
428        );
429        if composed.is_some() {
430            return composed;
431        }
432    }
433
434    score(
435        &expand_multi_char_lowercase(pattern, pattern_composed),
436        &expand_multi_char_lowercase(text, text_composed),
437    )
438}
439
440/// Returns whether `text` spells [`MULTI_CHAR_LOWERCASE`] as the single character.
441///
442/// This is the gate every failed comparison pays; `slice::contains` over `u8` is specialized
443/// to `memchr`. Only text that holds the lead byte at all - `İ` itself or one of the 63 other
444/// Latin Extended-A characters that share it - pays for the character search that confirms
445/// it, and only text that passes this gate pays for the substring searches the rewrites need.
446fn contains_multi_char_lowercase(text: &str) -> bool {
447    text.as_bytes().contains(&MULTI_CHAR_LOWERCASE_LEAD_BYTE) && text.contains(MULTI_CHAR_LOWERCASE)
448}
449
450/// Returns `text` with [`MULTI_CHAR_LOWERCASE_EXPANSION`] composed back into the single
451/// character it is the lowercase mapping of, or `None` when `text` writes it out nowhere.
452///
453/// The replacement is derived from [`MULTI_CHAR_LOWERCASE`] rather than written down again,
454/// so the two spellings cannot drift apart.
455fn compose_multi_char_lowercase(text: &str) -> Option<String> {
456    if !text.contains(MULTI_CHAR_LOWERCASE_EXPANSION) {
457        return None;
458    }
459
460    let mut composed = [0u8; 4];
461    Some(text.replace(
462        MULTI_CHAR_LOWERCASE_EXPANSION,
463        MULTI_CHAR_LOWERCASE.encode_utf8(&mut composed),
464    ))
465}
466
467/// Returns `text` with [`MULTI_CHAR_LOWERCASE`] written out, borrowing it unchanged when
468/// `contains` says it holds none.
469fn expand_multi_char_lowercase(text: &str, contains: bool) -> Cow<'_, str> {
470    if contains {
471        Cow::Owned(text.replace(MULTI_CHAR_LOWERCASE, MULTI_CHAR_LOWERCASE_EXPANSION))
472    } else {
473        Cow::Borrowed(text)
474    }
475}
476
477/// Upper bound on the character comparisons a naive case-folded substring scan may do
478/// before [`find_folded_index`] takes over.
479///
480/// The naive scan is `O(text * pattern)`: a query of 20,000 `a`s against a candidate of
481/// 40,000 `a`s used to cost hundreds of milliseconds per record. Capping its work keeps
482/// short candidates on the cheap path while making the worst case linear.
483const NAIVE_FOLDED_SCAN_BUDGET: usize = 4096;
484
485/// Returns whether a naive case-folded scan of these lengths stays inside the budget.
486fn naive_folded_scan_affordable(text_len: usize, pattern_len: usize) -> bool {
487    (text_len.saturating_sub(pattern_len) + 1).saturating_mul(pattern_len)
488        <= NAIVE_FOLDED_SCAN_BUDGET
489}
490
491thread_local! {
492    /// Buffer holding a folded text followed by a folded pattern, reused across calls so
493    /// that [`find_folded_index`] allocates once per thread rather than once per call.
494    static FOLD_SCRATCH: std::cell::Cell<String> = const { std::cell::Cell::new(String::new()) };
495}
496
497/// Largest scratch buffer kept between calls; one huge candidate must not pin memory.
498const FOLD_SCRATCH_RETAINED_BYTES: usize = 64 * 1024;
499
500/// Returns the index of the first occurrence of `pattern` in `text`, where both iterators
501/// yield already-folded characters.
502///
503/// Copies both sides into a reusable buffer and defers to [`str::find`], whose two-way
504/// search is linear in both lengths. Because [`fold_char`] maps one character to exactly
505/// one character, the returned index counts characters of the caller's unfolded text.
506fn find_folded_index(
507    text: impl Iterator<Item = char>,
508    pattern: impl Iterator<Item = char>,
509) -> Option<usize> {
510    let mut scratch = FOLD_SCRATCH.take();
511    scratch.clear();
512    scratch.extend(text);
513    let split = scratch.len();
514    scratch.extend(pattern);
515
516    let (text, pattern) = scratch.split_at(split);
517    let found = text
518        .find(pattern)
519        .map(|offset| text[..offset].chars().count());
520
521    if scratch.capacity() > FOLD_SCRATCH_RETAINED_BYTES {
522        scratch.shrink_to(FOLD_SCRATCH_RETAINED_BYTES);
523    }
524    FOLD_SCRATCH.set(scratch);
525    found
526}
527
528/// Returns [`BONUS_CASE_EXACT`] when a completed case-insensitive match spelled every
529/// matched character the way the query spelled it.
530///
531/// `CASE_SENSITIVE` matches are exact by construction, so they collect nothing and their
532/// scores stay exactly what they were.
533fn case_exact_bonus<const CASE_SENSITIVE: bool>(case_exact: bool) -> i64 {
534    if !CASE_SENSITIVE && case_exact {
535        BONUS_CASE_EXACT
536    } else {
537        0
538    }
539}
540
541/// Folds one ASCII byte for comparison, leaving it as written when case matters.
542fn fold_ascii<const CASE_SENSITIVE: bool>(byte: u8) -> u8 {
543    if CASE_SENSITIVE {
544        byte
545    } else {
546        byte.to_ascii_lowercase()
547    }
548}
549
550fn score_unicode_text<const CASE_SENSITIVE: bool>(pattern: &str, text: &str) -> Option<i64> {
551    score_unicode_text_with::<CASE_SENSITIVE, true>(pattern, text)
552}
553
554/// Scores a pair [`retry_with_rewritten_multi_char_lowercase`] rewrote, which is
555/// [`score_unicode_text`] minus any [`BONUS_CASE_EXACT`].
556///
557/// The rewrite makes one spelling of [`MULTI_CHAR_LOWERCASE`] look like the other, so a
558/// character can compare equal *as written* in the copies while the candidate is not spelled
559/// the way the query was typed at all. The bonus states that spelling and nothing else, so a
560/// match reached through a rewrite forfeits it.
561fn score_rewritten_unicode_text(pattern: &str, text: &str) -> Option<i64> {
562    score_unicode_text_with::<false, false>(pattern, text)
563}
564
565fn score_unicode_text_with<const CASE_SENSITIVE: bool, const CASE_EXACT_ALLOWED: bool>(
566    pattern: &str,
567    text: &str,
568) -> Option<i64> {
569    let pattern_chars: Vec<char> = pattern.chars().collect();
570    let text_chars: Vec<char> = text.chars().collect();
571    let compact_score = compact_char_match_score::<CASE_SENSITIVE, CASE_EXACT_ALLOWED>(
572        &pattern_chars,
573        &text_chars,
574    )?;
575
576    let exact_bonus = if CASE_SENSITIVE {
577        whole_text_bonus(pattern, text)
578    } else {
579        folded_whole_text_bonus(&pattern_chars, &text_chars)
580    };
581
582    Some(exact_bonus + compact_score)
583}
584
585/// Returns the identical, prefix, and substring bonus for an as-written comparison.
586fn whole_text_bonus(pattern: &str, text: &str) -> i64 {
587    if pattern == text {
588        10_000
589    } else if text.starts_with(pattern) {
590        8_000
591    } else if text.contains(pattern) {
592        6_000
593    } else {
594        0
595    }
596}
597
598/// Returns the identical, prefix, and substring bonus for a case-folded comparison.
599fn folded_whole_text_bonus(pattern: &[char], text: &[char]) -> i64 {
600    if folded_chars_eq(pattern, text) {
601        10_000
602    } else if text.len() >= pattern.len() && folded_chars_eq(pattern, &text[..pattern.len()]) {
603        8_000
604    } else if folded_chars_contain(text, pattern) {
605        6_000
606    } else {
607        0
608    }
609}
610
611fn folded_chars_eq(left: &[char], right: &[char]) -> bool {
612    left.len() == right.len()
613        && left
614            .iter()
615            .zip(right)
616            .all(|(left, right)| fold_char::<false>(*left) == fold_char::<false>(*right))
617}
618
619fn folded_chars_contain(text: &[char], pattern: &[char]) -> bool {
620    let Some(last_start) = text.len().checked_sub(pattern.len()) else {
621        return false;
622    };
623
624    if !naive_folded_scan_affordable(text.len(), pattern.len()) {
625        return find_folded_index(
626            text.iter().copied().map(fold_char::<false>),
627            pattern.iter().copied().map(fold_char::<false>),
628        )
629        .is_some();
630    }
631
632    (0..=last_start).any(|start| folded_chars_eq(pattern, &text[start..start + pattern.len()]))
633}
634
635fn compact_char_match_score<const CASE_SENSITIVE: bool, const CASE_EXACT_ALLOWED: bool>(
636    pattern: &[char],
637    text: &[char],
638) -> Option<i64> {
639    if pattern.is_empty() {
640        return Some(0);
641    }
642    if pattern.len() > text.len() {
643        return None;
644    }
645
646    let mut pattern_index = 0usize;
647    let mut wanted = fold_char::<CASE_SENSITIVE>(pattern[0]);
648    let mut end = None;
649    for (text_index, &text_ch) in text.iter().enumerate() {
650        if fold_char::<CASE_SENSITIVE>(text_ch) == wanted {
651            pattern_index += 1;
652            if pattern_index == pattern.len() {
653                end = Some(text_index);
654                break;
655            }
656            wanted = fold_char::<CASE_SENSITIVE>(pattern[pattern_index]);
657        }
658    }
659
660    let mut text_index = end?;
661    let mut score = 1000;
662    let mut right_match: Option<usize> = None;
663    let mut first = 0usize;
664    let mut case_exact = true;
665    for pattern_index in (0..pattern.len()).rev() {
666        let wanted = fold_char::<CASE_SENSITIVE>(pattern[pattern_index]);
667        while fold_char::<CASE_SENSITIVE>(text[text_index]) != wanted {
668            if text_index == 0 {
669                return None;
670            }
671            text_index -= 1;
672        }
673        let position = text_index;
674        first = position;
675        if !CASE_SENSITIVE && text[position] != pattern[pattern_index] {
676            case_exact = false;
677        }
678
679        score += SCORE_MATCH;
680        let bonus = char_bonus_at(text, position);
681        if pattern_index == 0 {
682            score += bonus * BONUS_FIRST_CHAR_MULTIPLIER;
683        } else {
684            score += bonus;
685        }
686
687        if let Some(right_match) = right_match {
688            if right_match == position + 1 {
689                score += BONUS_CONSECUTIVE;
690            } else {
691                let gap = right_match.saturating_sub(position + 1) as i64;
692                score += SCORE_GAP_START + SCORE_GAP_EXTENSION * gap.saturating_sub(1);
693            }
694        }
695        right_match = Some(position);
696
697        if pattern_index > 0 {
698            if text_index == 0 {
699                return None;
700            }
701            text_index -= 1;
702        }
703    }
704
705    Some(
706        score + case_exact_bonus::<CASE_SENSITIVE>(CASE_EXACT_ALLOWED && case_exact)
707            - first as i64 * START_POSITION_PENALTY
708            - text.len() as i64 / TEXT_LENGTH_PENALTY_DIVISOR,
709    )
710}
711
712fn char_bonus_at(text: &[char], position: usize) -> i64 {
713    if position == 0 {
714        return BONUS_BOUNDARY_WHITE;
715    }
716
717    let previous = text[position - 1];
718    let current = text[position];
719    if previous.is_whitespace() {
720        BONUS_BOUNDARY_WHITE
721    } else if is_path_or_field_delimiter(previous) {
722        BONUS_BOUNDARY_DELIMITER
723    } else if !previous.is_alphanumeric() {
724        BONUS_BOUNDARY
725    } else if previous.is_lowercase() && current.is_uppercase()
726        || !previous.is_numeric() && current.is_numeric()
727    {
728        BONUS_CAMEL_OR_NUMBER
729    } else {
730        0
731    }
732}
733
734/// Finds character positions suitable for highlighting a matched pattern.
735pub fn match_positions(pattern: &str, text: &str, case_sensitive: bool) -> Option<MatchPositions> {
736    if pattern.is_empty() {
737        return Some(MatchPositions {
738            char_indices: Vec::new(),
739        });
740    }
741
742    let pattern = comparable_chars(pattern, case_sensitive);
743    let text_comparable = comparable_indexed_chars(text, case_sensitive);
744    let text_chars: Vec<char> = text.chars().collect();
745    contiguous_text_positions(&pattern, &text_comparable)
746        .or_else(|| best_subsequence_positions(&pattern, &text_comparable, &text_chars))
747        .map(|char_indices| MatchPositions { char_indices })
748}
749
750fn comparable_chars(text: &str, case_sensitive: bool) -> Vec<char> {
751    comparable_indexed_chars(text, case_sensitive)
752        .into_iter()
753        .map(|(_, ch)| ch)
754        .collect()
755}
756
757fn comparable_indexed_chars(text: &str, case_sensitive: bool) -> Vec<(usize, char)> {
758    let mut out = Vec::new();
759    for (char_index, ch) in text.chars().enumerate() {
760        for normalized in std::iter::once(ch).nfkc() {
761            if case_sensitive {
762                out.push((char_index, comparable_char(normalized)));
763            } else {
764                out.extend(
765                    normalized
766                        .to_lowercase()
767                        .map(|lower| (char_index, comparable_char(lower))),
768                );
769            }
770        }
771    }
772    out
773}
774
775fn comparable_char(ch: char) -> char {
776    let folded = crate::normalize::fold_width_compatible_char(ch);
777    if folded != ch {
778        folded
779    } else if ('ァ'..='ヶ').contains(&ch) {
780        char::from_u32(ch as u32 - 0x60).unwrap_or(ch)
781    } else {
782        ch
783    }
784}
785
786#[derive(Clone, Debug, Eq, PartialEq)]
787struct PositionCandidate {
788    score: i64,
789    positions: Vec<usize>,
790}
791
792fn best_subsequence_positions(
793    pattern: &[char],
794    text_comparable: &[(usize, char)],
795    text_chars: &[char],
796) -> Option<Vec<usize>> {
797    if pattern.len() > text_comparable.len() {
798        return None;
799    }
800
801    let mut states = Vec::new();
802    for &(text_index, text_ch) in text_comparable {
803        if pattern.first() == Some(&text_ch) {
804            states.push(Some(PositionCandidate {
805                score: match_position_score(text_chars, text_index) - text_index as i64 * 2,
806                positions: vec![text_index],
807            }));
808        } else {
809            states.push(None);
810        }
811    }
812
813    for &pattern_ch in &pattern[1..] {
814        let mut next_states = vec![None; text_comparable.len()];
815        for (text_offset, &(text_index, text_ch)) in text_comparable.iter().enumerate() {
816            if text_ch != pattern_ch {
817                continue;
818            }
819
820            let mut best = None;
821            for previous in states[..text_offset].iter().flatten() {
822                let Some(&previous_index) = previous.positions.last() else {
823                    continue;
824                };
825                if previous_index >= text_index {
826                    continue;
827                }
828
829                let mut positions = previous.positions.clone();
830                positions.push(text_index);
831                let gap = text_index.saturating_sub(previous_index + 1) as i64;
832                let consecutive_bonus = if text_index == previous_index + 1 {
833                    160
834                } else {
835                    0
836                };
837                let score = previous.score
838                    + match_position_score(text_chars, text_index)
839                    + consecutive_bonus
840                    - gap * 4;
841                let candidate = PositionCandidate { score, positions };
842                if best
843                    .as_ref()
844                    .is_none_or(|current| better_position_candidate(&candidate, current))
845                {
846                    best = Some(candidate);
847                }
848            }
849
850            next_states[text_offset] = best;
851        }
852
853        states = next_states;
854    }
855
856    states
857        .into_iter()
858        .flatten()
859        .max_by(compare_position_candidate)
860        .map(|candidate| candidate.positions)
861}
862
863fn match_position_score(text_chars: &[char], position: usize) -> i64 {
864    let boundary_bonus = if is_boundary(text_chars, position) {
865        90
866    } else {
867        0
868    };
869    100 + boundary_bonus
870}
871
872fn better_position_candidate(left: &PositionCandidate, right: &PositionCandidate) -> bool {
873    compare_position_candidate(left, right).is_gt()
874}
875
876fn compare_position_candidate(
877    left: &PositionCandidate,
878    right: &PositionCandidate,
879) -> std::cmp::Ordering {
880    left.score
881        .cmp(&right.score)
882        .then_with(|| span_len(right).cmp(&span_len(left)))
883        .then_with(|| right.positions.cmp(&left.positions))
884}
885
886fn span_len(candidate: &PositionCandidate) -> usize {
887    match (candidate.positions.first(), candidate.positions.last()) {
888        (Some(first), Some(last)) => last - first + 1,
889        _ => 0,
890    }
891}
892
893fn contiguous_text_positions(
894    pattern: &[char],
895    text_comparable: &[(usize, char)],
896) -> Option<Vec<usize>> {
897    if pattern.len() > text_comparable.len() {
898        return None;
899    }
900
901    text_comparable
902        .windows(pattern.len())
903        .find(|window| window.iter().map(|(_, ch)| ch).eq(pattern.iter()))
904        .map(|window| window.iter().map(|(index, _)| *index).collect())
905}
906
907fn score_ascii_text<const CASE_SENSITIVE: bool>(pattern: &str, text: &str) -> Option<i64> {
908    let pattern_bytes = pattern.as_bytes();
909    let text_bytes = text.as_bytes();
910
911    let compact_score = compact_ascii_match_score::<CASE_SENSITIVE>(pattern_bytes, text_bytes)?;
912
913    let exact_bonus = if CASE_SENSITIVE {
914        whole_text_bonus(pattern, text)
915    } else {
916        folded_ascii_whole_text_bonus(pattern_bytes, text_bytes)
917    };
918
919    Some(exact_bonus + compact_score)
920}
921
922/// Returns the identical, prefix, and substring bonus for a case-folded ASCII comparison.
923fn folded_ascii_whole_text_bonus(pattern: &[u8], text: &[u8]) -> i64 {
924    if text.eq_ignore_ascii_case(pattern) {
925        10_000
926    } else if text.len() >= pattern.len() && text[..pattern.len()].eq_ignore_ascii_case(pattern) {
927        8_000
928    } else if find_ascii_ignore_case(text, pattern).is_some() {
929        6_000
930    } else {
931        0
932    }
933}
934
935/// Returns the byte offset of the first case-folded occurrence of `pattern` in `text`.
936///
937/// Both slices must be ASCII, so a byte offset is also a character index.
938fn find_ascii_ignore_case(text: &[u8], pattern: &[u8]) -> Option<usize> {
939    debug_assert!(text.is_ascii() && pattern.is_ascii());
940    let Some((&first, rest)) = pattern.split_first() else {
941        return Some(0);
942    };
943    let first = first.to_ascii_lowercase();
944    let last_start = text.len().checked_sub(pattern.len())?;
945
946    if rest.is_empty() {
947        return text
948            .iter()
949            .position(|byte| byte.to_ascii_lowercase() == first);
950    }
951
952    if !naive_folded_scan_affordable(text.len(), pattern.len()) {
953        return find_folded_index(
954            text.iter()
955                .map(|byte| char::from(byte.to_ascii_lowercase())),
956            pattern
957                .iter()
958                .map(|byte| char::from(byte.to_ascii_lowercase())),
959        );
960    }
961
962    (0..=last_start).find(|&start| {
963        text[start].to_ascii_lowercase() == first
964            && text[start + 1..start + pattern.len()].eq_ignore_ascii_case(rest)
965    })
966}
967
968fn compact_ascii_match_score<const CASE_SENSITIVE: bool>(
969    pattern: &[u8],
970    text: &[u8],
971) -> Option<i64> {
972    if pattern.is_empty() {
973        return Some(0);
974    }
975    if pattern.len() > text.len() {
976        return None;
977    }
978
979    let mut pattern_index = 0usize;
980    let mut wanted = fold_ascii::<CASE_SENSITIVE>(pattern[0]);
981    let mut end = None;
982    for (text_index, &text_byte) in text.iter().enumerate() {
983        if fold_ascii::<CASE_SENSITIVE>(text_byte) == wanted {
984            pattern_index += 1;
985            if pattern_index == pattern.len() {
986                end = Some(text_index);
987                break;
988            }
989            wanted = fold_ascii::<CASE_SENSITIVE>(pattern[pattern_index]);
990        }
991    }
992
993    let mut text_index = end?;
994    let mut score = 1000;
995    let mut right_match: Option<usize> = None;
996    let mut first = 0usize;
997    let mut case_exact = true;
998    for pattern_index in (0..pattern.len()).rev() {
999        let wanted = fold_ascii::<CASE_SENSITIVE>(pattern[pattern_index]);
1000        while fold_ascii::<CASE_SENSITIVE>(text[text_index]) != wanted {
1001            if text_index == 0 {
1002                return None;
1003            }
1004            text_index -= 1;
1005        }
1006        let position = text_index;
1007        first = position;
1008        if !CASE_SENSITIVE && text[position] != pattern[pattern_index] {
1009            case_exact = false;
1010        }
1011
1012        score += SCORE_MATCH;
1013        let bonus = ascii_bonus_at(text, position);
1014        if pattern_index == 0 {
1015            score += bonus * BONUS_FIRST_CHAR_MULTIPLIER;
1016        } else {
1017            score += bonus;
1018        }
1019
1020        if let Some(right_match) = right_match {
1021            if right_match == position + 1 {
1022                score += BONUS_CONSECUTIVE;
1023            } else {
1024                let gap = right_match.saturating_sub(position + 1) as i64;
1025                score += SCORE_GAP_START + SCORE_GAP_EXTENSION * gap.saturating_sub(1);
1026            }
1027        }
1028        right_match = Some(position);
1029
1030        if pattern_index > 0 {
1031            if text_index == 0 {
1032                return None;
1033            }
1034            text_index -= 1;
1035        }
1036    }
1037
1038    Some(
1039        score + case_exact_bonus::<CASE_SENSITIVE>(case_exact)
1040            - first as i64 * START_POSITION_PENALTY
1041            - text.len() as i64 / TEXT_LENGTH_PENALTY_DIVISOR,
1042    )
1043}
1044
1045fn ascii_bonus_at(text: &[u8], position: usize) -> i64 {
1046    if position == 0 {
1047        return BONUS_BOUNDARY_WHITE;
1048    }
1049
1050    let previous = text[position - 1];
1051    let current = text[position];
1052    if previous.is_ascii_whitespace() {
1053        BONUS_BOUNDARY_WHITE
1054    } else if matches!(previous, b'/' | b'\\' | b',' | b':' | b';' | b'|') {
1055        BONUS_BOUNDARY_DELIMITER
1056    } else if !previous.is_ascii_alphanumeric() {
1057        BONUS_BOUNDARY
1058    } else if previous.is_ascii_lowercase() && current.is_ascii_uppercase()
1059        || !previous.is_ascii_digit() && current.is_ascii_digit()
1060    {
1061        BONUS_CAMEL_OR_NUMBER
1062    } else {
1063        0
1064    }
1065}
1066
1067/// Scores an exact substring match between `pattern` and `text`.
1068///
1069/// With `case_sensitive` false the substring search itself is case-folded, and an occurrence
1070/// spelled exactly like the pattern collects [`BONUS_CASE_EXACT`].
1071pub fn score_exact_text(pattern: &str, text: &str, case_sensitive: bool) -> Option<i64> {
1072    if let Some(score) = score_exact_folded_text(pattern, text, case_sensitive) {
1073        return Some(score);
1074    }
1075    if case_sensitive {
1076        return None;
1077    }
1078
1079    retry_with_rewritten_multi_char_lowercase(pattern, text, score_rewritten_exact_text)
1080}
1081
1082/// Scores a pair [`retry_with_rewritten_multi_char_lowercase`] rewrote, which is
1083/// [`score_exact_folded_text`] minus any [`BONUS_CASE_EXACT`] - see
1084/// [`score_rewritten_unicode_text`] for why the bonus cannot survive a rewrite.
1085fn score_rewritten_exact_text(pattern: &str, text: &str) -> Option<i64> {
1086    score_exact_folded_text_with(pattern, text, false, false)
1087}
1088
1089/// Scores an exact substring match comparing both sides as written, up to a 1:1 case fold.
1090fn score_exact_folded_text(pattern: &str, text: &str, case_sensitive: bool) -> Option<i64> {
1091    score_exact_folded_text_with(pattern, text, case_sensitive, true)
1092}
1093
1094fn score_exact_folded_text_with(
1095    pattern: &str,
1096    text: &str,
1097    case_sensitive: bool,
1098    case_exact_allowed: bool,
1099) -> Option<i64> {
1100    if pattern.is_empty() {
1101        return Some(0);
1102    }
1103
1104    // A folded hit that still starts with the pattern's own bytes matched nothing by folding,
1105    // so it is the occurrence the user spelled. Case-sensitive hits are all of that kind and
1106    // collect nothing, keeping their scores exactly what they were.
1107    let (start, case_bonus) = if case_sensitive {
1108        (text.find(pattern)?, 0)
1109    } else {
1110        let start = find_ignore_case(text, pattern)?;
1111        (
1112            start,
1113            case_exact_bonus::<false>(case_exact_allowed && text[start..].starts_with(pattern)),
1114        )
1115    };
1116    // Only a match at the very start can cover the whole text.
1117    let whole_text = start == 0
1118        && if case_sensitive {
1119            pattern == text
1120        } else {
1121            eq_ignore_case(pattern, text)
1122        };
1123
1124    let exact_bonus = if whole_text {
1125        10_000
1126    } else if start == 0 {
1127        8_000
1128    } else {
1129        6_000
1130    };
1131    Some(1000 + exact_bonus + case_bonus - start as i64 * 5 - text.chars().count() as i64)
1132}
1133
1134/// Returns the byte offset of the first case-folded occurrence of `pattern` in `text`.
1135fn find_ignore_case(text: &str, pattern: &str) -> Option<usize> {
1136    if text.is_ascii() && pattern.is_ascii() {
1137        return find_ascii_ignore_case(text.as_bytes(), pattern.as_bytes());
1138    }
1139
1140    if !naive_folded_scan_affordable(text.len(), pattern.len()) {
1141        let char_index = find_folded_index(
1142            text.chars().map(fold_char::<false>),
1143            pattern.chars().map(fold_char::<false>),
1144        )?;
1145        return text
1146            .char_indices()
1147            .nth(char_index)
1148            .map(|(offset, _)| offset);
1149    }
1150
1151    let first = fold_char::<false>(pattern.chars().next()?);
1152    text.char_indices()
1153        .filter(|&(_, ch)| fold_char::<false>(ch) == first)
1154        .map(|(index, _)| index)
1155        .find(|&index| starts_with_ignore_case(&text[index..], pattern))
1156}
1157
1158fn starts_with_ignore_case(text: &str, pattern: &str) -> bool {
1159    let mut text_chars = text.chars();
1160    pattern.chars().all(|expected| {
1161        text_chars.next().map(fold_char::<false>) == Some(fold_char::<false>(expected))
1162    })
1163}
1164
1165fn eq_ignore_case(left: &str, right: &str) -> bool {
1166    left.chars()
1167        .map(fold_char::<false>)
1168        .eq(right.chars().map(fold_char::<false>))
1169}
1170
1171fn is_boundary(text: &[char], position: usize) -> bool {
1172    position == 0 || matches!(text[position - 1], '/' | '\\' | '_' | '-' | ' ' | '.')
1173}
1174
1175fn is_path_or_field_delimiter(ch: char) -> bool {
1176    matches!(ch, '/' | '\\' | ',' | ':' | ';' | '|')
1177}
1178
1179#[cfg(test)]
1180mod tests;