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<TAB>`, 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>
This commit is contained in:
Yash Thakur 2025-03-31 14:19:09 -04:00 committed by GitHub
parent 9aba96604b
commit 5c2bcd068b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 62 additions and 21 deletions

View File

@ -22,18 +22,22 @@ pub struct PathBuiltFromString {
/// Recursively goes through paths that match a given `partial`. /// Recursively goes through paths that match a given `partial`.
/// built: State struct for a valid matching path built so far. /// 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. /// `isdir`: whether the current partial path has a trailing slash.
/// Parsing a path string into a pathbuf loses that bit of information. /// Parsing a path string into a pathbuf loses that bit of information.
/// ///
/// want_directory: Whether we want only directories as completion matches. /// `enable_exact_match`: Whether match algorithm is Prefix and all previous components
/// Some commands like `cd` can only be run on directories whereas others /// of the path matched a directory exactly.
/// like `ls` can be run on regular files as well.
fn complete_rec( fn complete_rec(
partial: &[&str], partial: &[&str],
built_paths: &[PathBuiltFromString], built_paths: &[PathBuiltFromString],
options: &CompletionOptions, options: &CompletionOptions,
want_directory: bool, want_directory: bool,
isdir: bool, isdir: bool,
enable_exact_match: bool,
) -> Vec<PathBuiltFromString> { ) -> Vec<PathBuiltFromString> {
if let Some((&base, rest)) = partial.split_first() { if let Some((&base, rest)) = partial.split_first() {
if base.chars().all(|c| c == '.') && (isdir || !rest.is_empty()) { if base.chars().all(|c| c == '.') && (isdir || !rest.is_empty()) {
@ -46,7 +50,14 @@ fn complete_rec(
built built
}) })
.collect(); .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 // Serves as confirmation to ignore longer completions for
// components in between. // components in between.
if !rest.is_empty() || isdir { 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( completions.extend(complete_rec(
rest, rest,
&[built], &[built],
options, options,
want_directory, want_directory,
isdir, 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 { if exact_match {
break; break;
} }
} else {
completions.push(built);
} }
} }
None => { None => {
@ -255,6 +265,7 @@ pub fn complete_item(
options, options,
want_directory, want_directory,
isdir, isdir,
options.match_algorithm == MatchAlgorithm::Prefix,
) )
.into_iter() .into_iter()
.map(|mut p| { .map(|mut p| {

View File

@ -951,10 +951,11 @@ fn partial_completions() {
// Create the expected values // Create the expected values
let expected_paths = [ let expected_paths = [
file(dir.join("partial").join("hello.txt")), 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.exe")),
file(dir.join("partial-a").join("have_ext.txt")), file(dir.join("partial-a").join("have_ext.txt")),
file(dir.join("partial-a").join("hello")), 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("hello_b")),
file(dir.join("partial-b").join("hi_b")), file(dir.join("partial-b").join("hi_b")),
file(dir.join("partial-c").join("hello_c")), file(dir.join("partial-c").join("hello_c")),
@ -971,11 +972,12 @@ fn partial_completions() {
// Create the expected values // Create the expected values
let expected_paths = [ let expected_paths = [
file(dir.join("partial").join("hello.txt")), 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("anotherfile")),
file(dir.join("partial-a").join("have_ext.exe")), file(dir.join("partial-a").join("have_ext.exe")),
file(dir.join("partial-a").join("have_ext.txt")), file(dir.join("partial-a").join("have_ext.txt")),
file(dir.join("partial-a").join("hello")), 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("hello_b")),
file(dir.join("partial-b").join("hi_b")), file(dir.join("partial-b").join("hi_b")),
file(dir.join("partial-c").join("hello_c")), 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)); 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 target_dir = format!("open {}", folder(dir.join("pArTiAL")));
let suggestions = completer.complete(&target_dir, target_dir.len()); 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( 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, &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"] #[ignore = "was reverted, still needs fixing"]

View File