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",
]
[[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"

View file

@ -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"

View file

@ -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 =

View file

@ -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]));
}
}

View file

@ -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()
}

View file

@ -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;

View file

@ -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