Fuzzy completion matching (#5320)

* Implement fuzzy match algorithm for suggestions

* Use MatchingAlgorithm for custom completions
This commit is contained in:
Richard 2022-04-24 23:43:18 +02:00 committed by GitHub
parent f6b99b2d8f
commit 9771270b38
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 138 additions and 25 deletions

19
Cargo.lock generated
View File

@ -1195,6 +1195,15 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "fuzzy-matcher"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
dependencies = [
"thread_local",
]
[[package]] [[package]]
name = "fxhash" name = "fxhash"
version = "0.2.1" version = "0.2.1"
@ -2263,6 +2272,7 @@ name = "nu-cli"
version = "0.61.1" version = "0.61.1"
dependencies = [ dependencies = [
"crossterm", "crossterm",
"fuzzy-matcher",
"is_executable", "is_executable",
"log", "log",
"miette 4.5.0", "miette 4.5.0",
@ -4352,6 +4362,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "thread_local"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5516c27b78311c50bf42c071425c560ac799b11c30b31f87e3081965fe5e0180"
dependencies = [
"once_cell",
]
[[package]] [[package]]
name = "time" name = "time"
version = "0.1.44" version = "0.1.44"

View File

@ -22,6 +22,7 @@ reedline = { git = "https://github.com/nushell/reedline", branch = "main", featu
crossterm = "0.23.0" crossterm = "0.23.0"
miette = { version = "4.5.0", features = ["fancy"] } miette = { version = "4.5.0", features = ["fancy"] }
thiserror = "1.0.29" thiserror = "1.0.29"
fuzzy-matcher = "0.3.7"
log = "0.4" log = "0.4"
is_executable = "1.0.1" is_executable = "1.0.1"

View File

@ -1,6 +1,6 @@
use crate::completions::{ use crate::completions::{
CommandCompletion, Completer, CompletionOptions, CustomCompletion, DirectoryCompletion, CommandCompletion, Completer, CompletionOptions, CustomCompletion, DirectoryCompletion,
DotNuCompletion, FileCompletion, FlagCompletion, VariableCompletion, DotNuCompletion, FileCompletion, FlagCompletion, MatchAlgorithm, VariableCompletion,
}; };
use nu_parser::{flatten_expression, parse, FlatShape}; use nu_parser::{flatten_expression, parse, FlatShape};
use nu_protocol::{ use nu_protocol::{
@ -35,7 +35,13 @@ impl NuCompleter {
offset: usize, offset: usize,
pos: usize, pos: usize,
) -> Vec<Suggestion> { ) -> Vec<Suggestion> {
let options = CompletionOptions::default(); let config = self.engine_state.get_config();
let mut options = CompletionOptions::default();
if config.completion_algorithm == "fuzzy" {
options.match_algorithm = MatchAlgorithm::Fuzzy;
}
// Fetch // Fetch
let mut suggestions = let mut suggestions =

View File

@ -1,3 +1,7 @@
use std::fmt::Display;
use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher};
#[derive(Copy, Clone)] #[derive(Copy, Clone)]
pub enum SortBy { pub enum SortBy {
LevenshteinDistance, LevenshteinDistance,
@ -6,13 +10,19 @@ pub enum SortBy {
} }
/// Describes how suggestions should be matched. /// Describes how suggestions should be matched.
#[derive(Copy, Clone)] #[derive(Copy, Clone, Debug)]
pub enum MatchAlgorithm { pub enum MatchAlgorithm {
/// Only show suggestions which begin with the given input /// Only show suggestions which begin with the given input
/// ///
/// Example: /// Example:
/// "git switch" is matched by "git sw" /// "git switch" is matched by "git sw"
Prefix, Prefix,
/// Only show suggestions which contain the input chars at any place
///
/// Example:
/// "git checkout" is matched by "gco"
Fuzzy,
} }
impl MatchAlgorithm { impl MatchAlgorithm {
@ -20,6 +30,10 @@ impl MatchAlgorithm {
pub fn matches_str(&self, haystack: &str, needle: &str) -> bool { pub fn matches_str(&self, haystack: &str, needle: &str) -> bool {
match *self { match *self {
MatchAlgorithm::Prefix => haystack.starts_with(needle), MatchAlgorithm::Prefix => haystack.starts_with(needle),
MatchAlgorithm::Fuzzy => {
let matcher = SkimMatcherV2::default();
matcher.fuzzy_match(haystack, needle).is_some()
}
} }
} }
@ -27,10 +41,44 @@ impl MatchAlgorithm {
pub fn matches_u8(&self, haystack: &[u8], needle: &[u8]) -> bool { pub fn matches_u8(&self, haystack: &[u8], needle: &[u8]) -> bool {
match *self { match *self {
MatchAlgorithm::Prefix => haystack.starts_with(needle), MatchAlgorithm::Prefix => haystack.starts_with(needle),
MatchAlgorithm::Fuzzy => {
let haystack_str = String::from_utf8_lossy(haystack);
let needle_str = String::from_utf8_lossy(needle);
let matcher = SkimMatcherV2::default();
matcher.fuzzy_match(&haystack_str, &needle_str).is_some()
}
} }
} }
} }
impl TryFrom<String> for MatchAlgorithm {
type Error = InvalidMatchAlgorithm;
fn try_from(value: String) -> Result<Self, Self::Error> {
match value.as_str() {
"prefix" => Ok(Self::Prefix),
"fuzzy" => Ok(Self::Fuzzy),
_ => Err(InvalidMatchAlgorithm::Unknown),
}
}
}
#[derive(Debug)]
pub enum InvalidMatchAlgorithm {
Unknown,
}
impl Display for InvalidMatchAlgorithm {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self {
InvalidMatchAlgorithm::Unknown => write!(f, "unknown match algorithm"),
}
}
}
impl std::error::Error for InvalidMatchAlgorithm {}
#[derive(Clone)] #[derive(Clone)]
pub struct CompletionOptions { pub struct CompletionOptions {
pub case_sensitive: bool, pub case_sensitive: bool,
@ -66,4 +114,21 @@ mod test {
assert!(algorithm.matches_u8(&[1, 2, 3], &[1, 2])); assert!(algorithm.matches_u8(&[1, 2, 3], &[1, 2]));
assert!(!algorithm.matches_u8(&[1, 2, 3], &[2, 3])); assert!(!algorithm.matches_u8(&[1, 2, 3], &[2, 3]));
} }
#[test]
fn match_algorithm_fuzzy() {
let algorithm = MatchAlgorithm::Fuzzy;
assert!(algorithm.matches_str("example text", ""));
assert!(algorithm.matches_str("example text", "examp"));
assert!(algorithm.matches_str("example text", "ext"));
assert!(algorithm.matches_str("example text", "mplxt"));
assert!(!algorithm.matches_str("example text", "mpp"));
assert!(algorithm.matches_u8(&[1, 2, 3], &[]));
assert!(algorithm.matches_u8(&[1, 2, 3], &[1, 2]));
assert!(algorithm.matches_u8(&[1, 2, 3], &[2, 3]));
assert!(algorithm.matches_u8(&[1, 2, 3], &[1, 3]));
assert!(!algorithm.matches_u8(&[1, 2, 3], &[2, 2]));
}
} }

View File

@ -97,7 +97,7 @@ impl Completer for CustomCompletion {
span: Span, span: Span,
offset: usize, offset: usize,
pos: usize, pos: usize,
_options: &CompletionOptions, completion_options: &CompletionOptions,
) -> Vec<Suggestion> { ) -> Vec<Suggestion> {
// Line position // Line position
let line_pos = pos - offset; let line_pos = pos - offset;
@ -129,8 +129,10 @@ impl Completer for CustomCompletion {
PipelineData::new(span), PipelineData::new(span),
); );
let mut custom_completion_options = None;
// Parse result // Parse result
let (suggestions, options) = match result { let suggestions = match result {
Ok(pd) => { Ok(pd) => {
let value = pd.into_value(span); let value = pd.into_value(span);
match &value { match &value {
@ -145,7 +147,7 @@ impl Completer for CustomCompletion {
.unwrap_or_default(); .unwrap_or_default();
let options = value.get_data_by_key("options"); let options = value.get_data_by_key("options");
let options = if let Some(Value::Record { .. }) = &options { if let Some(Value::Record { .. }) = &options {
let options = options.unwrap_or_default(); let options = options.unwrap_or_default();
let should_sort = options let should_sort = options
.get_data_by_key("sort") .get_data_by_key("sort")
@ -156,7 +158,7 @@ impl Completer for CustomCompletion {
self.sort_by = SortBy::Ascending; self.sort_by = SortBy::Ascending;
} }
CompletionOptions { custom_completion_options = Some(CompletionOptions {
case_sensitive: options case_sensitive: options
.get_data_by_key("case_sensitive") .get_data_by_key("case_sensitive")
.and_then(|val| val.as_bool().ok()) .and_then(|val| val.as_bool().ok())
@ -170,25 +172,33 @@ impl Completer for CustomCompletion {
} else { } else {
SortBy::None SortBy::None
}, },
match_algorithm: MatchAlgorithm::Prefix, match_algorithm: match options
} .get_data_by_key("completion_algorithm")
} else { {
CompletionOptions::default() Some(option) => option
}; .as_string()
.ok()
.and_then(|option| option.try_into().ok())
.unwrap_or(MatchAlgorithm::Prefix),
None => completion_options.match_algorithm,
},
});
}
(completions, options) completions
} }
Value::List { vals, .. } => { Value::List { vals, .. } => self.map_completions(vals.iter(), span, offset),
let completions = self.map_completions(vals.iter(), span, offset); _ => vec![],
(completions, CompletionOptions::default())
}
_ => (vec![], CompletionOptions::default()),
} }
} }
_ => (vec![], CompletionOptions::default()), _ => vec![],
}; };
filter(&prefix, suggestions, options) if let Some(custom_completion_options) = custom_completion_options {
filter(&prefix, suggestions, &custom_completion_options)
} else {
filter(&prefix, suggestions, completion_options)
}
} }
fn get_sort_by(&self) -> SortBy { fn get_sort_by(&self) -> SortBy {
@ -196,12 +206,11 @@ impl Completer for CustomCompletion {
} }
} }
fn filter(prefix: &[u8], items: Vec<Suggestion>, options: CompletionOptions) -> Vec<Suggestion> { fn filter(prefix: &[u8], items: Vec<Suggestion>, options: &CompletionOptions) -> Vec<Suggestion> {
items items
.into_iter() .into_iter()
.filter(|it| { .filter(|it| match options.match_algorithm {
// Minimise clones for new functionality MatchAlgorithm::Prefix => match (options.case_sensitive, options.positional) {
match (options.case_sensitive, options.positional) {
(true, true) => it.value.as_bytes().starts_with(prefix), (true, true) => it.value.as_bytes().starts_with(prefix),
(true, false) => it.value.contains(std::str::from_utf8(prefix).unwrap_or("")), (true, false) => it.value.contains(std::str::from_utf8(prefix).unwrap_or("")),
(false, positional) => { (false, positional) => {
@ -213,7 +222,10 @@ fn filter(prefix: &[u8], items: Vec<Suggestion>, options: CompletionOptions) ->
value.contains(&prefix) value.contains(&prefix)
} }
} }
} },
MatchAlgorithm::Fuzzy => options
.match_algorithm
.matches_u8(it.value.as_bytes(), prefix),
}) })
.collect() .collect()
} }

