on master help sign in
add substring matching options to picker (#5114)
commitf0c2e898b49b2392d6d1ca02680e88b90e62017e authorPascal Kuthe <pascal.kuthe@semimod.de> dateThu 2 Feb 2023, 20:48 (4 years ago) parente31943c
2 files changed+212−42
helix-term/src/ui/fuzzy_match.rs+201−36 helix-term/src/ui/picker.rs+11−6
helix-term/src/ui/fuzzy_match.rs+201−36
@@ -4,41 +4,209 @@ use fuzzy_matcher::FuzzyMatcher;
44#[cfg(test)]
55mod test;
66
7+struct QueryAtom {
8+ kind: QueryAtomKind,
9+ atom: String,
10+ ignore_case: bool,
11+ inverse: bool,
12+}
13+impl QueryAtom {
14+ fn new(atom: &str) -> Option<QueryAtom> {
15+ let mut atom = atom.to_string();
16+ let inverse = atom.starts_with('!');
17+ if inverse {
18+ atom.remove(0);
19+ }
20+
21+ let mut kind = match atom.chars().next() {
22+ Some('^') => QueryAtomKind::Prefix,
23+ Some('\'') => QueryAtomKind::Substring,
24+ _ if inverse => QueryAtomKind::Substring,
25+ _ => QueryAtomKind::Fuzzy,
26+ };
27+
28+ if atom.starts_with(&['^', '\'']) {
29+ atom.remove(0);
30+ }
31+
32+ if atom.is_empty() {
33+ return None;
34+ }
35+
36+ if atom.ends_with('$') && !atom.ends_with("\\$") {
37+ atom.pop();
38+ kind = if kind == QueryAtomKind::Prefix {
39+ QueryAtomKind::Exact
40+ } else {
41+ QueryAtomKind::Postfix
42+ }
43+ }
44+
45+ Some(QueryAtom {
46+ kind,
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()),
52+ inverse,
53+ })
54+ }
55+
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
59+ if self.inverse {
60+ return self.matches(matcher, item);
61+ }
62+ let buf;
63+ let item = if self.ignore_case {
64+ buf = item.to_ascii_lowercase();
65+ &buf
66+ } else {
67+ item
68+ };
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);
73+ return true;
74+ } else {
75+ return false;
76+ }
77+ }
78+ QueryAtomKind::Substring => {
79+ if let Some(off) = item.find(&self.atom) {
80+ off
81+ } else {
82+ return false;
83+ }
84+ }
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,
88+ _ => return false,
89+ };
90+
91+ indices.extend(off..(off + self.atom.len()));
92+ true
93+ }
94+
95+ fn matches(&self, matcher: &Matcher, item: &str) -> bool {
96+ let buf;
97+ let item = if self.ignore_case {
98+ buf = item.to_ascii_lowercase();
99+ &buf
100+ } else {
101+ item
102+ };
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,
109+ };
110+ if self.inverse {
111+ res = !res;
112+ }
113+ res
114+ }
115+}
116+
117+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
118+enum QueryAtomKind {
119+ /// Item is a fuzzy match of this behaviour
120+ ///
121+ /// Usage: `foo`
122+ Fuzzy,
123+ /// Item contains query atom as a continous substring
124+ ///
125+ /// Usage `'foo`
126+ Substring,
127+ /// Item starts with query atom
128+ ///
129+ /// Usage: `^foo`
130+ Prefix,
131+ /// Item ends with query atom
132+ ///
133+ /// Usage: `foo$`
134+ Postfix,
135+ /// Item is equal to query atom
136+ ///
137+ /// Usage `^foo$`
138+ Exact,
139+}
140+
141+#[derive(Default)]
7142pub struct FuzzyQuery {
8− queries: Vec<String>,
143+ first_fuzzy_atom: Option<String>,
144+ query_atoms: Vec<QueryAtom>,
145+}
146+
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,
152+ '\\' => true,
153+ _ => false,
154+ };
155+ false
156+ })
9157}
10158
11159impl 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);
164+
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
167+ // the matches.
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
172+ {
173+ is_refinement = false;
174+ }
175+ }
176+
177+ (new_query, is_refinement)
178+ }
179+
12180 pub fn new(query: &str) -> FuzzyQuery {
13− let mut saw_backslash = false;
14− let queries = query
15− .split(|c| {
16− saw_backslash = match c {
17− ' ' if !saw_backslash => return true,
18− '\\' => true,
19− _ => false,
20− };
21− false
22− })
23− .filter_map(|query| {
24− if query.is_empty() {
181+ let mut first_fuzzy_query = None;
182+ let query_atoms = query_atoms(query)
183+ .filter_map(|atom| {
184+ let atom = QueryAtom::new(atom)?;
185+ if atom.kind == QueryAtomKind::Fuzzy && first_fuzzy_query.is_none() {
186+ first_fuzzy_query = Some(atom.atom);
25187 None
26188 } else {
27− Some(query.replace("\\ ", " "))
189+ Some(atom)
28190 }
29191 })
30192 .collect();
31− FuzzyQuery { queries }
193+ FuzzyQuery {
194+ first_fuzzy_atom: first_fuzzy_query,
195+ query_atoms,
196+ }
32197 }
33198
34199 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
36201 // this behaviour matches fzf and skim
37− let score = matcher.fuzzy_match(item, self.queries.get(0)?)?;
202+ let score = self
203+ .first_fuzzy_atom
204+ .as_ref()
205+ .map_or(Some(0), |atom| matcher.fuzzy_match(item, atom))?;
38206 if self
39− .queries
207+ .query_atoms
40208 .iter()
41− .any(|query| matcher.fuzzy_match(item, query).is_none())
209+ .any(|atom| !atom.matches(matcher, item))
42210 {
43211 return None;
44212 }
@@ -46,29 +214,26 @@ impl FuzzyQuery {
46214 }
47215
48216 pub fn fuzzy_indicies(&self, item: &str, matcher: &Matcher) -> Option<(i64, Vec<usize>)> {
49− if self.queries.len() == 1 {
50− return matcher.fuzzy_indices(item, &self.queries[0]);
51− }
52−
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)?)?;
217+ let (score, mut indices) = self.first_fuzzy_atom.as_ref().map_or_else(
218+ || Some((0, Vec::new())),
219+ |atom| matcher.fuzzy_indices(item, atom),
220+ )?;
56221
57− // fast path for the common case of not using a space
58− // during matching this branch should be free thanks to branch prediction
59− if self.queries.len() == 1 {
60− return Some((score, indicies));
222+ // fast path for the common case of just a single atom
223+ if self.query_atoms.is_empty() {
224+ return Some((score, indices));
61225 }
62226
63− for query in &self.queries[1..] {
64− let (_, matched_indicies) = matcher.fuzzy_indices(item, query)?;
65− indicies.extend_from_slice(&matched_indicies);
227+ for atom in &self.query_atoms {
228+ if !atom.indices(matcher, item, &mut indices) {
229+ return None;
230+ }
66231 }
67232
68233 // deadup and remove duplicate matches
69− indicies.sort_unstable();
70− indicies.dedup();
234+ indices.sort_unstable();
235+ indices.dedup();
71236
72− Some((score, indicies))
237+ Some((score, indices))
73238 }
74239}
helix-term/src/ui/picker.rs+11−6
@@ -407,7 +407,7 @@ pub struct Picker<T: Item> {
407407 cursor: usize,
408408 // pattern: String,
409409 prompt: Prompt,
410− previous_pattern: String,
410+ previous_pattern: (String, FuzzyQuery),
411411 /// Whether to truncate the start (default true)
412412 pub truncate_start: bool,
413413 /// Whether to show the preview panel (default true)
@@ -458,7 +458,7 @@ impl<T: Item> Picker<T> {
458458 matches: Vec::new(),
459459 cursor: 0,
460460 prompt,
461− previous_pattern: String::new(),
461+ previous_pattern: (String::new(), FuzzyQuery::default()),
462462 truncate_start: true,
463463 show_preview: true,
464464 callback_fn: Box::new(callback_fn),
@@ -485,10 +485,15 @@ impl<T: Item> Picker<T> {
485485 pub fn score(&mut self) {
486486 let pattern = self.prompt.line();
487487
488− if pattern == &self.previous_pattern {
488+ if pattern == &self.previous_pattern.0 {
489489 return;
490490 }
491491
492+ let (query, is_refined) = self
493+ .previous_pattern
494+ .1
495+ .refine(pattern, &self.previous_pattern.0);
496+
492497 if pattern.is_empty() {
493498 // Fast path for no pattern.
494499 self.matches.clear();
@@ -501,8 +506,7 @@ impl<T: Item> Picker<T> {
501506 len: text.chars().count(),
502507 }
503508 }));
504− } else if pattern.starts_with(&self.previous_pattern) {
505− let query = FuzzyQuery::new(pattern);
509+ } else if is_refined {
506510 // optimization: if the pattern is a more specific version of the previous one
507511 // then we can score the filtered set.
508512 self.matches.retain_mut(|pmatch| {
@@ -527,7 +531,8 @@ impl<T: Item> Picker<T> {
527531 // reset cursor position
528532 self.cursor = 0;
529533 let pattern = self.prompt.line();
530− self.previous_pattern.clone_from(pattern);
534+ self.previous_pattern.0.clone_from(pattern);
535+ self.previous_pattern.1 = query;
531536 }
532537
533538 pub fn force_score(&mut self) {