From 5c15a4dd6e0c573ac7d902fe8af93e98f332f4ed Mon Sep 17 00:00:00 2001 From: Himadri Bhattacharjee Date: Mon, 2 Oct 2023 17:44:51 +0000 Subject: [PATCH] Fish-like completions for nested directories (#10543) # Description This PR allows tab completion for nested directories while only specifying a part of the directory names. To illustrate this, if I type `tar/de/inc` and hit tab, it autocompletes to `./target/debug/incremental`. # User-Facing Changes Nested paths can be tab completed by typing lesser characters. # Tests + Formatting Tests cases are added. # After Submitting --- Cargo.lock | 1 + crates/nu-cli/Cargo.toml | 1 + .../src/completions/completion_common.rs | 136 ++++++++++++++++++ .../src/completions/directory_completions.rs | 94 +++--------- .../src/completions/file_completions.rs | 113 +++------------ crates/nu-cli/src/completions/mod.rs | 5 +- crates/nu-cli/tests/completions.rs | 86 ++++++++++- .../tests/support/completions_helpers.rs | 36 +++++ .../final_partial/somefile | 0 .../partial_completions/partial_a/anotherfile | 0 .../partial_completions/partial_a/hello | 0 .../partial_completions/partial_a/hola | 0 .../partial_completions/partial_b/hello_b | 0 .../partial_completions/partial_b/hi_b | 0 .../partial_completions/partial_c/hello_c | 0 15 files changed, 302 insertions(+), 170 deletions(-) create mode 100644 crates/nu-cli/src/completions/completion_common.rs create mode 100644 tests/fixtures/partial_completions/final_partial/somefile create mode 100644 tests/fixtures/partial_completions/partial_a/anotherfile create mode 100644 tests/fixtures/partial_completions/partial_a/hello create mode 100644 tests/fixtures/partial_completions/partial_a/hola create mode 100644 tests/fixtures/partial_completions/partial_b/hello_b create mode 100644 tests/fixtures/partial_completions/partial_b/hi_b create mode 100644 tests/fixtures/partial_completions/partial_c/hello_c diff --git a/Cargo.lock b/Cargo.lock index 82b93c740..5eaacb45a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2741,6 +2741,7 @@ dependencies = [ "nu-test-support", "nu-utils", "once_cell", + "pathdiff", "percent-encoding", "reedline", "rstest", diff --git a/crates/nu-cli/Cargo.toml b/crates/nu-cli/Cargo.toml index 4b1df657f..211cddc45 100644 --- a/crates/nu-cli/Cargo.toml +++ b/crates/nu-cli/Cargo.toml @@ -36,6 +36,7 @@ log = "0.4" miette = { version = "5.10", features = ["fancy-no-backtrace"] } once_cell = "1.18" percent-encoding = "2" +pathdiff = "0.2" sysinfo = "0.29" unicode-segmentation = "1.10" uuid = { version = "1.4.1", features = ["v4"] } diff --git a/crates/nu-cli/src/completions/completion_common.rs b/crates/nu-cli/src/completions/completion_common.rs new file mode 100644 index 000000000..6b57fd709 --- /dev/null +++ b/crates/nu-cli/src/completions/completion_common.rs @@ -0,0 +1,136 @@ +use crate::completions::{matches, CompletionOptions}; +use nu_path::home_dir; +use std::path::{is_separator, Component, Path, PathBuf, MAIN_SEPARATOR as SEP}; + +fn complete_rec( + partial: &[String], + cwd: &Path, + options: &CompletionOptions, + dir: bool, + isdir: bool, +) -> Vec { + let mut completions = vec![]; + + if let Ok(result) = cwd.read_dir() { + for entry in result.filter_map(|e| e.ok()) { + let entry_name = entry.file_name().to_string_lossy().into_owned(); + let path = entry.path(); + + if !dir || path.is_dir() { + match partial.first() { + Some(base) if matches(base, &entry_name, options) => { + let partial = &partial[1..]; + if !partial.is_empty() || isdir { + completions.extend(complete_rec(partial, &path, options, dir, isdir)) + } else { + completions.push(path) + } + } + None => completions.push(path), + _ => {} + } + } + } + } + completions +} + +enum OriginalCwd { + None, + Home(PathBuf), + Some(PathBuf), +} + +impl OriginalCwd { + fn apply(&self, p: &Path) -> String { + let mut ret = match self { + Self::None => p.to_string_lossy().into_owned(), + Self::Some(base) => pathdiff::diff_paths(p, base) + .unwrap_or(p.to_path_buf()) + .to_string_lossy() + .into_owned(), + Self::Home(home) => match p.strip_prefix(home) { + Ok(suffix) => format!("~{}{}", SEP, suffix.to_string_lossy()), + _ => p.to_string_lossy().into_owned(), + }, + }; + + if p.is_dir() { + ret.push(SEP); + } + ret + } +} + +pub fn complete_item( + want_directory: bool, + span: nu_protocol::Span, + partial: &str, + cwd: &str, + options: &CompletionOptions, +) -> Vec<(nu_protocol::Span, String)> { + let isdir = partial.ends_with(is_separator); + let cwd_pathbuf = Path::new(cwd).to_path_buf(); + let mut original_cwd = OriginalCwd::None; + let mut components = Path::new(partial).components().peekable(); + let mut cwd = match components.peek().cloned() { + Some(c @ Component::Prefix(..)) => { + // windows only by definition + components.next(); + if let Some(Component::RootDir) = components.peek().cloned() { + components.next(); + }; + [c, Component::RootDir].iter().collect() + } + Some(c @ Component::RootDir) => { + components.next(); + PathBuf::from(c.as_os_str()) + } + Some(Component::Normal(home)) if home.to_string_lossy() == "~" => { + components.next(); + original_cwd = OriginalCwd::Home(home_dir().unwrap_or(cwd_pathbuf.clone())); + home_dir().unwrap_or(cwd_pathbuf) + } + _ => { + original_cwd = OriginalCwd::Some(cwd_pathbuf.clone()); + cwd_pathbuf + } + }; + + let mut partial = vec![]; + + for component in components { + match component { + Component::Prefix(..) => unreachable!(), + Component::RootDir => unreachable!(), + Component::CurDir => {} + Component::ParentDir => { + if partial.pop().is_none() { + cwd.pop(); + } + } + Component::Normal(c) => { + partial.push(c.to_string_lossy().into_owned()); + } + } + } + + complete_rec(partial.as_slice(), &cwd, options, want_directory, isdir) + .into_iter() + .map(|p| (span, escape_path(original_cwd.apply(&p), want_directory))) + .collect() +} + +// Fix files or folders with quotes or hashes +pub fn escape_path(path: String, dir: bool) -> String { + let filename_contaminated = !dir + && path.contains([ + '\'', '"', ' ', '#', '(', ')', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + ]); + let dirname_contaminated = dir && path.contains(['\'', '"', ' ', '#']); + if filename_contaminated || dirname_contaminated { + format!("`{path}`") + } else { + path + } +} diff --git a/crates/nu-cli/src/completions/directory_completions.rs b/crates/nu-cli/src/completions/directory_completions.rs index 6e712af08..3ac4b7767 100644 --- a/crates/nu-cli/src/completions/directory_completions.rs +++ b/crates/nu-cli/src/completions/directory_completions.rs @@ -1,14 +1,13 @@ -use crate::completions::{matches, Completer, CompletionOptions}; +use crate::completions::{completion_common::complete_item, Completer, CompletionOptions}; use nu_protocol::{ engine::{EngineState, StateWorkingSet}, levenshtein_distance, Span, }; use reedline::Suggestion; -use std::fs; use std::path::Path; use std::sync::Arc; -use super::{partial_from, prepend_base_dir, SortBy}; +use super::SortBy; const SEP: char = std::path::MAIN_SEPARATOR; @@ -33,23 +32,27 @@ impl Completer for DirectoryCompletion { _: usize, options: &CompletionOptions, ) -> Vec { - let cwd = self.engine_state.current_work_dir(); let partial = String::from_utf8_lossy(&prefix).to_string(); // Filter only the folders - let output: Vec<_> = directory_completion(span, &partial, &cwd, options) - .into_iter() - .map(move |x| Suggestion { - value: x.1, - description: None, - extra: None, - span: reedline::Span { - start: x.0.start - offset, - end: x.0.end - offset, - }, - append_whitespace: false, - }) - .collect(); + let output: Vec<_> = directory_completion( + span, + &partial, + &self.engine_state.current_work_dir(), + options, + ) + .into_iter() + .map(move |x| Suggestion { + value: x.1, + description: None, + extra: None, + span: reedline::Span { + start: x.0.start - offset, + end: x.0.end - offset, + }, + append_whitespace: false, + }) + .collect(); output } @@ -111,60 +114,5 @@ pub fn directory_completion( cwd: &str, options: &CompletionOptions, ) -> Vec<(nu_protocol::Span, String)> { - let original_input = partial; - - let (base_dir_name, partial) = partial_from(partial); - - let base_dir = nu_path::expand_path_with(&base_dir_name, cwd); - - // This check is here as base_dir.read_dir() with base_dir == "" will open the current dir - // which we don't want in this case (if we did, base_dir would already be ".") - if base_dir == Path::new("") { - return Vec::new(); - } - - if let Ok(result) = base_dir.read_dir() { - return result - .filter_map(|entry| { - entry.ok().and_then(|entry| { - if let Ok(metadata) = fs::metadata(entry.path()) { - if metadata.is_dir() { - let mut file_name = entry.file_name().to_string_lossy().into_owned(); - if matches(&partial, &file_name, options) { - let mut path = if prepend_base_dir(original_input, &base_dir_name) { - format!("{base_dir_name}{file_name}") - } else { - file_name.to_string() - }; - - if entry.path().is_dir() { - path.push(SEP); - file_name.push(SEP); - } - - // Fix files or folders with quotes or hash - if path.contains('\'') - || path.contains('"') - || path.contains(' ') - || path.contains('#') - { - path = format!("`{path}`"); - } - - Some((span, path)) - } else { - None - } - } else { - None - } - } else { - None - } - }) - }) - .collect(); - } - - Vec::new() + complete_item(true, span, partial, cwd, options) } diff --git a/crates/nu-cli/src/completions/file_completions.rs b/crates/nu-cli/src/completions/file_completions.rs index 4c6657aab..54f547ae4 100644 --- a/crates/nu-cli/src/completions/file_completions.rs +++ b/crates/nu-cli/src/completions/file_completions.rs @@ -1,4 +1,4 @@ -use crate::completions::{Completer, CompletionOptions}; +use crate::completions::{completion_common::complete_item, Completer, CompletionOptions}; use nu_protocol::{ engine::{EngineState, StateWorkingSet}, levenshtein_distance, Span, @@ -32,21 +32,25 @@ impl Completer for FileCompletion { _: usize, options: &CompletionOptions, ) -> Vec { - let cwd = self.engine_state.current_work_dir(); let prefix = String::from_utf8_lossy(&prefix).to_string(); - let output: Vec<_> = file_path_completion(span, &prefix, &cwd, options) - .into_iter() - .map(move |x| Suggestion { - value: x.1, - description: None, - extra: None, - span: reedline::Span { - start: x.0.start - offset, - end: x.0.end - offset, - }, - append_whitespace: false, - }) - .collect(); + let output: Vec<_> = file_path_completion( + span, + &prefix, + &self.engine_state.current_work_dir(), + options, + ) + .into_iter() + .map(move |x| Suggestion { + value: x.1, + description: None, + extra: None, + span: reedline::Span { + start: x.0.start - offset, + end: x.0.end - offset, + }, + append_whitespace: false, + }) + .collect(); output } @@ -122,64 +126,7 @@ pub fn file_path_completion( cwd: &str, options: &CompletionOptions, ) -> Vec<(nu_protocol::Span, String)> { - let original_input = partial; - let (base_dir_name, partial) = partial_from(partial); - - let base_dir = nu_path::expand_path_with(&base_dir_name, cwd); - // This check is here as base_dir.read_dir() with base_dir == "" will open the current dir - // which we don't want in this case (if we did, base_dir would already be ".") - if base_dir == Path::new("") { - return Vec::new(); - } - - if let Ok(result) = base_dir.read_dir() { - return result - .filter_map(|entry| { - entry.ok().and_then(|entry| { - let mut file_name = entry.file_name().to_string_lossy().into_owned(); - if matches(&partial, &file_name, options) { - let mut path = if prepend_base_dir(original_input, &base_dir_name) { - format!("{base_dir_name}{file_name}") - } else { - file_name.to_string() - }; - - if entry.path().is_dir() { - path.push(SEP); - file_name.push(SEP); - } - - // Fix files or folders with quotes or hashes - if path.contains('\'') - || path.contains('"') - || path.contains(' ') - || path.contains('#') - || path.contains('(') - || path.contains(')') - || path.starts_with('0') - || path.starts_with('1') - || path.starts_with('2') - || path.starts_with('3') - || path.starts_with('4') - || path.starts_with('5') - || path.starts_with('6') - || path.starts_with('7') - || path.starts_with('8') - || path.starts_with('9') - { - path = format!("`{path}`"); - } - - Some((span, path)) - } else { - None - } - }) - }) - .collect(); - } - - Vec::new() + complete_item(false, span, partial, cwd, options) } pub fn matches(partial: &str, from: &str, options: &CompletionOptions) -> bool { @@ -192,23 +139,3 @@ pub fn matches(partial: &str, from: &str, options: &CompletionOptions) -> bool { options.match_algorithm.matches_str(from, partial) } - -/// Returns whether the base_dir should be prepended to the file path -pub fn prepend_base_dir(input: &str, base_dir: &str) -> bool { - if base_dir == format!(".{SEP}") { - // if the current base_dir path is the local folder we only add a "./" prefix if the user - // input already includes a local folder prefix. - let manually_entered = { - let mut chars = input.chars(); - let first_char = chars.next(); - let second_char = chars.next(); - - first_char == Some('.') && second_char.map(is_separator).unwrap_or(false) - }; - - manually_entered - } else { - // always prepend the base dir if it is a subfolder - true - } -} diff --git a/crates/nu-cli/src/completions/mod.rs b/crates/nu-cli/src/completions/mod.rs index a710aa3d7..d8fb0b463 100644 --- a/crates/nu-cli/src/completions/mod.rs +++ b/crates/nu-cli/src/completions/mod.rs @@ -1,6 +1,7 @@ mod base; mod command_completions; mod completer; +mod completion_common; mod completion_options; mod custom_completions; mod directory_completions; @@ -16,8 +17,6 @@ pub use completion_options::{CompletionOptions, MatchAlgorithm, SortBy}; pub use custom_completions::CustomCompletion; pub use directory_completions::DirectoryCompletion; pub use dotnu_completions::DotNuCompletion; -pub use file_completions::{ - file_path_completion, matches, partial_from, prepend_base_dir, FileCompletion, -}; +pub use file_completions::{file_path_completion, matches, partial_from, FileCompletion}; pub use flag_completions::FlagCompletion; pub use variable_completions::VariableCompletion; diff --git a/crates/nu-cli/tests/completions.rs b/crates/nu-cli/tests/completions.rs index 135978c70..f73720e96 100644 --- a/crates/nu-cli/tests/completions.rs +++ b/crates/nu-cli/tests/completions.rs @@ -5,7 +5,10 @@ use nu_parser::parse; use nu_protocol::engine::StateWorkingSet; use reedline::{Completer, Suggestion}; use rstest::{fixture, rstest}; -use support::{completions_helpers::new_quote_engine, file, folder, match_suggestions, new_engine}; +use support::{ + completions_helpers::{new_partial_engine, new_quote_engine}, + file, folder, match_suggestions, new_engine, +}; #[fixture] fn completer() -> NuCompleter { @@ -201,6 +204,87 @@ fn file_completions() { match_suggestions(expected_paths, suggestions); } +#[test] +fn partial_completions() { + // Create a new engine + let (dir, _, engine, stack) = new_partial_engine(); + + // Instantiate a new completer + let mut completer = NuCompleter::new(std::sync::Arc::new(engine), stack); + + // Test completions for a folder's name + let target_dir = format!("cd {}", file(dir.join("pa"))); + let suggestions = completer.complete(&target_dir, target_dir.len()); + + // Create the expected values + let expected_paths: Vec = vec![ + folder(dir.join("partial_a")), + folder(dir.join("partial_b")), + folder(dir.join("partial_c")), + ]; + + // Match the results + match_suggestions(expected_paths, suggestions); + + // Test completions for the files whose name begin with "h" + // and are present under directories whose names begin with "pa" + let dir_str = file(dir.join("pa").join("h")); + let target_dir = format!("cp {dir_str}"); + let suggestions = completer.complete(&target_dir, target_dir.len()); + + // Create the expected values + let expected_paths: Vec = vec![ + file(dir.join("partial_a").join("hello")), + file(dir.join("partial_a").join("hola")), + file(dir.join("partial_b").join("hello_b")), + file(dir.join("partial_b").join("hi_b")), + file(dir.join("partial_c").join("hello_c")), + ]; + + // Match the results + match_suggestions(expected_paths, suggestions); + + // Test completion for all files under directories whose names begin with "pa" + let dir_str = folder(dir.join("pa")); + let target_dir = format!("ls {dir_str}"); + let suggestions = completer.complete(&target_dir, target_dir.len()); + + // Create the expected values + let expected_paths: Vec = vec![ + file(dir.join("partial_a").join("anotherfile")), + file(dir.join("partial_a").join("hello")), + file(dir.join("partial_a").join("hola")), + file(dir.join("partial_b").join("hello_b")), + file(dir.join("partial_b").join("hi_b")), + file(dir.join("partial_c").join("hello_c")), + ]; + + // Match the results + match_suggestions(expected_paths, suggestions); + + // Test completion for a single file + let dir_str = file(dir.join("fi").join("so")); + let target_dir = format!("rm {dir_str}"); + let suggestions = completer.complete(&target_dir, target_dir.len()); + + // Create the expected values + let expected_paths: Vec = vec![file(dir.join("final_partial").join("somefile"))]; + + // Match the results + match_suggestions(expected_paths, suggestions); + + // Test completion where there is a sneaky `..` in the path + let dir_str = file(dir.join("par").join("..").join("fi").join("so")); + let target_dir = format!("rm {dir_str}"); + let suggestions = completer.complete(&target_dir, target_dir.len()); + + // Create the expected values + let expected_paths: Vec = vec![file(dir.join("final_partial").join("somefile"))]; + + // Match the results + match_suggestions(expected_paths, suggestions); +} + #[test] fn command_ls_with_filecompletion() { let (_, _, engine, stack) = new_engine(); diff --git a/crates/nu-cli/tests/support/completions_helpers.rs b/crates/nu-cli/tests/support/completions_helpers.rs index 8d1432313..4ca974d5c 100644 --- a/crates/nu-cli/tests/support/completions_helpers.rs +++ b/crates/nu-cli/tests/support/completions_helpers.rs @@ -109,6 +109,42 @@ pub fn new_quote_engine() -> (PathBuf, String, EngineState, Stack) { (dir, dir_str, engine_state, stack) } +pub fn new_partial_engine() -> (PathBuf, String, EngineState, Stack) { + // Target folder inside assets + let dir = fs::fixtures().join("partial_completions"); + let mut dir_str = dir + .clone() + .into_os_string() + .into_string() + .unwrap_or_default(); + dir_str.push(SEP); + + // Create a new engine with default context + let mut engine_state = create_default_context(); + + // New stack + let mut stack = Stack::new(); + + // Add pwd as env var + stack.add_env_var( + "PWD".to_string(), + Value::string(dir_str.clone(), nu_protocol::Span::new(0, dir_str.len())), + ); + stack.add_env_var( + "TEST".to_string(), + Value::string( + "NUSHELL".to_string(), + nu_protocol::Span::new(0, dir_str.len()), + ), + ); + + // Merge environment into the permanent state + let merge_result = engine_state.merge_env(&mut stack, &dir); + assert!(merge_result.is_ok()); + + (dir, dir_str, engine_state, stack) +} + // match a list of suggestions with the expected values pub fn match_suggestions(expected: Vec, suggestions: Vec) { let expected_len = expected.len(); diff --git a/tests/fixtures/partial_completions/final_partial/somefile b/tests/fixtures/partial_completions/final_partial/somefile new file mode 100644 index 000000000..e69de29bb diff --git a/tests/fixtures/partial_completions/partial_a/anotherfile b/tests/fixtures/partial_completions/partial_a/anotherfile new file mode 100644 index 000000000..e69de29bb diff --git a/tests/fixtures/partial_completions/partial_a/hello b/tests/fixtures/partial_completions/partial_a/hello new file mode 100644 index 000000000..e69de29bb diff --git a/tests/fixtures/partial_completions/partial_a/hola b/tests/fixtures/partial_completions/partial_a/hola new file mode 100644 index 000000000..e69de29bb diff --git a/tests/fixtures/partial_completions/partial_b/hello_b b/tests/fixtures/partial_completions/partial_b/hello_b new file mode 100644 index 000000000..e69de29bb diff --git a/tests/fixtures/partial_completions/partial_b/hi_b b/tests/fixtures/partial_completions/partial_b/hi_b new file mode 100644 index 000000000..e69de29bb diff --git a/tests/fixtures/partial_completions/partial_c/hello_c b/tests/fixtures/partial_completions/partial_c/hello_c new file mode 100644 index 000000000..e69de29bb