mirror of
https://github.com/nushell/nushell.git
synced 2024-11-22 08:23:24 +01: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
19
Cargo.lock
generated
19
Cargo.lock
generated
@ -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"
|
||||||
|
@ -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"
|
||||||
|
@ -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 =
|
||||||
|
@ -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]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user