mirror of
https://github.com/nushell/nushell
synced 2024-12-27 05:23:11 +00:00
Fuzzy completion matching (#5320)
* Implement fuzzy match algorithm for suggestions * Use MatchingAlgorithm for custom completions
This commit is contained in:
parent
f6b99b2d8f
commit
9771270b38
7 changed files with 138 additions and 25 deletions
19
Cargo.lock
generated
19
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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<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
|
||||
let mut suggestions =
|
||||
|
|
|
@ -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,9 +41,43 @@ 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<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)]
|
||||
pub struct CompletionOptions {
|
||||
|
@ -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]));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -97,7 +97,7 @@ impl Completer for CustomCompletion {
|
|||
span: Span,
|
||||
offset: usize,
|
||||
pos: usize,
|
||||
_options: &CompletionOptions,
|
||||
completion_options: &CompletionOptions,
|
||||
) -> Vec<Suggestion> {
|
||||
// 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,
|
||||
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
|
||||
}
|
||||
Value::List { vals, .. } => self.map_completions(vals.iter(), span, offset),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
if let Some(custom_completion_options) = custom_completion_options {
|
||||
filter(&prefix, suggestions, &custom_completion_options)
|
||||
} else {
|
||||
CompletionOptions::default()
|
||||
};
|
||||
|
||||
(completions, options)
|
||||
filter(&prefix, suggestions, completion_options)
|
||||
}
|
||||
Value::List { vals, .. } => {
|
||||
let completions = self.map_completions(vals.iter(), span, offset);
|
||||
(completions, CompletionOptions::default())
|
||||
}
|
||||
_ => (vec![], CompletionOptions::default()),
|
||||
}
|
||||
}
|
||||
_ => (vec![], CompletionOptions::default()),
|
||||
};
|
||||
|
||||
filter(&prefix, suggestions, options)
|
||||
}
|
||||
|
||||
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
|
||||
.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<Suggestion>, options: CompletionOptions) ->
|
|||
value.contains(&prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
MatchAlgorithm::Fuzzy => options
|
||||
.match_algorithm
|
||||
.matches_u8(it.value.as_bytes(), prefix),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue