limb 0.1.0

A focused CLI for git worktree management
Documentation
//! Picker filter backed by `nucleo_matcher`.
//!
//! Wraps `nucleo_matcher` with a pre-allocated UTF-32 scratch buffer so
//! scoring thousands of haystacks per keystroke doesn't allocate.

use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher, Utf32Str};

/// Stateful fuzzy matcher for the picker's `/` filter.
///
/// Holds a pre-parsed [`Pattern`] and a reusable scratch buffer. One
/// instance serves every keystroke without reallocating.
pub struct Fuzzy {
    matcher: Matcher,
    pattern: Pattern,
    query: String,
    buf: Vec<char>,
}

impl Fuzzy {
    /// Creates a matcher with an empty query.
    #[must_use]
    pub fn new() -> Self {
        Self {
            matcher: Matcher::new(Config::DEFAULT.match_paths()),
            pattern: Pattern::parse("", CaseMatching::Smart, Normalization::Smart),
            query: String::new(),
            buf: Vec::new(),
        }
    }

    /// Updates the query; no-op if unchanged.
    ///
    /// Uses smart-case matching (case-insensitive unless the query
    /// contains an uppercase character) and Unicode normalisation.
    pub fn set_query(&mut self, query: &str) {
        if self.query == query {
            return;
        }
        self.pattern
            .reparse(query, CaseMatching::Smart, Normalization::Smart);
        self.query = query.to_string();
    }

    /// Returns the current query string.
    #[must_use]
    pub fn query(&self) -> &str {
        &self.query
    }

    /// Scores `haystack` against the current query.
    ///
    /// Returns `Some(0)` for the empty query (matches everything), `None`
    /// if the haystack does not match, else `Some(score)` where higher is
    /// a better match.
    ///
    /// # Examples
    ///
    /// ```
    /// use limb::fuzzy::Fuzzy;
    ///
    /// let mut f = Fuzzy::new();
    /// f.set_query("auth");
    /// assert!(f.score("feat-auth").is_some());
    /// assert!(f.score("zzz").is_none());
    /// ```
    pub fn score(&mut self, haystack: &str) -> Option<u32> {
        if self.query.is_empty() {
            return Some(0);
        }
        let needle = Utf32Str::new(haystack, &mut self.buf);
        self.pattern.score(needle, &mut self.matcher)
    }
}

impl Default for Fuzzy {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_query_matches_everything_with_zero_score() {
        let mut f = Fuzzy::new();
        assert_eq!(f.score("anything"), Some(0));
    }

    #[test]
    fn substring_matches() {
        let mut f = Fuzzy::new();
        f.set_query("feat");
        assert!(f.score("feat-auth").is_some());
        assert!(f.score("x-feat-y").is_some());
    }

    #[test]
    fn nonmatching_query_returns_none() {
        let mut f = Fuzzy::new();
        f.set_query("zzz");
        assert!(f.score("hello").is_none());
    }

    #[test]
    fn case_insensitive_smart() {
        let mut f = Fuzzy::new();
        f.set_query("feat");
        let lower = f.score("feat-auth");
        let upper = f.score("FEAT-AUTH");
        assert!(lower.is_some() && upper.is_some());
    }

    #[test]
    fn case_sensitive_when_uppercase_in_query() {
        let mut f = Fuzzy::new();
        f.set_query("FEAT");
        assert!(f.score("FEAT-AUTH").is_some());
        assert!(f.score("feat-auth").is_none());
    }

    #[test]
    fn exact_match_scores_higher_than_fuzzy() {
        let mut f = Fuzzy::new();
        f.set_query("auth");
        let exact = f.score("auth").unwrap();
        let fuzzy = f.score("a-u-t-h").unwrap();
        assert!(
            exact > fuzzy,
            "exact ({exact}) should beat scattered ({fuzzy})"
        );
    }

    #[test]
    fn buffer_is_reused_across_calls() {
        let mut f = Fuzzy::new();
        f.set_query("ö");
        let _ = f.score("öbar");
        let _ = f.score("öbaz");
        let _ = f.score("unrelated");
        assert!(!f.buf.is_empty());
    }
}

#[cfg(test)]
mod proptests {
    use super::*;
    use proptest::prelude::*;

    proptest! {
        #[test]
        fn shorter_query_matches_when_longer_does(
            haystack in "[a-zA-Z0-9_-]{1,32}",
            full in "[a-zA-Z0-9_-]{1,6}",
        ) {
            let mut f = Fuzzy::new();
            f.set_query(&full);
            if f.score(&haystack).is_some() {
                for end in 1..full.len() {
                    let prefix = &full[..end];
                    f.set_query(prefix);
                    prop_assert!(
                        f.score(&haystack).is_some(),
                        "prefix {:?} should match {:?} when full {:?} matches",
                        prefix, haystack, full,
                    );
                }
            }
        }
    }
}