diff --git a/Cargo.lock b/Cargo.lock index e8098f018..64d3736ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1195,6 +1195,15 @@ dependencies = [ "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]] name = "fxhash" version = "0.2.1" @@ -2263,6 +2272,7 @@ name = "nu-cli" version = "0.61.1" dependencies = [ "crossterm", + "fuzzy-matcher", "is_executable", "log", "miette 4.5.0", @@ -4352,6 +4362,15 @@ dependencies = [ "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]] name = "time" version = "0.1.44" diff --git a/crates/nu-cli/Cargo.toml b/crates/nu-cli/Cargo.toml index f1f8b8b06..07d4a2dd7 100644 --- a/crates/nu-cli/Cargo.toml +++ b/crates/nu-cli/Cargo.toml @@ -22,6 +22,7 @@ reedline = { git = "https://github.com/nushell/reedline", branch = "main", featu crossterm = "0.23.0" miette = { version = "4.5.0", features = ["fancy"] } thiserror = "1.0.29" +fuzzy-matcher = "0.3.7" log = "0.4" is_executable = "1.0.1" diff --git a/crates/nu-cli/src/completions/completer.rs b/crates/nu-cli/src/completions/completer.rs index ca14ab472..55cc4a4ac 100644 --- a/crates/nu-cli/src/completions/completer.rs +++ b/crates/nu-cli/src/completions/completer.rs @@ -1,6 +1,6 @@ use crate::completions::{ CommandCompletion, Completer, CompletionOptions, CustomCompletion, DirectoryCompletion, - DotNuCompletion, FileCompletion, FlagCompletion, VariableCompletion, + DotNuCompletion, FileCompletion, FlagCompletion, MatchAlgorithm, VariableCompletion, }; use nu_parser::{flatten_expression, parse, FlatShape}; use nu_protocol::{ @@ -35,7 +35,13 @@ impl NuCompleter { offset: usize, pos: usize, ) -> Vec { - 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 let mut suggestions = diff --git a/crates/nu-cli/src/completions/completion_options.rs b/crates/nu-cli/src/completions/completion_options.rs index c03f96d68..8e5c08b52 100644 --- a/crates/nu-cli/src/completions/completion_options.rs +++ b/crates/nu-cli/src/completions/completion_options.rs @@ -1,3 +1,7 @@ +use std::fmt::Display; + +use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; + #[derive(Copy, Clone)] pub enum SortBy { LevenshteinDistance, @@ -6,13 +10,19 @@ pub enum SortBy { } /// Describes how suggestions should be matched. -#[derive(Copy, Clone)] +#[derive(Copy, Clone, Debug)] pub enum MatchAlgorithm { /// Only show suggestions which begin with the given input /// /// Example: /// "git switch" is matched by "git sw" Prefix, + + /// Only show suggestions which contain the input chars at any place + /// + /// Example: + /// "git checkout" is matched by "gco" + Fuzzy, } impl MatchAlgorithm { @@ -20,6 +30,10 @@ impl MatchAlgorithm { pub fn matches_str(&self, haystack: &str, needle: &str) -> bool { match *self { 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 { match *self { 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 for MatchAlgorithm { + type Error = InvalidMatchAlgorithm; + + fn try_from(value: String) -> Result { + 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)] pub struct CompletionOptions { 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], &[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])); + } } diff --git a/crates/nu-cli/src/completions/custom_completions.rs b/crates/nu-cli/src/completions/custom_completions.rs index e7c5c11f4..b8e7f1d08 100644 --- a/crates/nu-cli/src/completions/custom_completions.rs +++ b/crates/nu-cli/src/completions/custom_completions.rs @@ -97,7 +97,7 @@ impl Completer for CustomCompletion { span: Span, offset: usize, pos: usize, - _options: &CompletionOptions, + completion_options: &CompletionOptions, ) -> Vec { // Line position let line_pos = pos - offset; @@ -129,8 +129,10 @@ impl Completer for CustomCompletion { PipelineData::new(span), ); + let mut custom_completion_options = None; + // Parse result - let (suggestions, options) = match result { + let suggestions = match result { Ok(pd) => { let value = pd.into_value(span); match &value { @@ -145,7 +147,7 @@ impl Completer for CustomCompletion { .unwrap_or_default(); 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 should_sort = options .get_data_by_key("sort") @@ -156,7 +158,7 @@ impl Completer for CustomCompletion { self.sort_by = SortBy::Ascending; } - CompletionOptions { + custom_completion_options = Some(CompletionOptions { case_sensitive: options .get_data_by_key("case_sensitive") .and_then(|val| val.as_bool().ok()) @@ -170,25 +172,33 @@ impl Completer for CustomCompletion { } else { SortBy::None }, - match_algorithm: MatchAlgorithm::Prefix, - } - } else { - CompletionOptions::default() - }; + match_algorithm: match options + .get_data_by_key("completion_algorithm") + { + 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, .. } => { - let completions = self.map_completions(vals.iter(), span, offset); - (completions, CompletionOptions::default()) - } - _ => (vec![], CompletionOptions::default()), + Value::List { vals, .. } => self.map_completions(vals.iter(), span, offset), + _ => vec![], } } - _ => (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 { @@ -196,12 +206,11 @@ impl Completer for CustomCompletion { } } -fn filter(prefix: &[u8], items: Vec, options: CompletionOptions) -> Vec { +fn filter(prefix: &[u8], items: Vec, options: &CompletionOptions) -> Vec { items .into_iter() - .filter(|it| { - // Minimise clones for new functionality - match (options.case_sensitive, options.positional) { + .filter(|it| match options.match_algorithm { + MatchAlgorithm::Prefix => match (options.case_sensitive, options.positional) { (true, true) => it.value.as_bytes().starts_with(prefix), (true, false) => it.value.contains(std::str::from_utf8(prefix).unwrap_or("")), (false, positional) => { @@ -213,7 +222,10 @@ fn filter(prefix: &[u8], items: Vec, options: CompletionOptions) -> value.contains(&prefix) } } - } + }, + MatchAlgorithm::Fuzzy => options + .match_algorithm + .matches_u8(it.value.as_bytes(), prefix), }) .collect() } diff --git a/crates/nu-protocol/src/config.rs b/crates/nu-protocol/src/config.rs index d2cff3ff0..98ad3d87b 100644 --- a/crates/nu-protocol/src/config.rs +++ b/crates/nu-protocol/src/config.rs @@ -38,6 +38,7 @@ pub struct Config { pub use_ansi_coloring: bool, pub quick_completions: bool, pub partial_completions: bool, + pub completion_algorithm: String, pub edit_mode: String, pub max_history_size: i64, pub sync_history_on_enter: bool, @@ -63,6 +64,7 @@ impl Default for Config { use_ansi_coloring: true, quick_completions: true, partial_completions: true, + completion_algorithm: "prefix".into(), edit_mode: "emacs".into(), max_history_size: 1000, sync_history_on_enter: true, @@ -182,6 +184,13 @@ impl Value { 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" => { if let Ok(b) = value.as_bool() { config.rm_always_trash = b; diff --git a/docs/sample_config/default_config.nu b/docs/sample_config/default_config.nu index 0d5b9bacf..67a09c2a3 100644 --- a/docs/sample_config/default_config.nu +++ b/docs/sample_config/default_config.nu @@ -187,6 +187,7 @@ let-env config = { 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 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 float_precision: 2 use_ansi_coloring: true