skim 5.0.0

Fuzzy Finder in rust!
Documentation
//! Matcher using <https://crates.io/crates/frizbee>
use frizbee::Scoring;
use frizbee::smith_waterman::SmithWatermanMatcher;

use crate::CaseMatching;
use crate::fuzzy_matcher::{FuzzyMatcher, MatchIndices, ScoreType};

const RESPECT_CASE_BONUS: u16 = 10000;

/// Matcher using frizbee,
/// the same one that `blink.cmp` uses in neovim
/// credits to @saghen
#[derive(Default)]
pub struct FrizbeeMatcher {
    case: CaseMatching,
    max_typos: Option<u16>,
}

impl FrizbeeMatcher {
    /// Set the max typos to use
    #[must_use]
    pub fn max_typos(mut self, typos: Option<usize>) -> Self {
        self.max_typos = Some(typos.map_or(0, |x| u16::try_from(x).unwrap_or(u16::MAX)));
        self
    }
    /// Set the case, will be converted to a `matching_case_bonus`
    #[must_use]
    pub fn case(mut self, case: CaseMatching) -> Self {
        self.case = case;
        self
    }
}

impl FuzzyMatcher for FrizbeeMatcher {
    fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(ScoreType, MatchIndices)> {
        let scoring = Scoring {
            matching_case_bonus: match self.case {
                CaseMatching::Respect => RESPECT_CASE_BONUS,
                CaseMatching::Ignore => 0,
                CaseMatching::Smart => {
                    if pattern.chars().any(char::is_uppercase) {
                        RESPECT_CASE_BONUS
                    } else {
                        0
                    }
                }
            },
            ..Default::default()
        };
        let mut matcher = SmithWatermanMatcher::new(pattern.as_bytes(), &scoring);
        matcher
            .match_haystack_indices(choice.as_bytes(), 0, self.max_typos)
            .and_then(|(m, mut indices)| {
                debug!("{choice}: {m} ({})", scoring.matching_case_bonus);
                if m > scoring.matching_case_bonus.saturating_mul(
                    pattern
                        .chars()
                        .count()
                        .saturating_sub(self.max_typos.unwrap_or(0).into())
                        .try_into()
                        .unwrap(),
                ) {
                    indices.reverse();
                    Some((m.into(), MatchIndices::from(indices)))
                } else {
                    None
                }
            })
    }
    fn fuzzy_match(&self, choice: &str, pattern: &str) -> Option<i64> {
        let scoring = Scoring {
            matching_case_bonus: match self.case {
                CaseMatching::Respect => RESPECT_CASE_BONUS,
                CaseMatching::Ignore => 0,
                CaseMatching::Smart => {
                    if pattern.chars().any(char::is_uppercase) {
                        RESPECT_CASE_BONUS
                    } else {
                        0
                    }
                }
            },
            ..Default::default()
        };
        let mut matcher = SmithWatermanMatcher::new(pattern.as_bytes(), &scoring);
        matcher
            .match_haystack(choice.as_bytes(), self.max_typos)
            .map(ScoreType::from)
    }
}

#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
    use super::*;
    use crate::fuzzy_matcher::FuzzyMatcher;

    #[test]
    fn matches_subsequence() {
        let m = FrizbeeMatcher::default();
        assert!(m.fuzzy_match("foobar", "foo").is_some());
        assert!(m.fuzzy_indices("foobar", "foo").is_some());
    }

    #[test]
    fn respect_case_variant() {
        let m = FrizbeeMatcher::default().case(CaseMatching::Respect);
        assert!(m.fuzzy_indices("FooBar", "Foo").is_some());
    }

    #[test]
    fn smart_case_variant() {
        let m = FrizbeeMatcher::default().case(CaseMatching::Smart);
        // Uppercase pattern triggers the case bonus branch.
        assert!(m.fuzzy_indices("FooBar", "Foo").is_some());
        // Lowercase pattern -> no bonus.
        assert!(m.fuzzy_indices("foobar", "foo").is_some());
    }

    #[test]
    fn ignore_case_variant() {
        let m = FrizbeeMatcher::default().case(CaseMatching::Ignore);
        assert!(m.fuzzy_match("FOOBAR", "foo").is_some());
    }

    #[test]
    fn max_typos_tolerates_mismatch() {
        let m = FrizbeeMatcher::default().max_typos(Some(1));
        assert!(m.fuzzy_match("foobar", "fxo").is_some());
    }

    #[test]
    fn fuzzy_indices_ignore_case() {
        // Ignore case → matching_case_bonus is 0 in fuzzy_indices.
        let m = FrizbeeMatcher::default().case(CaseMatching::Ignore);
        assert!(m.fuzzy_indices("FOOBAR", "foo").is_some());
    }

    #[test]
    fn fuzzy_indices_no_match_returns_none() {
        // A non-subsequence pattern exercises the None branch.
        let m = FrizbeeMatcher::default();
        assert!(m.fuzzy_indices("foobar", "zzz").is_none());
    }

    #[test]
    fn fuzzy_match_respect_and_smart_case() {
        // fuzzy_match (score-only) across the Respect and Smart case arms.
        let respect = FrizbeeMatcher::default().case(CaseMatching::Respect);
        assert!(respect.fuzzy_match("FooBar", "Foo").is_some());

        let smart = FrizbeeMatcher::default().case(CaseMatching::Smart);
        assert!(smart.fuzzy_match("FooBar", "Foo").is_some());
        assert!(smart.fuzzy_match("foobar", "foo").is_some());
    }
}