From 3f398350b6a87a219df3ab38d71d0408b977c65a Mon Sep 17 00:00:00 2001 From: JamBalaya56562 Date: Sat, 1 Aug 2026 05:48:02 +0900 Subject: [PATCH 1/2] fix(complete): use `type -P` so the CLI-presence guard ignores shell functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `type -p` returns exit status 0 for a shell function (it just prints nothing), so the generated bash completion guard passed even when the usage CLI was not installed. Environments that define a `usage` shell function — oh-my-bash does — therefore fell through the guard and failed later with an unrelated error. `type -P` forces a PATH search. The same file already used `type -P` for command path resolution; only the guards were missed. --- cli/assets/completions/usage.bash | 2 +- cli/assets/completions/usage.fish | 2 +- lib/src/complete/bash.rs | 4 ++-- lib/src/complete/fish.rs | 2 +- .../usage__complete__bash__tests__complete_bash-2.snap | 2 +- .../usage__complete__bash__tests__complete_bash-3.snap | 2 +- .../usage__complete__bash__tests__complete_bash.snap | 2 +- .../usage__complete__bash__tests__complete_bash_init.snap | 2 +- .../usage__complete__fish__tests__complete_fish-2.snap | 2 +- .../usage__complete__fish__tests__complete_fish-3.snap | 2 +- .../usage__complete__fish__tests__complete_fish.snap | 2 +- 11 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cli/assets/completions/usage.bash b/cli/assets/completions/usage.bash index 4a30b6f7..eb1dbf38 100644 --- a/cli/assets/completions/usage.bash +++ b/cli/assets/completions/usage.bash @@ -1,6 +1,6 @@ # @generated by usage-cli from usage spec _usage() { - if ! type -p usage &> /dev/null; then + if ! type -P usage &> /dev/null; then echo >&2 echo "Error: usage CLI not found. This is required for completions to work in usage." >&2 echo "See https://usage.jdx.dev for more information." >&2 diff --git a/cli/assets/completions/usage.fish b/cli/assets/completions/usage.fish index 26e5e5fe..588db4b3 100644 --- a/cli/assets/completions/usage.fish +++ b/cli/assets/completions/usage.fish @@ -1,7 +1,7 @@ # @generated by usage-cli from usage spec # if "usage" is not installed show an error -if ! type -p usage &> /dev/null +if ! type -P usage &> /dev/null echo >&2 echo "Error: usage CLI not found. This is required for completions to work in usage." >&2 echo "See https://usage.jdx.dev for more information." >&2 diff --git a/lib/src/complete/bash.rs b/lib/src/complete/bash.rs index 6d34e35e..5a405ad2 100644 --- a/lib/src/complete/bash.rs +++ b/lib/src/complete/bash.rs @@ -23,7 +23,7 @@ pub fn complete_bash(opts: &CompleteOptions) -> String { }; out.push(format!( r#"_{bin_snake}() {{ - if ! type -p {usage_bin} &> /dev/null; then + if ! type -P {usage_bin} &> /dev/null; then echo >&2 echo "Error: {usage_bin} CLI not found. This is required for completions to work in {bin}." >&2 echo "See https://usage.jdx.dev for more information." >&2 @@ -149,7 +149,7 @@ _usage_default_complete() {{ if [[ -n "$cmdpath" && -f "$cmdpath" ]]; then local first if IFS= read -r first < "$cmdpath" 2>/dev/null && [[ "$first" == "#!"*"usage"* ]]; then - if type -p {usage_bin} &> /dev/null; then + if type -P {usage_bin} &> /dev/null; then local IFS=$'\n' # shellcheck disable=SC2207 COMPREPLY=( $(command {usage_bin} complete-word --shell bash -f "$cmdpath" --cword="$COMP_CWORD" -- "${{COMP_WORDS[@]}}") ) diff --git a/lib/src/complete/fish.rs b/lib/src/complete/fish.rs index d39e6633..40040a45 100644 --- a/lib/src/complete/fish.rs +++ b/lib/src/complete/fish.rs @@ -20,7 +20,7 @@ pub fn complete_fish(opts: &CompleteOptions) -> String { format!( r#" # if "{usage_bin}" is not installed show an error -if ! type -p {usage_bin} &> /dev/null +if ! type -P {usage_bin} &> /dev/null echo >&2 echo "Error: {usage_bin} CLI not found. This is required for completions to work in {bin}." >&2 echo "See https://usage.jdx.dev for more information." >&2 diff --git a/lib/src/complete/snapshots/usage__complete__bash__tests__complete_bash-2.snap b/lib/src/complete/snapshots/usage__complete__bash__tests__complete_bash-2.snap index 0828a40d..4b06ee9a 100644 --- a/lib/src/complete/snapshots/usage__complete__bash__tests__complete_bash-2.snap +++ b/lib/src/complete/snapshots/usage__complete__bash__tests__complete_bash-2.snap @@ -4,7 +4,7 @@ expression: "complete_bash(&CompleteOptions\n{\n usage_bin: \"usage\".to_stri --- # @generated by usage-cli from usage spec _mycli() { - if ! type -p usage &> /dev/null; then + if ! type -P usage &> /dev/null; then echo >&2 echo "Error: usage CLI not found. This is required for completions to work in mycli." >&2 echo "See https://usage.jdx.dev for more information." >&2 diff --git a/lib/src/complete/snapshots/usage__complete__bash__tests__complete_bash-3.snap b/lib/src/complete/snapshots/usage__complete__bash__tests__complete_bash-3.snap index 82bfbbe2..a4298150 100644 --- a/lib/src/complete/snapshots/usage__complete__bash__tests__complete_bash-3.snap +++ b/lib/src/complete/snapshots/usage__complete__bash__tests__complete_bash-3.snap @@ -4,7 +4,7 @@ expression: "complete_bash(&CompleteOptions\n{\n usage_bin: \"usage\".to_stri --- # @generated by usage-cli from usage spec _mycli() { - if ! type -p usage &> /dev/null; then + if ! type -P usage &> /dev/null; then echo >&2 echo "Error: usage CLI not found. This is required for completions to work in mycli." >&2 echo "See https://usage.jdx.dev for more information." >&2 diff --git a/lib/src/complete/snapshots/usage__complete__bash__tests__complete_bash.snap b/lib/src/complete/snapshots/usage__complete__bash__tests__complete_bash.snap index 0045774f..92cb7698 100644 --- a/lib/src/complete/snapshots/usage__complete__bash__tests__complete_bash.snap +++ b/lib/src/complete/snapshots/usage__complete__bash__tests__complete_bash.snap @@ -4,7 +4,7 @@ expression: "complete_bash(&CompleteOptions\n{\n usage_bin: \"usage\".to_stri --- # @generated by usage-cli from usage spec _mycli() { - if ! type -p usage &> /dev/null; then + if ! type -P usage &> /dev/null; then echo >&2 echo "Error: usage CLI not found. This is required for completions to work in mycli." >&2 echo "See https://usage.jdx.dev for more information." >&2 diff --git a/lib/src/complete/snapshots/usage__complete__bash__tests__complete_bash_init.snap b/lib/src/complete/snapshots/usage__complete__bash__tests__complete_bash_init.snap index c441200d..1b9c4b68 100644 --- a/lib/src/complete/snapshots/usage__complete__bash__tests__complete_bash_init.snap +++ b/lib/src/complete/snapshots/usage__complete__bash__tests__complete_bash_init.snap @@ -37,7 +37,7 @@ _usage_default_complete() { if [[ -n "$cmdpath" && -f "$cmdpath" ]]; then local first if IFS= read -r first < "$cmdpath" 2>/dev/null && [[ "$first" == "#!"*"usage"* ]]; then - if type -p usage &> /dev/null; then + if type -P usage &> /dev/null; then local IFS=$'\n' # shellcheck disable=SC2207 COMPREPLY=( $(command usage complete-word --shell bash -f "$cmdpath" --cword="$COMP_CWORD" -- "${COMP_WORDS[@]}") ) diff --git a/lib/src/complete/snapshots/usage__complete__fish__tests__complete_fish-2.snap b/lib/src/complete/snapshots/usage__complete__fish__tests__complete_fish-2.snap index ba5cc75c..f1954a66 100644 --- a/lib/src/complete/snapshots/usage__complete__fish__tests__complete_fish-2.snap +++ b/lib/src/complete/snapshots/usage__complete__fish__tests__complete_fish-2.snap @@ -5,7 +5,7 @@ expression: "complete_fish(&CompleteOptions\n{\n usage_bin: \"usage\".to_stri # @generated by usage-cli from usage spec # if "usage" is not installed show an error -if ! type -p usage &> /dev/null +if ! type -P usage &> /dev/null echo >&2 echo "Error: usage CLI not found. This is required for completions to work in mycli." >&2 echo "See https://usage.jdx.dev for more information." >&2 diff --git a/lib/src/complete/snapshots/usage__complete__fish__tests__complete_fish-3.snap b/lib/src/complete/snapshots/usage__complete__fish__tests__complete_fish-3.snap index 17b3ec41..dd326e6e 100644 --- a/lib/src/complete/snapshots/usage__complete__fish__tests__complete_fish-3.snap +++ b/lib/src/complete/snapshots/usage__complete__fish__tests__complete_fish-3.snap @@ -5,7 +5,7 @@ expression: "complete_fish(&CompleteOptions\n{\n usage_bin: \"usage\".to_stri # @generated by usage-cli from usage spec # if "usage" is not installed show an error -if ! type -p usage &> /dev/null +if ! type -P usage &> /dev/null echo >&2 echo "Error: usage CLI not found. This is required for completions to work in mycli." >&2 echo "See https://usage.jdx.dev for more information." >&2 diff --git a/lib/src/complete/snapshots/usage__complete__fish__tests__complete_fish.snap b/lib/src/complete/snapshots/usage__complete__fish__tests__complete_fish.snap index a2d5522b..a617ccba 100644 --- a/lib/src/complete/snapshots/usage__complete__fish__tests__complete_fish.snap +++ b/lib/src/complete/snapshots/usage__complete__fish__tests__complete_fish.snap @@ -5,7 +5,7 @@ expression: "complete_fish(&CompleteOptions\n{\n usage_bin: \"usage\".to_stri # @generated by usage-cli from usage spec # if "usage" is not installed show an error -if ! type -p usage &> /dev/null +if ! type -P usage &> /dev/null echo >&2 echo "Error: usage CLI not found. This is required for completions to work in mycli." >&2 echo "See https://usage.jdx.dev for more information." >&2 From 097e6fc7f2a90b8a1bfd7f90423b43f9d36f96da Mon Sep 17 00:00:00 2001 From: JamBalaya56562 Date: Sat, 1 Aug 2026 06:59:05 +0900 Subject: [PATCH 2/2] fix(complete): stop shell functions from intercepting the usage CLI Follow-up to the `type -P` guard fix, covering two collision cases the guard alone does not: - `complete_fish_init()` still probed with `type -q`, which succeeds on a shell function just like `type -p` did. Use `type -P`. - The guard only proves an executable exists, not that a bare `usage` would reach it. A shell function shadowing a real executable still intercepted `usage --usage-spec`, so the spec file was filled with the function`s output. The shipped completions now call `command usage`. Left caller-provided `--usage-cmd` strings alone (nu prefixes them with `^`, so blanket rewriting would break). Adds behavioral tests for both cases on the bash and fish paths. --- cli/assets/completions/_usage | 2 +- cli/assets/completions/usage.bash | 2 +- cli/assets/completions/usage.fish | 2 +- cli/tests/shell_completions_integration.rs | 338 ++++++++++++++++++ lib/src/complete/fish.rs | 4 +- ...lete__fish__tests__complete_fish_init.snap | 4 +- mise.toml | 9 +- 7 files changed, 353 insertions(+), 8 deletions(-) diff --git a/cli/assets/completions/_usage b/cli/assets/completions/_usage index d2d43910..75d06c92 100644 --- a/cli/assets/completions/_usage +++ b/cli/assets/completions/_usage @@ -27,7 +27,7 @@ _usage() { local spec_dir="${XDG_CACHE_HOME:-$HOME/.cache}/usage" [[ -d "$spec_dir" ]] || mkdir -p -m 700 "$spec_dir" local spec_file="$spec_dir/usage__usage_spec_usage.spec" - usage --usage-spec >| "$spec_file" + command usage --usage-spec >| "$spec_file" local -a values=() descs=() inserts=() local needs_menu=0 line while IFS= read -r line; do diff --git a/cli/assets/completions/usage.bash b/cli/assets/completions/usage.bash index eb1dbf38..aabbd8f8 100644 --- a/cli/assets/completions/usage.bash +++ b/cli/assets/completions/usage.bash @@ -12,7 +12,7 @@ _usage() { local spec_dir="${XDG_CACHE_HOME:-$HOME/.cache}/usage" [[ -d "$spec_dir" ]] || mkdir -p -m 700 "$spec_dir" local spec_file="$spec_dir/usage__usage_spec_usage.spec" - usage --usage-spec >| "$spec_file" + command usage --usage-spec >| "$spec_file" # shellcheck disable=SC2207 _comp_compgen -- -W "$(command usage complete-word --shell bash -f "$spec_file" --cword="$cword" -- "${words[@]}")" _comp_ltrim_colon_completions "$cur" diff --git a/cli/assets/completions/usage.fish b/cli/assets/completions/usage.fish index 588db4b3..bdb9c9f5 100644 --- a/cli/assets/completions/usage.fish +++ b/cli/assets/completions/usage.fish @@ -10,7 +10,7 @@ end set -l spec_dir (if set -q XDG_CACHE_HOME; echo $XDG_CACHE_HOME; else; echo $HOME/.cache; end)/usage test -d "$spec_dir"; or mkdir -p -m 700 "$spec_dir" set -l spec_file "$spec_dir/usage__usage_spec_usage.spec" -usage --usage-spec | string collect > "$spec_file" +command usage --usage-spec | string collect > "$spec_file" set -l tokens if commandline -x >/dev/null 2>&1 diff --git a/cli/tests/shell_completions_integration.rs b/cli/tests/shell_completions_integration.rs index 2282e954..47581270 100644 --- a/cli/tests/shell_completions_integration.rs +++ b/cli/tests/shell_completions_integration.rs @@ -18,6 +18,16 @@ fn skip_if_shell_missing(shell: &str) -> bool { true } +/// Path to a checked-in generated completion under `cli/assets/completions/`. +/// These are what users actually source, so the shell-function collision tests +/// assert against them rather than a freshly generated stand-in. +fn checked_in_completion(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("assets") + .join("completions") + .join(name) +} + /// Helper to run usage complete-word and return stdout fn run_complete_word(usage_bin: &Path, shell: &str, spec_file: &Path, words: &[&str]) -> String { let mut args = vec![ @@ -1373,6 +1383,334 @@ echo "[foo]" (complete -C 'ex --f') let _ = fs::remove_dir_all(&temp_dir); } +// --------------------------------------------------------------------------- +// Shell-function collision tests. +// +// Two distinct ways a shell function can defeat the "is the usage CLI +// installed?" logic: +// +// 1. Function only, no executable. `type -p` (bash) and `type -q` (fish) +// both succeed on a function, so the guard passed and the failure +// surfaced later as something unrelated. `type -P` searches $PATH only. +// oh-my-bash defines a `usage` function, which is how this was found. +// +// 2. Function shadowing a real executable. The guard is satisfied honestly, +// but a bare `usage --usage-spec` still resolves to the function, so the +// spec file gets filled with the function's output. The shipped +// completions call `command usage` to bypass function lookup. +// --------------------------------------------------------------------------- + +#[test] +fn test_bash_guard_rejects_shell_function_masquerading_as_cli() { + if skip_if_shell_missing("bash") { + return; + } + + let usage_bin = build_usage_binary(); + let temp_dir = env::temp_dir().join(format!("usage_bash_guard_test_{}", std::process::id())); + fs::create_dir_all(&temp_dir).unwrap(); + + // `--usage-bin` names a probe that cannot be on $PATH, so the shell + // function defined below is the only thing that could satisfy the guard. + let output = Command::new(&usage_bin) + .args([ + "generate", + "completion", + "bash", + "testcli", + "--usage-bin", + "usage_guard_probe", + "--usage-cmd", + "command usage_guard_probe --usage-spec", + ]) + .output() + .expect("Failed to generate bash completion"); + let comp_file = temp_dir.join("testcli.bash"); + fs::write(&comp_file, &output.stdout).unwrap(); + + let test_script = format!( + r#"#!/usr/bin/env bash +usage_guard_probe() {{ echo "I am a function"; }} +source "{comp}" +_testcli testcli "" testcli 1 +echo "GUARD_EXIT=$?" +"#, + comp = comp_file.display(), + ); + let script_file = temp_dir.join("test.sh"); + fs::write(&script_file, &test_script).unwrap(); + + let result = Command::new("bash") + .arg(script_file.to_str().unwrap()) + .output() + .expect("Failed to run bash guard test"); + let stdout = String::from_utf8_lossy(&result.stdout); + let stderr = String::from_utf8_lossy(&result.stderr); + + assert!( + stdout.contains("GUARD_EXIT=1"), + "guard should return 1 when only a shell function shadows the CLI.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!( + stderr.contains("usage_guard_probe CLI not found"), + "guard should explain the CLI is missing.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[test] +fn test_bash_self_completion_uses_executable_not_shadowing_function() { + if skip_if_shell_missing("bash") { + return; + } + + let usage_bin = build_usage_binary(); + let temp_dir = env::temp_dir().join(format!("usage_bash_shadow_test_{}", std::process::id())); + fs::create_dir_all(&temp_dir).unwrap(); + + // The real usage binary is on $PATH *and* a `usage` function is defined, + // so the guard passes legitimately — only the spec call can go wrong. + // bash-completion isn't loaded here; stub the three helpers so the spec + // write is the only thing under test. + let test_script = format!( + r#"#!/usr/bin/env bash +export PATH="{usage_dir}:$PATH" +export XDG_CACHE_HOME="{cache}" +usage() {{ echo "FUNCTION_MARKER"; }} +_comp_initialize() {{ return 0; }} +_comp_compgen() {{ return 0; }} +_comp_ltrim_colon_completions() {{ return 0; }} +source "{asset}" +_usage usage "" usage 1 +echo "SPEC_BEGIN" +cat "$XDG_CACHE_HOME/usage/usage__usage_spec_usage.spec" +"#, + usage_dir = usage_bin.parent().unwrap().display(), + cache = temp_dir.display(), + asset = checked_in_completion("usage.bash").display(), + ); + let script_file = temp_dir.join("test.sh"); + fs::write(&script_file, &test_script).unwrap(); + + let result = Command::new("bash") + .arg(script_file.to_str().unwrap()) + .output() + .expect("Failed to run bash shadow test"); + let stdout = String::from_utf8_lossy(&result.stdout); + let stderr = String::from_utf8_lossy(&result.stderr); + + assert!( + !stdout.contains("FUNCTION_MARKER"), + "spec file was written by the shell function instead of the CLI.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!( + stdout.contains("bin usage"), + "spec file should hold the real usage spec.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[test] +fn test_bash_init_guard_rejects_shell_function() { + if skip_if_shell_missing("bash") { + return; + } + + let usage_bin = build_usage_binary(); + let (temp_dir, bin_dir, init_script) = + stage_init_test_env(&usage_bin, "bash", "bash_guard_init"); + + // $PATH deliberately omits the usage binary. A pre-registered `complete -D` + // handler gives us a positive signal: the init handler must chain to it + // rather than dispatch to a `usage` that is only a shell function. + let test_script = format!( + r#"#!/usr/bin/env bash +export PATH="{bin_dir}:/usr/bin:/bin" +usage() {{ echo "I am a function"; }} +_test_chained_handler() {{ echo "FELL_THROUGH"; }} +complete -D -F _test_chained_handler +source "{init_script}" + +COMP_WORDS=(ex "") +COMP_CWORD=1 +COMPREPLY=() +_usage_default_complete ex "" ex +echo "INIT_EXIT=$?" +"#, + bin_dir = bin_dir.display(), + init_script = init_script.display(), + ); + let script_file = temp_dir.join("test.sh"); + fs::write(&script_file, &test_script).unwrap(); + + let result = Command::new("bash") + .arg(script_file.to_str().unwrap()) + .output() + .expect("Failed to run bash init guard test"); + let stdout = String::from_utf8_lossy(&result.stdout); + let stderr = String::from_utf8_lossy(&result.stderr); + + assert!( + stdout.contains("FELL_THROUGH"), + "init handler should chain to the previous default handler instead of dispatching to a shell function.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!( + !stderr.contains("command not found"), + "init handler dispatched to a missing CLI.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[test] +fn test_fish_guard_rejects_shell_function_masquerading_as_cli() { + if skip_if_shell_missing("fish") { + return; + } + + let usage_bin = build_usage_binary(); + let temp_dir = env::temp_dir().join(format!("usage_fish_guard_test_{}", std::process::id())); + fs::create_dir_all(&temp_dir).unwrap(); + + let output = Command::new(&usage_bin) + .args([ + "generate", + "completion", + "fish", + "testcli", + "--usage-bin", + "usage_guard_probe", + "--usage-cmd", + "command usage_guard_probe --usage-spec", + ]) + .output() + .expect("Failed to generate fish completion"); + let comp_file = temp_dir.join("testcli.fish"); + fs::write(&comp_file, &output.stdout).unwrap(); + + let test_script = format!( + r#"#!/usr/bin/env fish +function usage_guard_probe + echo "I am a function" +end +source "{comp}" +echo "[completions]" (complete -c testcli | string collect) +"#, + comp = comp_file.display(), + ); + let script_file = temp_dir.join("test.fish"); + fs::write(&script_file, &test_script).unwrap(); + + let result = Command::new("fish") + .arg(script_file.to_str().unwrap()) + .output() + .expect("Failed to run fish guard test"); + let stdout = String::from_utf8_lossy(&result.stdout); + let stderr = String::from_utf8_lossy(&result.stderr); + + assert!( + stderr.contains("usage_guard_probe CLI not found"), + "guard should explain the CLI is missing.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!( + stdout.contains("[completions]") && !stdout.contains("complete-word"), + "no completion should be registered when the CLI is absent.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[test] +fn test_fish_self_completion_uses_executable_not_shadowing_function() { + if skip_if_shell_missing("fish") { + return; + } + + let usage_bin = build_usage_binary(); + let temp_dir = env::temp_dir().join(format!("usage_fish_shadow_test_{}", std::process::id())); + fs::create_dir_all(&temp_dir).unwrap(); + + let test_script = format!( + r#"#!/usr/bin/env fish +set -gx PATH "{usage_dir}" /usr/bin /bin +set -gx XDG_CACHE_HOME "{cache}" +function usage + echo "FUNCTION_MARKER" +end +source "{asset}" +echo "SPEC_BEGIN" +cat "$XDG_CACHE_HOME/usage/usage__usage_spec_usage.spec" +"#, + usage_dir = usage_bin.parent().unwrap().display(), + cache = temp_dir.display(), + asset = checked_in_completion("usage.fish").display(), + ); + let script_file = temp_dir.join("test.fish"); + fs::write(&script_file, &test_script).unwrap(); + + let result = Command::new("fish") + .arg(script_file.to_str().unwrap()) + .output() + .expect("Failed to run fish shadow test"); + let stdout = String::from_utf8_lossy(&result.stdout); + let stderr = String::from_utf8_lossy(&result.stderr); + + assert!( + !stdout.contains("FUNCTION_MARKER"), + "spec file was written by the shell function instead of the CLI.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!( + stdout.contains("bin usage"), + "spec file should hold the real usage spec.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[test] +fn test_fish_init_guard_rejects_shell_function() { + if skip_if_shell_missing("fish") { + return; + } + + let usage_bin = build_usage_binary(); + let (temp_dir, bin_dir, init_script) = + stage_init_test_env(&usage_bin, "fish", "fish_guard_init"); + + // $PATH omits the usage binary but still holds the `ex` shebang script, so + // the scan would register `ex` if the guard let a shell function through. + let test_script = format!( + r#"#!/usr/bin/env fish +set -gx PATH "{bin_dir}" /usr/bin /bin +function usage + echo "I am a function" +end +source "{init_script}" +echo "[registered]" (complete -c ex | string collect) +"#, + bin_dir = bin_dir.display(), + init_script = init_script.display(), + ); + let script_file = temp_dir.join("test.fish"); + fs::write(&script_file, &test_script).unwrap(); + + let result = Command::new("fish") + .arg(script_file.to_str().unwrap()) + .output() + .expect("Failed to run fish init guard test"); + let stdout = String::from_utf8_lossy(&result.stdout); + let stderr = String::from_utf8_lossy(&result.stderr); + + assert!( + stdout.contains("[registered]") && !stdout.contains("complete-word"), + "init scan should register nothing when only a shell function shadows the CLI.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + #[test] fn test_complete_path_adds_trailing_slash_for_directories() { let usage_bin = build_usage_binary(); diff --git a/lib/src/complete/fish.rs b/lib/src/complete/fish.rs index 40040a45..dfd71e5e 100644 --- a/lib/src/complete/fish.rs +++ b/lib/src/complete/fish.rs @@ -111,7 +111,9 @@ pub fn complete_fish_init(usage_bin: &str) -> String { # shebang. function __usage_register_shebang_completions - if not type -q {usage_bin} + # `type -P` (not `-q`/`-p`) so a shell function named `{usage_bin}` can't + # satisfy the check — only a real executable on $PATH does. + if not type -P {usage_bin} &> /dev/null return 0 end # `commandline -x` (fish 3.4+) tokenizes quoted/complex arguments more diff --git a/lib/src/complete/snapshots/usage__complete__fish__tests__complete_fish_init.snap b/lib/src/complete/snapshots/usage__complete__fish__tests__complete_fish_init.snap index 325599cd..5bfd8b14 100644 --- a/lib/src/complete/snapshots/usage__complete__fish__tests__complete_fish_init.snap +++ b/lib/src/complete/snapshots/usage__complete__fish__tests__complete_fish_init.snap @@ -8,7 +8,9 @@ expression: "complete_fish_init(\"usage\")" # shebang. function __usage_register_shebang_completions - if not type -q usage + # `type -P` (not `-q`/`-p`) so a shell function named `usage` can't + # satisfy the check — only a real executable on $PATH does. + if not type -P usage &> /dev/null return 0 end # `commandline -x` (fish 3.4+) tokenizes quoted/complex arguments more diff --git a/mise.toml b/mise.toml index 380f8e41..ccc1c924 100644 --- a/mise.toml +++ b/mise.toml @@ -99,9 +99,12 @@ run = [ 'usage g json -f cli/usage.usage.kdl > docs/cli/reference/commands.json', 'usage g md -mf cli/usage.usage.kdl --out-dir docs/cli/reference --url-prefix /cli/reference --replace-pre-with-code-fences', 'usage g manpage -f cli/usage.usage.kdl -o cli/assets/usage.1', - 'usage g completion bash usage --usage-cmd "usage --usage-spec" > cli/assets/completions/usage.bash', - 'usage g completion fish usage --usage-cmd "usage --usage-spec" > cli/assets/completions/usage.fish', - 'usage g completion zsh usage --usage-cmd "usage --usage-spec" > cli/assets/completions/_usage', + # `command usage` so a shell function named `usage` can't intercept the spec + # call — the guard above only proves an executable exists, not that a bare + # `usage` would reach it. + 'usage g completion bash usage --usage-cmd "command usage --usage-spec" > cli/assets/completions/usage.bash', + 'usage g completion fish usage --usage-cmd "command usage --usage-spec" > cli/assets/completions/usage.fish', + 'usage g completion zsh usage --usage-cmd "command usage --usage-spec" > cli/assets/completions/_usage', 'mise run lint-fix', ]