add substring matching options to picker (#5114)
This commit is contained in:
parent
e31943c4c4
commit
f0c2e898b4
2 changed files with 213 additions and 43 deletions
|
@ -4,41 +4,209 @@ use fuzzy_matcher::FuzzyMatcher;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test;
|
mod test;
|
||||||
|
|
||||||
|
struct QueryAtom {
|
||||||
|
kind: QueryAtomKind,
|
||||||
|
atom: String,
|
||||||
|
ignore_case: bool,
|
||||||
|
inverse: bool,
|
||||||
|
}
|
||||||
|
impl QueryAtom {
|
||||||
|
fn new(atom: &str) -> Option<QueryAtom> {
|
||||||
|
let mut atom = atom.to_string();
|
||||||
|
let inverse = atom.starts_with('!');
|
||||||
|
if inverse {
|
||||||
|
atom.remove(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut kind = match atom.chars().next() {
|
||||||
|
Some('^') => QueryAtomKind::Prefix,
|
||||||
|
Some('\'') => QueryAtomKind::Substring,
|
||||||
|
_ if inverse => QueryAtomKind::Substring,
|
||||||
|
_ => QueryAtomKind::Fuzzy,
|
||||||
|
};
|
||||||
|
|
||||||
|
if atom.starts_with(&['^', '\'']) {
|
||||||
|
atom.remove(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if atom.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if atom.ends_with('$') && !atom.ends_with("\\$") {
|
||||||
|
atom.pop();
|
||||||
|
kind = if kind == QueryAtomKind::Prefix {
|
||||||
|
QueryAtomKind::Exact
|
||||||
|
} else {
|
||||||
|
QueryAtomKind::Postfix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(QueryAtom {
|
||||||
|
kind,
|
||||||
|
atom: atom.replace('\\', ""),
|
||||||
|
// not ideal but fuzzy_matches only knows ascii uppercase so more consistent
|
||||||
|
// to behave the same
|
||||||
|
ignore_case: kind != QueryAtomKind::Fuzzy
|
||||||
|
&& atom.chars().all(|c| c.is_ascii_lowercase()),
|
||||||
|
inverse,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn indices(&self, matcher: &Matcher, item: &str, indices: &mut Vec<usize>) -> bool {
|
||||||
|
// for inverse there are no indicies to return
|
||||||
|
// just return whether we matched
|
||||||
|
if self.inverse {
|
||||||
|
return self.matches(matcher, item);
|
||||||
|
}
|
||||||
|
let buf;
|
||||||
|
let item = if self.ignore_case {
|
||||||
|
buf = item.to_ascii_lowercase();
|
||||||
|
&buf
|
||||||
|
} else {
|
||||||
|
item
|
||||||
|
};
|
||||||
|
let off = match self.kind {
|
||||||
|
QueryAtomKind::Fuzzy => {
|
||||||
|
if let Some((_, fuzzy_indices)) = matcher.fuzzy_indices(item, &self.atom) {
|
||||||
|
indices.extend_from_slice(&fuzzy_indices);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QueryAtomKind::Substring => {
|
||||||
|
if let Some(off) = item.find(&self.atom) {
|
||||||
|
off
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QueryAtomKind::Prefix if item.starts_with(&self.atom) => 0,
|
||||||
|
QueryAtomKind::Postfix if item.ends_with(&self.atom) => item.len() - self.atom.len(),
|
||||||
|
QueryAtomKind::Exact if item == self.atom => 0,
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
indices.extend(off..(off + self.atom.len()));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches(&self, matcher: &Matcher, item: &str) -> bool {
|
||||||
|
let buf;
|
||||||
|
let item = if self.ignore_case {
|
||||||
|
buf = item.to_ascii_lowercase();
|
||||||
|
&buf
|
||||||
|
} else {
|
||||||
|
item
|
||||||
|
};
|
||||||
|
let mut res = match self.kind {
|
||||||
|
QueryAtomKind::Fuzzy => matcher.fuzzy_match(item, &self.atom).is_some(),
|
||||||
|
QueryAtomKind::Substring => item.contains(&self.atom),
|
||||||
|
QueryAtomKind::Prefix => item.starts_with(&self.atom),
|
||||||
|
QueryAtomKind::Postfix => item.ends_with(&self.atom),
|
||||||
|
QueryAtomKind::Exact => item == self.atom,
|
||||||
|
};
|
||||||
|
if self.inverse {
|
||||||
|
res = !res;
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
|
enum QueryAtomKind {
|
||||||
|
/// Item is a fuzzy match of this behaviour
|
||||||
|
///
|
||||||
|
/// Usage: `foo`
|
||||||
|
Fuzzy,
|
||||||
|
/// Item contains query atom as a continous substring
|
||||||
|
///
|
||||||
|
/// Usage `'foo`
|
||||||
|
Substring,
|
||||||
|
/// Item starts with query atom
|
||||||
|
///
|
||||||
|
/// Usage: `^foo`
|
||||||
|
Prefix,
|
||||||
|
/// Item ends with query atom
|
||||||
|
///
|
||||||
|
/// Usage: `foo$`
|
||||||
|
Postfix,
|
||||||
|
/// Item is equal to query atom
|
||||||
|
///
|
||||||
|
/// Usage `^foo$`
|
||||||
|
Exact,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
pub struct FuzzyQuery {
|
pub struct FuzzyQuery {
|
||||||
queries: Vec<String>,
|
first_fuzzy_atom: Option<String>,
|
||||||
|
query_atoms: Vec<QueryAtom>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_atoms(query: &str) -> impl Iterator<Item = &str> + '_ {
|
||||||
|
let mut saw_backslash = false;
|
||||||
|
query.split(move |c| {
|
||||||
|
saw_backslash = match c {
|
||||||
|
' ' if !saw_backslash => return true,
|
||||||
|
'\\' => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FuzzyQuery {
|
impl FuzzyQuery {
|
||||||
|
pub fn refine(&self, query: &str, old_query: &str) -> (FuzzyQuery, bool) {
|
||||||
|
// TODO: we could be a lot smarter about this
|
||||||
|
let new_query = Self::new(query);
|
||||||
|
let mut is_refinement = query.starts_with(old_query);
|
||||||
|
|
||||||
|
// if the last atom is an inverse atom adding more text to it
|
||||||
|
// will actually increase the number of matches and we can not refine
|
||||||
|
// the matches.
|
||||||
|
if is_refinement && !self.query_atoms.is_empty() {
|
||||||
|
let last_idx = self.query_atoms.len() - 1;
|
||||||
|
if self.query_atoms[last_idx].inverse
|
||||||
|
&& self.query_atoms[last_idx].atom != new_query.query_atoms[last_idx].atom
|
||||||
|
{
|
||||||
|
is_refinement = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(new_query, is_refinement)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn new(query: &str) -> FuzzyQuery {
|
pub fn new(query: &str) -> FuzzyQuery {
|
||||||
let mut saw_backslash = false;
|
let mut first_fuzzy_query = None;
|
||||||
let queries = query
|
let query_atoms = query_atoms(query)
|
||||||
.split(|c| {
|
.filter_map(|atom| {
|
||||||
saw_backslash = match c {
|
let atom = QueryAtom::new(atom)?;
|
||||||
' ' if !saw_backslash => return true,
|
if atom.kind == QueryAtomKind::Fuzzy && first_fuzzy_query.is_none() {
|
||||||
'\\' => true,
|
first_fuzzy_query = Some(atom.atom);
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
false
|
|
||||||
})
|
|
||||||
.filter_map(|query| {
|
|
||||||
if query.is_empty() {
|
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(query.replace("\\ ", " "))
|
Some(atom)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
FuzzyQuery { queries }
|
FuzzyQuery {
|
||||||
|
first_fuzzy_atom: first_fuzzy_query,
|
||||||
|
query_atoms,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn fuzzy_match(&self, item: &str, matcher: &Matcher) -> Option<i64> {
|
pub fn fuzzy_match(&self, item: &str, matcher: &Matcher) -> Option<i64> {
|
||||||
// use the rank of the first query for the rank, because merging ranks is not really possible
|
// use the rank of the first fuzzzy query for the rank, because merging ranks is not really possible
|
||||||
// this behaviour matches fzf and skim
|
// this behaviour matches fzf and skim
|
||||||
let score = matcher.fuzzy_match(item, self.queries.get(0)?)?;
|
let score = self
|
||||||
|
.first_fuzzy_atom
|
||||||
|
.as_ref()
|
||||||
|
.map_or(Some(0), |atom| matcher.fuzzy_match(item, atom))?;
|
||||||
if self
|
if self
|
||||||
.queries
|
.query_atoms
|
||||||
.iter()
|
.iter()
|
||||||
.any(|query| matcher.fuzzy_match(item, query).is_none())
|
.any(|atom| !atom.matches(matcher, item))
|
||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
@ -46,29 +214,26 @@ impl FuzzyQuery {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn fuzzy_indicies(&self, item: &str, matcher: &Matcher) -> Option<(i64, Vec<usize>)> {
|
pub fn fuzzy_indicies(&self, item: &str, matcher: &Matcher) -> Option<(i64, Vec<usize>)> {
|
||||||
if self.queries.len() == 1 {
|
let (score, mut indices) = self.first_fuzzy_atom.as_ref().map_or_else(
|
||||||
return matcher.fuzzy_indices(item, &self.queries[0]);
|
|| Some((0, Vec::new())),
|
||||||
|
|atom| matcher.fuzzy_indices(item, atom),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// fast path for the common case of just a single atom
|
||||||
|
if self.query_atoms.is_empty() {
|
||||||
|
return Some((score, indices));
|
||||||
}
|
}
|
||||||
|
|
||||||
// use the rank of the first query for the rank, because merging ranks is not really possible
|
for atom in &self.query_atoms {
|
||||||
// this behaviour matches fzf and skim
|
if !atom.indices(matcher, item, &mut indices) {
|
||||||
let (score, mut indicies) = matcher.fuzzy_indices(item, self.queries.get(0)?)?;
|
return None;
|
||||||
|
}
|
||||||
// fast path for the common case of not using a space
|
|
||||||
// during matching this branch should be free thanks to branch prediction
|
|
||||||
if self.queries.len() == 1 {
|
|
||||||
return Some((score, indicies));
|
|
||||||
}
|
|
||||||
|
|
||||||
for query in &self.queries[1..] {
|
|
||||||
let (_, matched_indicies) = matcher.fuzzy_indices(item, query)?;
|
|
||||||
indicies.extend_from_slice(&matched_indicies);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// deadup and remove duplicate matches
|
// deadup and remove duplicate matches
|
||||||
indicies.sort_unstable();
|
indices.sort_unstable();
|
||||||
indicies.dedup();
|
indices.dedup();
|
||||||
|
|
||||||
Some((score, indicies))
|
Some((score, indices))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -407,7 +407,7 @@ pub struct Picker<T: Item> {
|
||||||
cursor: usize,
|
cursor: usize,
|
||||||
// pattern: String,
|
// pattern: String,
|
||||||
prompt: Prompt,
|
prompt: Prompt,
|
||||||
previous_pattern: String,
|
previous_pattern: (String, FuzzyQuery),
|
||||||
/// Whether to truncate the start (default true)
|
/// Whether to truncate the start (default true)
|
||||||
pub truncate_start: bool,
|
pub truncate_start: bool,
|
||||||
/// Whether to show the preview panel (default true)
|
/// Whether to show the preview panel (default true)
|
||||||
|
@ -458,7 +458,7 @@ impl<T: Item> Picker<T> {
|
||||||
matches: Vec::new(),
|
matches: Vec::new(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
prompt,
|
prompt,
|
||||||
previous_pattern: String::new(),
|
previous_pattern: (String::new(), FuzzyQuery::default()),
|
||||||
truncate_start: true,
|
truncate_start: true,
|
||||||
show_preview: true,
|
show_preview: true,
|
||||||
callback_fn: Box::new(callback_fn),
|
callback_fn: Box::new(callback_fn),
|
||||||
|
@ -485,10 +485,15 @@ impl<T: Item> Picker<T> {
|
||||||
pub fn score(&mut self) {
|
pub fn score(&mut self) {
|
||||||
let pattern = self.prompt.line();
|
let pattern = self.prompt.line();
|
||||||
|
|
||||||
if pattern == &self.previous_pattern {
|
if pattern == &self.previous_pattern.0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let (query, is_refined) = self
|
||||||
|
.previous_pattern
|
||||||
|
.1
|
||||||
|
.refine(pattern, &self.previous_pattern.0);
|
||||||
|
|
||||||
if pattern.is_empty() {
|
if pattern.is_empty() {
|
||||||
// Fast path for no pattern.
|
// Fast path for no pattern.
|
||||||
self.matches.clear();
|
self.matches.clear();
|
||||||
|
@ -501,8 +506,7 @@ impl<T: Item> Picker<T> {
|
||||||
len: text.chars().count(),
|
len: text.chars().count(),
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
} else if pattern.starts_with(&self.previous_pattern) {
|
} else if is_refined {
|
||||||
let query = FuzzyQuery::new(pattern);
|
|
||||||
// optimization: if the pattern is a more specific version of the previous one
|
// optimization: if the pattern is a more specific version of the previous one
|
||||||
// then we can score the filtered set.
|
// then we can score the filtered set.
|
||||||
self.matches.retain_mut(|pmatch| {
|
self.matches.retain_mut(|pmatch| {
|
||||||
|
@ -527,7 +531,8 @@ impl<T: Item> Picker<T> {
|
||||||
// reset cursor position
|
// reset cursor position
|
||||||
self.cursor = 0;
|
self.cursor = 0;
|
||||||
let pattern = self.prompt.line();
|
let pattern = self.prompt.line();
|
||||||
self.previous_pattern.clone_from(pattern);
|
self.previous_pattern.0.clone_from(pattern);
|
||||||
|
self.previous_pattern.1 = query;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn force_score(&mut self) {
|
pub fn force_score(&mut self) {
|
||||||
|
|
Loading…
Add table
Reference in a new issue