From 5c2bcd068b02183ec7528fe1d6cd26e57abd25f2 Mon Sep 17 00:00:00 2001 From: Yash Thakur <45539777+ysthakur@users.noreply.github.com> Date: Mon, 31 Mar 2025 14:19:09 -0400 Subject: [PATCH] Enable exact match behavior for any path with slashes (#15458) # Description Closes #14794. This PR enables the strict exact match behavior requested in #13204 and #14794 for any path containing a slash (#13302 implemented this for paths ending in slashes). If any of the components along the way *don't* exactly match a directory, then the next components will use the old Fish-like completion behavior rather than the strict behavior. This change only affects those using prefix matching. Fuzzy matching remains unaffected. # User-Facing Changes Suppose you have the following directory structure: ``` - foo - bar - xyzzy - barbaz - xyzzy - foobar - bar - xyzzy - barbaz - xyzzy ``` - If you type `cd foo`, you will be suggested `[foo, foobar]` - This is because `foo` is the last component of the path, so the strict behavior isn't activated - Similarly, `foo/bar` will show you `[foo/bar, foo/barbaz]` - If you type `foo/bar/x`, you will be suggested `[foo/bar/xyzzy]` - This is because `foo` and `bar` both exactly matched a directory - If you type `foo/b/x`, you will be suggested `[foo/bar/xyzzy, foo/barbaz/xyzzy]` - This is because `foo` matches a directory exactly, so `foobar/*` won't be suggested, but `b` doesn't exactly match a directory, so both `bar` and `barbaz` are suggested - If you type `f/b/x`, you will be suggested all four of the `xyzzy` files above - If you type `f/bar/x`, you will be suggested all four of the `xyzzy` files above - Since `f` doesn't exactly match a directory, every component after it won't use the strict matching behavior (even though `bar` exactly matches a directory) # Tests + Formatting # After Submitting This is a pretty minor change but should be mentioned somewhere in the release notes in case it surprises someone. --------- Co-authored-by: 132ikl <132@ikl.sh> --- .../src/completions/completion_common.rs | 41 +++++++++++------- crates/nu-cli/tests/completions/mod.rs | 42 ++++++++++++++++--- .../partial-a/{hola => hola/foo.txt} | 0 .../partial_completions/partial/hol/foo.txt | 0 4 files changed, 62 insertions(+), 21 deletions(-) rename tests/fixtures/partial_completions/partial-a/{hola => hola/foo.txt} (100%) create mode 100644 tests/fixtures/partial_completions/partial/hol/foo.txt diff --git a/crates/nu-cli/src/completions/completion_common.rs b/crates/nu-cli/src/completions/completion_common.rs index c40a57527b..e8179e0ec9 100644 --- a/crates/nu-cli/src/completions/completion_common.rs +++ b/crates/nu-cli/src/completions/completion_common.rs @@ -22,18 +22,22 @@ pub struct PathBuiltFromString { /// Recursively goes through paths that match a given `partial`. /// built: State struct for a valid matching path built so far. /// +/// `want_directory`: Whether we want only directories as completion matches. +/// Some commands like `cd` can only be run on directories whereas others +/// like `ls` can be run on regular files as well. +/// /// `isdir`: whether the current partial path has a trailing slash. /// Parsing a path string into a pathbuf loses that bit of information. /// -/// want_directory: Whether we want only directories as completion matches. -/// Some commands like `cd` can only be run on directories whereas others -/// like `ls` can be run on regular files as well. +/// `enable_exact_match`: Whether match algorithm is Prefix and all previous components +/// of the path matched a directory exactly. fn complete_rec( partial: &[&str], built_paths: &[PathBuiltFromString], options: &CompletionOptions, want_directory: bool, isdir: bool, + enable_exact_match: bool, ) -> Vec { if let Some((&base, rest)) = partial.split_first() { if base.chars().all(|c| c == '.') && (isdir || !rest.is_empty()) { @@ -46,7 +50,14 @@ fn complete_rec( built }) .collect(); - return complete_rec(rest, &built_paths, options, want_directory, isdir); + return complete_rec( + rest, + &built_paths, + options, + want_directory, + isdir, + enable_exact_match, + ); } } @@ -86,27 +97,26 @@ fn complete_rec( // Serves as confirmation to ignore longer completions for // components in between. if !rest.is_empty() || isdir { + // Don't show longer completions if we have an exact match (#13204, #14794) + let exact_match = enable_exact_match + && (if options.case_sensitive { + entry_name.eq(base) + } else { + entry_name.eq_ignore_case(base) + }); completions.extend(complete_rec( rest, &[built], options, want_directory, isdir, + exact_match, )); - } else { - completions.push(built); - } - - // For https://github.com/nushell/nushell/issues/13204 - if isdir && options.match_algorithm == MatchAlgorithm::Prefix { - let exact_match = if options.case_sensitive { - entry_name.eq(base) - } else { - entry_name.to_folded_case().eq(&base.to_folded_case()) - }; if exact_match { break; } + } else { + completions.push(built); } } None => { @@ -255,6 +265,7 @@ pub fn complete_item( options, want_directory, isdir, + options.match_algorithm == MatchAlgorithm::Prefix, ) .into_iter() .map(|mut p| { diff --git a/crates/nu-cli/tests/completions/mod.rs b/crates/nu-cli/tests/completions/mod.rs index 10a207b88c..e2b481f676 100644 --- a/crates/nu-cli/tests/completions/mod.rs +++ b/crates/nu-cli/tests/completions/mod.rs @@ -951,10 +951,11 @@ fn partial_completions() { // Create the expected values let expected_paths = [ file(dir.join("partial").join("hello.txt")), + folder(dir.join("partial").join("hol")), file(dir.join("partial-a").join("have_ext.exe")), file(dir.join("partial-a").join("have_ext.txt")), file(dir.join("partial-a").join("hello")), - file(dir.join("partial-a").join("hola")), + folder(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")), @@ -971,11 +972,12 @@ fn partial_completions() { // Create the expected values let expected_paths = [ file(dir.join("partial").join("hello.txt")), + folder(dir.join("partial").join("hol")), file(dir.join("partial-a").join("anotherfile")), file(dir.join("partial-a").join("have_ext.exe")), file(dir.join("partial-a").join("have_ext.txt")), file(dir.join("partial-a").join("hello")), - file(dir.join("partial-a").join("hola")), + folder(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")), @@ -2215,15 +2217,43 @@ fn exact_match() { let mut completer = NuCompleter::new(Arc::new(engine), Arc::new(stack)); + // Troll case to test if exact match logic works case insensitively let target_dir = format!("open {}", folder(dir.join("pArTiAL"))); let suggestions = completer.complete(&target_dir, target_dir.len()); - - // Since it's an exact match, only 'partial' should be suggested, not - // 'partial-a' and stuff. Implemented in #13302 match_suggestions( - &vec![file(dir.join("partial").join("hello.txt")).as_str()], + &vec![ + file(dir.join("partial").join("hello.txt")).as_str(), + folder(dir.join("partial").join("hol")).as_str(), + ], &suggestions, ); + + let target_dir = format!("open {}", file(dir.join("partial").join("h"))); + let suggestions = completer.complete(&target_dir, target_dir.len()); + match_suggestions( + &vec![ + file(dir.join("partial").join("hello.txt")).as_str(), + folder(dir.join("partial").join("hol")).as_str(), + ], + &suggestions, + ); + + // Even though "hol" is an exact match, the first component ("part") wasn't an + // exact match, so we include partial-a/hola + let target_dir = format!("open {}", file(dir.join("part").join("hol"))); + let suggestions = completer.complete(&target_dir, target_dir.len()); + match_suggestions( + &vec![ + folder(dir.join("partial").join("hol")).as_str(), + folder(dir.join("partial-a").join("hola")).as_str(), + ], + &suggestions, + ); + + // Exact match behavior shouldn't be enabled if the path has no slashes + let target_dir = format!("open {}", file(dir.join("partial"))); + let suggestions = completer.complete(&target_dir, target_dir.len()); + assert!(suggestions.len() > 1); } #[ignore = "was reverted, still needs fixing"] diff --git a/tests/fixtures/partial_completions/partial-a/hola b/tests/fixtures/partial_completions/partial-a/hola/foo.txt similarity index 100% rename from tests/fixtures/partial_completions/partial-a/hola rename to tests/fixtures/partial_completions/partial-a/hola/foo.txt diff --git a/tests/fixtures/partial_completions/partial/hol/foo.txt b/tests/fixtures/partial_completions/partial/hol/foo.txt new file mode 100644 index 0000000000..e69de29bb2