From 273226d6662e8dc03f6a8a377cd5ad717be6b289 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20H=E1=BB=93ng=20Qu=C3=A2n?= Date: Tue, 18 Feb 2025 00:52:07 +0700 Subject: [PATCH] Provide more directories autocomplete for "overlay use" (#15057) ## Description - Fixes #12891 - An escape for #12835 Currently, Nushell is not very friendly to Python workflow, because Python developers very often need to activate a virtual environment, and in some workflow, the _activate.nu_ script is not near to "current working directory" (especially ones who use [virtualenvwrapper](https://virtualenvwrapper.readthedocs.io/en/latest/) and [Poetry](https://python-poetry.org/)), and hence, is not auto-completed for `overlay use`. Though Nu v0.102.0 has improved auto-complete for `overlay use`, it doesn't work if user starts the file path with `~` or `/`, like: ```nu > overlay use /h ``` ```nu > overlay use ~/W ``` ### Before: ![image](https://github.com/user-attachments/assets/8b668c21-0f3a-4d6f-9cd2-8cc92460525c) ### After: ![image](https://github.com/user-attachments/assets/ca491e64-774a-48d4-8f4f-58d647e011df) ![image](https://github.com/user-attachments/assets/4e097008-b5e1-4f63-af80-c1697025d4ad) ## User-Facing Changes No ## Tests + Formatting Passed --------- Co-authored-by: blindfs --- .../src/completions/dotnu_completions.rs | 76 +++++++++---- crates/nu-cli/tests/completions/mod.rs | 101 +++++++++++++++++- .../support/completions_helpers.rs | 32 ++++-- 3 files changed, 174 insertions(+), 35 deletions(-) diff --git a/crates/nu-cli/src/completions/dotnu_completions.rs b/crates/nu-cli/src/completions/dotnu_completions.rs index f06b85f5ed..7bb3458fe0 100644 --- a/crates/nu-cli/src/completions/dotnu_completions.rs +++ b/crates/nu-cli/src/completions/dotnu_completions.rs @@ -5,7 +5,10 @@ use nu_protocol::{ Span, }; use reedline::Suggestion; -use std::path::{is_separator, PathBuf, MAIN_SEPARATOR as SEP, MAIN_SEPARATOR_STR}; +use std::{ + collections::HashSet, + path::{is_separator, PathBuf, MAIN_SEPARATOR as SEP, MAIN_SEPARATOR_STR}, +}; use super::{SemanticSuggestion, SuggestionKind}; @@ -33,47 +36,76 @@ impl Completer for DotNuCompletion { let start_with_backquote = prefix_str.starts_with('`'); let end_with_backquote = prefix_str.ends_with('`'); let prefix_str = prefix_str.replace('`', ""); + // e.g. `./`, `..\`, `/` + let not_lib_dirs = prefix_str + .chars() + .find(|c| *c != '.') + .is_some_and(is_separator); let mut search_dirs: Vec = vec![]; - // If prefix_str is only a word we want to search in the current dir - let (base, partial) = prefix_str - .rsplit_once(is_separator) - .unwrap_or((".", &prefix_str)); + let (base, partial) = if let Some((parent, remain)) = prefix_str.rsplit_once(is_separator) { + // If prefix_str is only a word we want to search in the current dir. + // "/xx" should be split to "/" and "xx". + if parent.is_empty() { + (MAIN_SEPARATOR_STR, remain) + } else { + (parent, remain) + } + } else { + (".", prefix_str.as_str()) + }; let base_dir = base.replace(is_separator, MAIN_SEPARATOR_STR); // Fetch the lib dirs - let lib_dirs: Vec = working_set + // NOTE: 2 ways to setup `NU_LIB_DIRS` + // 1. `const NU_LIB_DIRS = [paths]`, equal to `nu -I paths` + // 2. `$env.NU_LIB_DIRS = [paths]` + let const_lib_dirs = working_set .find_variable(b"$NU_LIB_DIRS") - .and_then(|vid| working_set.get_variable(vid).const_val.as_ref()) - .or(working_set.get_env_var("NU_LIB_DIRS")) - .map(|lib_dirs| { + .and_then(|vid| working_set.get_variable(vid).const_val.as_ref()); + let env_lib_dirs = working_set.get_env_var("NU_LIB_DIRS"); + let lib_dirs: HashSet = [const_lib_dirs, env_lib_dirs] + .into_iter() + .flatten() + .flat_map(|lib_dirs| { lib_dirs .as_list() .into_iter() .flat_map(|it| it.iter().filter_map(|x| x.to_path().ok())) .map(expand_tilde) - .collect() }) - .unwrap_or_default(); + .collect(); // Check if the base_dir is a folder - // rsplit_once removes the separator let cwd = working_set.permanent_state.cwd(None); if base_dir != "." { - // Search in base_dir as well as lib_dirs + let expanded_base_dir = expand_tilde(&base_dir); + let is_base_dir_relative = expanded_base_dir.is_relative(); + // Search in base_dir as well as lib_dirs. + // After expanded, base_dir can be a relative path or absolute path. + // If relative, we join "current working dir" with it to get subdirectory and add to search_dirs. + // If absolute, we add it to search_dirs. if let Ok(mut cwd) = cwd { - cwd.push(&base_dir); - search_dirs.push(cwd.into_std_path_buf()); + if is_base_dir_relative { + cwd.push(&base_dir); + search_dirs.push(cwd.into_std_path_buf()); + } else { + search_dirs.push(expanded_base_dir); + } + } + if !not_lib_dirs { + search_dirs.extend(lib_dirs.into_iter().map(|mut dir| { + dir.push(&base_dir); + dir + })); } - search_dirs.extend(lib_dirs.into_iter().map(|mut dir| { - dir.push(&base_dir); - dir - })); } else { if let Ok(cwd) = cwd { search_dirs.push(cwd.into_std_path_buf()); } - search_dirs.extend(lib_dirs); + if !not_lib_dirs { + search_dirs.extend(lib_dirs); + } } // Fetch the files filtering the ones that ends with .nu @@ -104,7 +136,9 @@ impl Completer for DotNuCompletion { let mut span_offset = 0; let mut value = x.path.to_string(); // Complete only the last path component - if base_dir != "." { + if base_dir == MAIN_SEPARATOR_STR { + span_offset = base_dir.len() + } else if base_dir != "." { span_offset = base_dir.len() + 1 } // Retain only one '`' diff --git a/crates/nu-cli/tests/completions/mod.rs b/crates/nu-cli/tests/completions/mod.rs index 3e07a6e266..dfc8f3d679 100644 --- a/crates/nu-cli/tests/completions/mod.rs +++ b/crates/nu-cli/tests/completions/mod.rs @@ -1,20 +1,62 @@ pub mod support; +use std::{ + fs::{read_dir, FileType, ReadDir}, + path::{PathBuf, MAIN_SEPARATOR}, + sync::Arc, +}; + use nu_cli::NuCompleter; use nu_engine::eval_block; use nu_parser::parse; +use nu_path::expand_tilde; use nu_protocol::{debugger::WithoutDebug, engine::StateWorkingSet, PipelineData}; use reedline::{Completer, Suggestion}; use rstest::{fixture, rstest}; -use std::{ - path::{PathBuf, MAIN_SEPARATOR}, - sync::Arc, -}; use support::{ completions_helpers::{new_dotnu_engine, new_partial_engine, new_quote_engine}, file, folder, match_suggestions, new_engine, }; +// Match a list of suggestions with the content of a directory. +// This helper is for DotNutCompletion, so actually it only retrieves +// *.nu files and subdirectories. +pub fn match_dir_content_for_dotnu(dir: ReadDir, suggestions: &[Suggestion]) { + let actual_dir_entries: Vec<_> = dir.filter_map(|c| c.ok()).collect(); + let type_name_pairs: Vec<(FileType, String)> = actual_dir_entries + .into_iter() + .filter_map(|t| t.file_type().ok().zip(t.file_name().into_string().ok())) + .collect(); + let mut simple_dir_entries: Vec<&str> = type_name_pairs + .iter() + .filter_map(|(t, n)| { + if t.is_dir() || n.ends_with(".nu") { + Some(n.as_str()) + } else { + None + } + }) + .collect(); + simple_dir_entries.sort(); + let mut pure_suggestions: Vec<&str> = suggestions + .iter() + .map(|s| { + // The file names in suggestions contain some extra characters, + // we clean them to compare more exactly with read_dir result. + s.value + .as_str() + .trim_end_matches('`') + .trim_end_matches('/') + .trim_end_matches('\\') + .trim_start_matches('`') + .trim_start_matches("~/") + .trim_start_matches("~\\") + }) + .collect(); + pure_suggestions.sort(); + assert_eq!(simple_dir_entries, pure_suggestions); +} + #[fixture] fn completer() -> NuCompleter { // Create a new engine @@ -343,6 +385,57 @@ fn dotnu_completions() { let suggestions = completer.complete(&completion_str, completion_str.len()); match_suggestions(&expected, &suggestions); + + // Test special paths + #[cfg(windows)] + { + let completion_str = "use \\".to_string(); + let dir_content = read_dir("\\").unwrap(); + let suggestions = completer.complete(&completion_str, completion_str.len()); + match_dir_content_for_dotnu(dir_content, &suggestions); + } + + let completion_str = "use /".to_string(); + let suggestions = completer.complete(&completion_str, completion_str.len()); + let dir_content = read_dir("/").unwrap(); + match_dir_content_for_dotnu(dir_content, &suggestions); + + let completion_str = "use ~".to_string(); + let dir_content = read_dir(expand_tilde("~")).unwrap(); + let suggestions = completer.complete(&completion_str, completion_str.len()); + match_dir_content_for_dotnu(dir_content, &suggestions); +} + +#[test] +fn dotnu_completions_const_nu_lib_dirs() { + let (_, _, engine, stack) = new_dotnu_engine(); + let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); + + // file in `lib-dir1/`, set by `const NU_LIB_DIRS` + let completion_str = "use xyzz".to_string(); + let suggestions = completer.complete(&completion_str, completion_str.len()); + match_suggestions(&vec!["xyzzy.nu".into()], &suggestions); + + // file in `lib-dir2/`, set by `$env.NU_LIB_DIRS` + let completion_str = "use asdf".to_string(); + let suggestions = completer.complete(&completion_str, completion_str.len()); + match_suggestions(&vec!["asdf.nu".into()], &suggestions); + + // file in `lib-dir3/`, set by both, should not replicate + let completion_str = "use spam".to_string(); + let suggestions = completer.complete(&completion_str, completion_str.len()); + match_suggestions(&vec!["spam.nu".into()], &suggestions); + + // if `./` specified by user, file in `lib-dir*` should be ignored + #[cfg(windows)] + { + let completion_str = "use .\\asdf".to_string(); + let suggestions = completer.complete(&completion_str, completion_str.len()); + assert!(suggestions.is_empty()); + } + let completion_str = "use ./asdf".to_string(); + let suggestions = completer.complete(&completion_str, completion_str.len()); + assert!(suggestions.is_empty()); } #[test] diff --git a/crates/nu-cli/tests/completions/support/completions_helpers.rs b/crates/nu-cli/tests/completions/support/completions_helpers.rs index 83e4dfddf3..ed541997a3 100644 --- a/crates/nu-cli/tests/completions/support/completions_helpers.rs +++ b/crates/nu-cli/tests/completions/support/completions_helpers.rs @@ -86,6 +86,23 @@ pub fn new_dotnu_engine() -> (AbsolutePathBuf, String, EngineState, Stack) { // Add $nu engine_state.generate_nu_constant(); + // const $NU_LIB_DIRS + let mut working_set = StateWorkingSet::new(&engine_state); + let var_id = working_set.add_variable( + b"$NU_LIB_DIRS".into(), + Span::unknown(), + nu_protocol::Type::List(Box::new(nu_protocol::Type::String)), + false, + ); + working_set.set_variable_const_val( + var_id, + Value::test_list(vec![ + Value::string(file(dir.join("lib-dir1")), dir_span), + Value::string(file(dir.join("lib-dir3")), dir_span), + ]), + ); + let _ = engine_state.merge_delta(working_set.render()); + // New stack let mut stack = Stack::new(); @@ -95,17 +112,12 @@ pub fn new_dotnu_engine() -> (AbsolutePathBuf, String, EngineState, Stack) { "TEST".to_string(), Value::string("NUSHELL".to_string(), dir_span), ); - stack.add_env_var( - "NU_LIB_DIRS".to_string(), - Value::list( - vec![ - Value::string(file(dir.join("lib-dir1")), dir_span), - Value::string(file(dir.join("lib-dir2")), dir_span), - Value::string(file(dir.join("lib-dir3")), dir_span), - ], - dir_span, - ), + "NU_LIB_DIRS".into(), + Value::test_list(vec![ + Value::string(file(dir.join("lib-dir2")), dir_span), + Value::string(file(dir.join("lib-dir3")), dir_span), + ]), ); // Merge environment into the permanent state