@@ -4,41 +4,209 @@ use fuzzy_matcher::FuzzyMatcher;
4#[cfg(test)]
4#[cfg(test)]
14+ fn new(atom: &str) -> Option<QueryAtom> {
15+ let mut atom = atom.to_string();
16+ let inverse = atom.starts_with('!');
21+ let mut kind = match atom.chars().next() {
22+ Some('^') => QueryAtomKind::Prefix,
23+ Some('\'') => QueryAtomKind::Substring,
24+ _ if inverse => QueryAtomKind::Substring,
25+ _ => QueryAtomKind::Fuzzy,
28+ if atom.starts_with(&['^', '\'']) {
36+ if atom.ends_with('$') && !atom.ends_with("\\$") {
38+ kind = if kind == QueryAtomKind::Prefix {
41+ QueryAtomKind::Postfix
47+ atom: atom.replace('\\', ""),
48+ // not ideal but fuzzy_matches only knows ascii uppercase so more consistent
49+ // to behave the same
50+ ignore_case: kind != QueryAtomKind::Fuzzy
51+ && atom.chars().all(|c| c.is_ascii_lowercase()),
56+ fn indices(&self, matcher: &Matcher, item: &str, indices: &mut Vec<usize>) -> bool {
57+ // for inverse there are no indicies to return
58+ // just return whether we matched
60+ return self.matches(matcher, item);
63+ let item = if self.ignore_case {
64+ buf = item.to_ascii_lowercase();
69+ let off = match self.kind {
70+ QueryAtomKind::Fuzzy => {
71+ if let Some((_, fuzzy_indices)) = matcher.fuzzy_indices(item, &self.atom) {
72+ indices.extend_from_slice(&fuzzy_indices);
78+ QueryAtomKind::Substring => {
79+ if let Some(off) = item.find(&self.atom) {
85+ QueryAtomKind::Prefix if item.starts_with(&self.atom) => 0,
86+ QueryAtomKind::Postfix if item.ends_with(&self.atom) => item.len() - self.atom.len(),
87+ QueryAtomKind::Exact if item == self.atom => 0,
91+ indices.extend(off..(off + self.atom.len()));
95+ fn matches(&self, matcher: &Matcher, item: &str) -> bool {
97+ let item = if self.ignore_case {
98+ buf = item.to_ascii_lowercase();
103+ let mut res = match self.kind {
104+ QueryAtomKind::Fuzzy => matcher.fuzzy_match(item, &self.atom).is_some(),
105+ QueryAtomKind::Substring => item.contains(&self.atom),
106+ QueryAtomKind::Prefix => item.starts_with(&self.atom),
107+ QueryAtomKind::Postfix => item.ends_with(&self.atom),
108+ QueryAtomKind::Exact => item == self.atom,
117+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
119+ /// Item is a fuzzy match of this behaviour
123+ /// Item contains query atom as a continous substring
127+ /// Item starts with query atom
131+ /// Item ends with query atom
135+ /// Item is equal to query atom
7pub struct FuzzyQuery {
142pub struct FuzzyQuery {
8− queries: Vec<String>,
143+ first_fuzzy_atom: Option<String>,
144+ query_atoms: Vec<QueryAtom>,
147+fn query_atoms(query: &str) -> impl Iterator<Item = &str> + '_ {
148+ let mut saw_backslash = false;
149+ query.split(move |c| {
150+ saw_backslash = match c {
151+ ' ' if !saw_backslash => return true,
11impl FuzzyQuery {
159impl FuzzyQuery {
160+ pub fn refine(&self, query: &str, old_query: &str) -> (FuzzyQuery, bool) {
161+ // TODO: we could be a lot smarter about this
162+ let new_query = Self::new(query);
163+ let mut is_refinement = query.starts_with(old_query);
165+ // if the last atom is an inverse atom adding more text to it
166+ // will actually increase the number of matches and we can not refine
168+ if is_refinement && !self.query_atoms.is_empty() {
169+ let last_idx = self.query_atoms.len() - 1;
170+ if self.query_atoms[last_idx].inverse
171+ && self.query_atoms[last_idx].atom != new_query.query_atoms[last_idx].atom
173+ is_refinement = false;
177+ (new_query, is_refinement)
12 pub fn new(query: &str) -> FuzzyQuery {
180 pub fn new(query: &str) -> FuzzyQuery {
13− let mut saw_backslash = false;
181+ let mut first_fuzzy_query = None;
14− let queries = query
182+ let query_atoms = query_atoms(query)
16− saw_backslash = match c {
17− ' ' if !saw_backslash => return true,
23− .filter_map(|query| {
183+ .filter_map(|atom| {
24− if query.is_empty() {
184+ let atom = QueryAtom::new(atom)?;
185+ if atom.kind == QueryAtomKind::Fuzzy && first_fuzzy_query.is_none() {
186+ first_fuzzy_query = Some(atom.atom);
27− Some(query.replace("\\ ", " "))
189+ Some(atom)
30 .collect();
192 .collect();
31− FuzzyQuery { queries }
193+ FuzzyQuery {
194+ first_fuzzy_atom: first_fuzzy_query,
34 pub fn fuzzy_match(&self, item: &str, matcher: &Matcher) -> Option<i64> {
199 pub fn fuzzy_match(&self, item: &str, matcher: &Matcher) -> Option<i64> {
35− // use the rank of the first query for the rank, because merging ranks is not really possible
200+ // use the rank of the first fuzzzy query for the rank, because merging ranks is not really possible
36 // this behaviour matches fzf and skim
201 // this behaviour matches fzf and skim
37− let score = matcher.fuzzy_match(item, self.queries.get(0)?)?;
205+ .map_or(Some(0), |atom| matcher.fuzzy_match(item, atom))?;
39− .queries
207+ .query_atoms
41− .any(|query| matcher.fuzzy_match(item, query).is_none())
209+ .any(|atom| !atom.matches(matcher, item))
43 return None;
211 return None;
@@ -46,29 +214,26 @@ impl FuzzyQuery {
48 pub fn fuzzy_indicies(&self, item: &str, matcher: &Matcher) -> Option<(i64, Vec<usize>)> {
216 pub fn fuzzy_indicies(&self, item: &str, matcher: &Matcher) -> Option<(i64, Vec<usize>)> {
49− if self.queries.len() == 1 {
217+ let (score, mut indices) = self.first_fuzzy_atom.as_ref().map_or_else(
50− return matcher.fuzzy_indices(item, &self.queries[0]);
218+ || Some((0, Vec::new())),
53− // use the rank of the first query for the rank, because merging ranks is not really possible
54− // this behaviour matches fzf and skim
55− let (score, mut indicies) = matcher.fuzzy_indices(item, self.queries.get(0)?)?;
219+ |atom| matcher.fuzzy_indices(item, atom),
57− // fast path for the common case of not using a space
222+ // fast path for the common case of just a single atom
58− // during matching this branch should be free thanks to branch prediction
223+ if self.query_atoms.is_empty() {
59− if self.queries.len() == 1 {
60− return Some((score, indicies));
224+ return Some((score, indices));
63− for query in &self.queries[1..] {
227+ for atom in &self.query_atoms {
64− let (_, matched_indicies) = matcher.fuzzy_indices(item, query)?;
228+ if !atom.indices(matcher, item, &mut indices) {
65− indicies.extend_from_slice(&matched_indicies);
229+ return None;
68 // deadup and remove duplicate matches
233 // deadup and remove duplicate matches
69− indicies.sort_unstable();
234+ indices.sort_unstable();
70− indicies.dedup();
235+ indices.dedup();
72− Some((score, indicies))
237+ Some((score, indices))