diff --git a/clap_complete/src/engine/candidate.rs b/clap_complete/src/engine/candidate.rs index af629f0f7c0..aeff1f37c97 100644 --- a/clap_complete/src/engine/candidate.rs +++ b/clap_complete/src/engine/candidate.rs @@ -12,6 +12,7 @@ pub struct CompletionCandidate { tag: Option, display_order: Option, hidden: bool, + nospace: bool, } impl CompletionCandidate { @@ -60,6 +61,16 @@ impl CompletionCandidate { self } + /// Suppress trailing space after this candidate + /// + /// This is useful for completions like directory paths (ending in `/`), + /// `--flag=` values, and delimiter-separated values where a trailing space + /// would be incorrect. + pub fn nospace(mut self, nospace: bool) -> Self { + self.nospace = nospace; + self + } + /// Add a prefix to the value of completion candidate /// /// This is generally used for post-process by [`complete`][crate::engine::complete()] for @@ -104,6 +115,11 @@ impl CompletionCandidate { pub fn is_hide_set(&self) -> bool { self.hidden } + + /// Get whether trailing space should be suppressed + pub fn is_nospace_set(&self) -> bool { + self.nospace + } } impl> From for CompletionCandidate { diff --git a/clap_complete/src/engine/complete.rs b/clap_complete/src/engine/complete.rs index f6e638d8636..3925485af33 100644 --- a/clap_complete/src/engine/complete.rs +++ b/clap_complete/src/engine/complete.rs @@ -271,7 +271,7 @@ fn complete_option( completions.extend( complete_arg_value(value.to_str().ok_or(value), arg, current_dir) .into_iter() - .map(|comp| comp.add_prefix(format!("--{flag}="))), + .map(|comp| comp.add_prefix(format!("--{flag}=")).nospace(true)), ); } } else { @@ -308,7 +308,12 @@ fn complete_option( .into_iter() .map(|comp| { let sep = if has_equal { "=" } else { "" }; - comp.add_prefix(format!("-{leading_flags}{sep}")) + let comp = comp.add_prefix(format!("-{leading_flags}{sep}")); + if has_equal { + comp.nospace(true) + } else { + comp + } }), ); } else { @@ -393,7 +398,7 @@ fn complete_arg_value( if let Some(prefix) = prefix { values = values .into_iter() - .map(|comp| comp.add_prefix(prefix)) + .map(|comp| comp.add_prefix(prefix).nospace(true)) .collect(); } values = values @@ -478,9 +483,14 @@ fn longs_and_visible_aliases(p: &clap::Command) -> Vec { p.get_arguments() .filter_map(|a| { a.get_long_and_visible_aliases().map(|longs| { - longs - .into_iter() - .map(|s| populate_arg_candidate(CompletionCandidate::new(format!("--{s}")), a)) + longs.into_iter().map(|s| { + if a.is_require_equals_set() { + populate_arg_candidate(CompletionCandidate::new(format!("--{s}=")), a) + .nospace(true) + } else { + populate_arg_candidate(CompletionCandidate::new(format!("--{s}")), a) + } + }) }) }) .flatten() @@ -495,7 +505,14 @@ fn hidden_longs_aliases(p: &clap::Command) -> Vec { .filter_map(|a| { a.get_aliases().map(|longs| { longs.into_iter().map(|s| { - populate_arg_candidate(CompletionCandidate::new(format!("--{s}")), a).hide(true) + if a.is_require_equals_set() { + populate_arg_candidate(CompletionCandidate::new(format!("--{s}=")), a) + .hide(true) + .nospace(true) + } else { + populate_arg_candidate(CompletionCandidate::new(format!("--{s}")), a) + .hide(true) + } }) }) }) diff --git a/clap_complete/src/engine/custom.rs b/clap_complete/src/engine/custom.rs index 52f8117b92c..10280a58c36 100644 --- a/clap_complete/src/engine/custom.rs +++ b/clap_complete/src/engine/custom.rs @@ -336,7 +336,8 @@ pub(crate) fn complete_path( let mut suggestion = prefix.join(&raw_file_name); suggestion.push(""); // Ensure trailing `/` let candidate = CompletionCandidate::new(suggestion.as_os_str().to_owned()) - .hide(is_hidden(&raw_file_name)); + .hide(is_hidden(&raw_file_name)) + .nospace(true); if is_wanted(&entry.path()) { completions.push(candidate); diff --git a/clap_complete/src/env/shells.rs b/clap_complete/src/env/shells.rs index a46d5d439ee..d3c1254528d 100644 --- a/clap_complete/src/env/shells.rs +++ b/clap_complete/src/env/shells.rs @@ -51,8 +51,11 @@ _clap_complete_NAME() { ) ) if [[ $? != 0 ]]; then unset COMPREPLY - elif [[ $_CLAP_COMPLETE_SPACE == false ]] && [[ "${COMPREPLY-}" =~ [=/:]$ ]]; then - compopt -o nospace + elif [[ $_CLAP_COMPLETE_SPACE == false ]]; then + if [[ ${#COMPREPLY[@]} -gt 0 ]] && [[ "${COMPREPLY[-1]}" == "_CLAP_COMPLETE_NOSPACE" ]]; then + unset 'COMPREPLY[-1]' + compopt -o nospace + fi fi } if [[ "${BASH_VERSINFO[0]}" -eq 4 && "${BASH_VERSINFO[1]}" -ge 4 || "${BASH_VERSINFO[0]}" -gt 4 ]]; then @@ -90,12 +93,18 @@ fi let ifs: Option = std::env::var("_CLAP_IFS").ok().and_then(|i| i.parse().ok()); let completions = crate::engine::complete(cmd, args, index, current_dir)?; + let has_nospace = completions.iter().any(|c| c.is_nospace_set()); + for (i, candidate) in completions.iter().enumerate() { if i != 0 { write!(buf, "{}", ifs.as_deref().unwrap_or("\n"))?; } write!(buf, "{}", candidate.get_value().to_string_lossy())?; } + if has_nospace && !completions.is_empty() { + write!(buf, "{}", ifs.as_deref().unwrap_or("\n"))?; + write!(buf, "_CLAP_COMPLETE_NOSPACE")?; + } Ok(()) } } @@ -160,7 +169,14 @@ set edit:completion:arg-completer[BIN] = { |@words| var index = (count $words) set index = (- $index 1) - put (env _CLAP_IFS="\n" _CLAP_COMPLETE_INDEX=(to-string $index) VAR="elvish" COMPLETER -- $@words) | to-lines + env _CLAP_IFS="\n" _CLAP_COMPLETE_INDEX=(to-string $index) VAR="elvish" COMPLETER -- $@words | from-lines | each { |line| + if (str:has-prefix $line "\x1f") { + var value = (str:trim-prefix $line "\x1f") + edit:complex-candidate $value &code-suffix='' + } else { + put $line + } + } } "# .replace("COMPLETER", &completer) @@ -188,6 +204,9 @@ set edit:completion:arg-completer[BIN] = { |@words| if i != 0 { write!(buf, "{}", ifs.as_deref().unwrap_or("\n"))?; } + if candidate.is_nospace_set() { + write!(buf, "\x1f")?; + } write!(buf, "{}", candidate.get_value().to_string_lossy())?; } Ok(()) @@ -376,24 +395,18 @@ function _clap_dynamic_completer_NAME() { )}") if [[ -n $completions ]]; then - local -a dirs=() + local -a nospace=() local -a other=() local completion for completion in $completions; do - local value="${completion%%:*}" - if [[ "$value" == */ ]]; then - local dir_no_slash="${value%/}" - if [[ "$completion" == *:* ]]; then - local desc="${completion#*:}" - dirs+=("$dir_no_slash:$desc") - else - dirs+=("$dir_no_slash") - fi + if [[ "$completion" == $'\x1f'* ]]; then + completion="${completion:1}" + nospace+=("$completion") else other+=("$completion") fi done - [[ -n $dirs ]] && _describe 'values' dirs -S '/' -r '/' + [[ -n $nospace ]] && _describe 'values' nospace -S '' [[ -n $other ]] && _describe 'values' other fi } @@ -431,6 +444,11 @@ compdef _clap_dynamic_completer_NAME BIN"# if i != 0 { write!(buf, "{}", ifs.as_deref().unwrap_or("\n"))?; } + // Prefix nospace candidates with \x1f so the registration script can + // split them into a separate group with -S '' + if candidate.is_nospace_set() { + write!(buf, "\x1f")?; + } write!( buf, "{}", diff --git a/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/bash/.bashrc b/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/bash/.bashrc index 7ecc315c6ac..beb0a88793a 100644 --- a/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/bash/.bashrc +++ b/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/bash/.bashrc @@ -24,8 +24,11 @@ _clap_complete_exhaustive() { ) ) if [[ $? != 0 ]]; then unset COMPREPLY - elif [[ $_CLAP_COMPLETE_SPACE == false ]] && [[ "${COMPREPLY-}" =~ [=/:]$ ]]; then - compopt -o nospace + elif [[ $_CLAP_COMPLETE_SPACE == false ]]; then + if [[ ${#COMPREPLY[@]} -gt 0 ]] && [[ "${COMPREPLY[-1]}" == "_CLAP_COMPLETE_NOSPACE" ]]; then + unset 'COMPREPLY[-1]' + compopt -o nospace + fi fi } if [[ "${BASH_VERSINFO[0]}" -eq 4 && "${BASH_VERSINFO[1]}" -ge 4 || "${BASH_VERSINFO[0]}" -gt 4 ]]; then diff --git a/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/elvish/elvish/rc.elv b/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/elvish/elvish/rc.elv index 56fc3985798..1b0ccb8e7e2 100644 --- a/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/elvish/elvish/rc.elv +++ b/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/elvish/elvish/rc.elv @@ -5,7 +5,13 @@ set edit:completion:arg-completer[exhaustive] = { |@words| var index = (count $words) set index = (- $index 1) - put (env _CLAP_IFS="\n" _CLAP_COMPLETE_INDEX=(to-string $index) COMPLETE="elvish" exhaustive -- $@words) | to-lines + env _CLAP_IFS="\n" _CLAP_COMPLETE_INDEX=(to-string $index) COMPLETE="elvish" exhaustive -- $@words | from-lines | each { |line| + if (str:has-prefix $line "\x1f") { + var value = (str:trim-prefix $line "\x1f") + edit:complex-candidate $value &code-suffix='' + } else { + put $line + } + } } - diff --git a/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/zsh/zsh/_exhaustive b/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/zsh/zsh/_exhaustive index 0090fd7b5e1..e874dc986a5 100644 --- a/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/zsh/zsh/_exhaustive +++ b/clap_complete/tests/snapshots/home/dynamic-env/exhaustive/zsh/zsh/_exhaustive @@ -11,24 +11,18 @@ function _clap_dynamic_completer_exhaustive() { )}") if [[ -n $completions ]]; then - local -a dirs=() + local -a nospace=() local -a other=() local completion for completion in $completions; do - local value="${completion%%:*}" - if [[ "$value" == */ ]]; then - local dir_no_slash="${value%/}" - if [[ "$completion" == *:* ]]; then - local desc="${completion#*:}" - dirs+=("$dir_no_slash:$desc") - else - dirs+=("$dir_no_slash") - fi + if [[ "$completion" == $'\x1f'* ]]; then + completion="${completion:1}" + nospace+=("$completion") else other+=("$completion") fi done - [[ -n $dirs ]] && _describe 'values' dirs -S '/' -r '/' + [[ -n $nospace ]] && _describe 'values' nospace -S '' [[ -n $other ]] && _describe 'values' other fi } diff --git a/clap_complete/tests/snapshots/register_minimal.bash b/clap_complete/tests/snapshots/register_minimal.bash index 1583de5f0b6..4be5746c279 100644 --- a/clap_complete/tests/snapshots/register_minimal.bash +++ b/clap_complete/tests/snapshots/register_minimal.bash @@ -1,6 +1,6 @@ _clap_complete_my_app() { - local IFS=$'/013' + local IFS=$'\013' local _CLAP_COMPLETE_INDEX=${COMP_CWORD} local _CLAP_COMPLETE_COMP_TYPE=${COMP_TYPE} if compopt +o nospace 2> /dev/null; then @@ -8,17 +8,20 @@ _clap_complete_my_app() { else local _CLAP_COMPLETE_SPACE=true fi - COMPREPLY=( $( / - IFS="$IFS" / - _CLAP_COMPLETE_INDEX="$_CLAP_COMPLETE_INDEX" / - _CLAP_COMPLETE_COMP_TYPE="$_CLAP_COMPLETE_COMP_TYPE" / - _CLAP_COMPLETE_SPACE="$_CLAP_COMPLETE_SPACE" / - "my-app" complete bash -- "${COMP_WORDS[@]}" / + COMPREPLY=( $( \ + IFS="$IFS" \ + _CLAP_COMPLETE_INDEX="$_CLAP_COMPLETE_INDEX" \ + _CLAP_COMPLETE_COMP_TYPE="$_CLAP_COMPLETE_COMP_TYPE" \ + _CLAP_COMPLETE_SPACE="$_CLAP_COMPLETE_SPACE" \ + "my-app" complete bash -- "${COMP_WORDS[@]}" \ ) ) if [[ $? != 0 ]]; then unset COMPREPLY - elif [[ $_CLAP_COMPLETE_SPACE == false ]] && [[ "${COMPREPLY-}" =~ [=/:]$ ]]; then - compopt -o nospace + elif [[ $_CLAP_COMPLETE_SPACE == false ]]; then + if [[ ${#COMPREPLY[@]} -gt 0 ]] && [[ "${COMPREPLY[-1]}" == "_CLAP_COMPLETE_NOSPACE" ]]; then + unset 'COMPREPLY[-1]' + compopt -o nospace + fi fi } if [[ "${BASH_VERSINFO[0]}" -eq 4 && "${BASH_VERSINFO[1]}" -ge 4 || "${BASH_VERSINFO[0]}" -gt 4 ]]; then diff --git a/clap_complete/tests/testsuite/engine.rs b/clap_complete/tests/testsuite/engine.rs index b8f03ee07e2..eaa9b75f26f 100644 --- a/clap_complete/tests/testsuite/engine.rs +++ b/clap_complete/tests/testsuite/engine.rs @@ -1478,3 +1478,166 @@ fn complete(cmd: &mut Command, args: impl AsRef, current_dir: Option<&Path> .collect::>() .join("\n") } + +/// Like `complete` but includes `[nospace]` marker for candidates with nospace set +fn complete_with_nospace( + cmd: &mut Command, + args: impl AsRef, + current_dir: Option<&Path>, +) -> String { + let input = args.as_ref(); + let mut args = vec![std::ffi::OsString::from(cmd.get_name())]; + let arg_index; + + if let Some((prior, after)) = input.split_once("[TAB]") { + args.extend(prior.split_whitespace().map(From::from)); + if prior.ends_with(char::is_whitespace) { + args.push(std::ffi::OsString::default()); + } + arg_index = args.len() - 1; + args.extend(after.split_whitespace().map(From::from)); + } else { + args.extend(input.split_whitespace().map(From::from)); + if input.ends_with(char::is_whitespace) { + args.push(std::ffi::OsString::default()); + } + arg_index = args.len() - 1; + } + + clap_complete::engine::complete(cmd, args, arg_index, current_dir) + .unwrap() + .into_iter() + .map(|candidate| { + let compl = candidate.get_value().to_str().unwrap(); + let nospace = if candidate.is_nospace_set() { + "[nospace]" + } else { + "" + }; + if let Some(help) = candidate.get_help() { + format!("{compl}\t{help}{nospace}") + } else if !nospace.is_empty() { + format!("{compl}\t{nospace}") + } else { + compl.to_owned() + } + }) + .collect::>() + .join("\n") +} + +#[test] +fn nospace_on_directory_completions() { + let testdir = snapbox::dir::DirRoot::mutable_temp().unwrap(); + let testdir_path = testdir.path().unwrap(); + + fs::write(testdir_path.join("a_file"), "").unwrap(); + fs::create_dir_all(testdir_path.join("b_dir")).unwrap(); + + let mut cmd = Command::new("dynamic").arg( + clap::Arg::new("input") + .long("input") + .value_hint(clap::ValueHint::AnyPath), + ); + + assert_data_eq!( + complete_with_nospace(&mut cmd, "--input [TAB]", Some(testdir_path)), + snapbox::str![[r#" +. +a_file +b_dir/ [nospace] +"#]], + ); +} + +#[test] +fn nospace_on_equals_value_completions() { + let mut cmd = Command::new("dynamic").arg( + clap::Arg::new("format") + .long("format") + .value_parser(["json", "yaml"]), + ); + + assert_data_eq!( + complete_with_nospace(&mut cmd, "--format=[TAB]", None), + snapbox::str![[r#" +--format=json [nospace] +--format=yaml [nospace] +"#]], + ); +} + +#[test] +fn nospace_on_short_equals_value_completions() { + let mut cmd = Command::new("dynamic").arg( + clap::Arg::new("format") + .long("format") + .short('F') + .value_parser(["json", "yaml"]), + ); + + assert_data_eq!( + complete_with_nospace(&mut cmd, "-F=[TAB]", None), + snapbox::str![[r#" +-F=json [nospace] +-F=yaml [nospace] +"#]], + ); + + // Without equals, no nospace + assert_data_eq!( + complete_with_nospace(&mut cmd, "-F [TAB]", None), + snapbox::str![[r#" +json +yaml +"#]], + ); +} + +#[test] +fn nospace_on_require_equals_options() { + let mut cmd = Command::new("dynamic").arg( + clap::Arg::new("format") + .long("format") + .require_equals(true) + .value_parser(["json", "yaml"]), + ); + + assert_data_eq!( + complete_with_nospace(&mut cmd, "--[TAB]", None), + snapbox::str![[r#" +--format= [nospace] +--help Print help +"#]], + ); +} + +#[test] +fn nospace_on_delimiter_completions() { + let mut cmd = Command::new("delimiter").arg( + clap::Arg::new("delimiter") + .long("delimiter") + .value_parser(["comma", "space", "tab"]) + .value_delimiter(','), + ); + + // First value: no nospace (no prefix) + assert_data_eq!( + complete_with_nospace(&mut cmd, "--delimiter [TAB]", None), + snapbox::str![[r#" +comma +space +tab +"#]], + ); + + // After delimiter: nospace because of prefix + assert_data_eq!( + complete_with_nospace(&mut cmd, "--delimiter comma,[TAB]", None), + snapbox::str![[r#" +comma,comma [nospace] +comma,space [nospace] +comma,tab [nospace] +"#]], + ); +}