View File

@ -38,6 +38,7 @@ pub struct Config {
pub use_ansi_coloring: bool, pub use_ansi_coloring: bool,
pub quick_completions: bool, pub quick_completions: bool,
pub partial_completions: bool, pub partial_completions: bool,
pub completion_algorithm: String,
pub edit_mode: String, pub edit_mode: String,
pub max_history_size: i64, pub max_history_size: i64,
pub sync_history_on_enter: bool, pub sync_history_on_enter: bool,
@ -63,6 +64,7 @@ impl Default for Config {
use_ansi_coloring: true, use_ansi_coloring: true,
quick_completions: true, quick_completions: true,
partial_completions: true, partial_completions: true,
completion_algorithm: "prefix".into(),
edit_mode: "emacs".into(), edit_mode: "emacs".into(),
max_history_size: 1000, max_history_size: 1000,
sync_history_on_enter: true, sync_history_on_enter: true,
@ -182,6 +184,13 @@ impl Value {
eprintln!("$config.partial_completions is not a bool") eprintln!("$config.partial_completions is not a bool")
} }
} }
"completion_algorithm" => {
if let Ok(v) = value.as_string() {
config.completion_algorithm = v.to_lowercase();
} else {
eprintln!("$config.completion_algorithm is not a string")
}
}
"rm_always_trash" => { "rm_always_trash" => {
if let Ok(b) = value.as_bool() { if let Ok(b) = value.as_bool() {
config.rm_always_trash = b; config.rm_always_trash = b;

View File

@ -187,6 +187,7 @@ let-env config = {
footer_mode: "25" # always, never, number_of_rows, auto footer_mode: "25" # always, never, number_of_rows, auto
quick_completions: true # set this to false to prevent auto-selecting completions when only one remains quick_completions: true # set this to false to prevent auto-selecting completions when only one remains
partial_completions: true # set this to false to prevent partial filling of the prompt partial_completions: true # set this to false to prevent partial filling of the prompt
completion_algorithm: "prefix" # prefix, fuzzy
animate_prompt: false # redraw the prompt every second animate_prompt: false # redraw the prompt every second
float_precision: 2 float_precision: 2
use_ansi_coloring: true use_ansi_coloring: true