From 59b476cf4ba0efd68fcfff73a5b23ded37a1057b Mon Sep 17 00:00:00 2001 From: Tobias Hunger Date: Fri, 3 Jul 2026 09:20:47 +0000 Subject: [PATCH 1/3] fix(completion): complete tasks after options in `pixi run` The bash completion for `pixi run` only suggested task names directly after `run`. Preserve the clap-generated `case "${prev}"` block and run task completion after it, so tasks are suggested at any position, e.g. after `-e ` or `--frozen`. Also deduplicate task names that appear in multiple environments. Fixes #6494 --- crates/pixi_cli/src/completion.rs | 79 ++++++++++--------- ...i__completion__tests__bash_completion.snap | 39 ++++++--- crates/pixi_cli/src/task.rs | 1 + tests/integration_python/test_main_cli.py | 51 ++++++++++++ 4 files changed, 122 insertions(+), 48 deletions(-) diff --git a/crates/pixi_cli/src/completion.rs b/crates/pixi_cli/src/completion.rs index 024002f36e..5f52d8af78 100644 --- a/crates/pixi_cli/src/completion.rs +++ b/crates/pixi_cli/src/completion.rs @@ -64,7 +64,7 @@ pub fn execute(args: Args) -> miette::Result<()> { // For supported shells, modify the script to include more context sensitive completions. let script = match args.shell { - Shell::Bash => replace_bash_completion(&script), + Shell::Bash => replace_bash_completion(&script, pixi_utils::executable_name()), Shell::Zsh => replace_zsh_completion(&script), Shell::Fish => replace_fish_completion(&script), Shell::Nushell => replace_nushell_completion(&script), @@ -88,31 +88,32 @@ fn get_completion_script(shell: Shell) -> String { } /// Replace the parts of the bash completion script that need different functionality. -fn replace_bash_completion(script: &str) -> Cow<'_, str> { +fn replace_bash_completion<'a>(script: &'a str, bin_name: &str) -> Cow<'a, str> { // Adds tab completion to the pixi run command. // NOTE THIS IS FORMATTED BY HAND // Replace the '-' with '__' since that's what clap's generator does as well for Bash Shell completion. - let bin_name: &str = pixi_utils::executable_name(); let clap_name = bin_name.replace("-", "__"); // clap_complete >=4.6.2 separates each segment of the bin name from each // subcommand with an explicit `__subcmd__` marker, so the function for the // `run` subcommand of `pixi` is `pixi__subcmd__run`. let func_prefix = clap_name.replace("__", "__subcmd__"); + // Keep the generated `case "${prev}"` block (option-value completion) and + // run task completion after it, so tasks complete at any position. let pattern = format!( - r#"(?s){}__subcmd__run\).*?opts="(.*?)".*?(if.*?fi)"#, + r#"(?s){}__subcmd__run\).*?opts="(.*?)".*?if.*?fi\s*(case.*?esac)"#, func_prefix ); let replacement = r#"FUNC_PREFIX__subcmd__run) opts="$1" if [[ $${cur} == -* ]] ; then - COMPREPLY=( $$(compgen -W "$${opts}" -- "$${cur}") ) - return 0 - elif [[ $${COMP_CWORD} -eq 2 ]]; then - local tasks=$$(BIN_NAME task list --machine-readable 2> /dev/null) - if [[ $$? -eq 0 ]]; then - COMPREPLY=( $$(compgen -W "$${tasks}" -- "$${cur}") ) - return 0 - fi + COMPREPLY=( $$(compgen -W "$${opts}" -- "$${cur}") ) + return 0 + fi + $2 + local tasks + if tasks=$$(BIN_NAME task list --machine-readable 2> /dev/null) && [[ -n "$${tasks}" ]]; then + COMPREPLY=( $$(compgen -W "$${tasks}" -- "$${cur}") ) + return 0 fi"#; let re = Regex::new(pattern.as_str()).expect("should be able to compile the regex"); re.replace( @@ -334,30 +335,32 @@ _arguments "${_arguments_options[@]}" \ #[test] pub(crate) fn test_bash_completion() { - // NOTE THIS IS FORMATTED BY HAND! + // Trimmed excerpt of what clap_complete >=4.6.2 generates for `pixi`. let script = r#" - pixi__project__help__help) + pixi__subcmd__project__subcmd__help__subcmd__help) opts="" COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; - pixi__run) - opts="-v -q -h --manifest-path --locked --frozen --verbose --quiet --color --help [TASK]..." - if [[ ${cur} == -* ]] ; then + pixi__subcmd__run) + opts="-e -v -q -h --manifest-path --locked --frozen --environment --verbose --quiet --color --help [TASK]..." + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 - elif [[ ${COMP_CWORD} -eq 2 ]]; then - local tasks=$(pixi task list --machine-readable 2> /dev/null) - if [[ $? -eq 0 ]]; then - COMPREPLY=( $(compgen -W "${tasks}" -- "${cur}") ) - return 0 - fi fi case "${prev}" in --manifest-path) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; + --environment) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -e) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; --color) COMPREPLY=($(compgen -W "always never auto" -- "${cur}")) return 0 @@ -369,27 +372,28 @@ _arguments "${_arguments_options[@]}" \ COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; - pixi__search) + pixi__subcmd__search) opts="-c -l -v -q -h --channel --color --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 fi case "${prev}" in --channel) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; esac COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) - + return 0 ;; "#; - let result = replace_bash_completion(script); - let replacement = format!("{} task list", pixi_utils::executable_name()); - let zsh_arg_name = format!("{}__", pixi_utils::executable_name().replace("-", "__")); - println!("{result}"); - insta::with_settings!({filters => vec![ - (replacement.as_str(), "pixi task list"), - (zsh_arg_name.as_str(), "[PIXI COMMAND]"), - ]}, { - insta::assert_snapshot!(result); - }); + let result = replace_bash_completion(script, "pixi"); + assert_ne!(result, script); + insta::assert_snapshot!(result); } #[test] @@ -436,7 +440,10 @@ _arguments "${_arguments_options[@]}" \ // Generate the original completion script. let script = get_completion_script(Shell::Bash); // Test if there was a replacement done on the clap generated completions - assert_ne!(replace_bash_completion(&script), script); + assert_ne!( + replace_bash_completion(&script, pixi_utils::executable_name()), + script + ); } #[test] diff --git a/crates/pixi_cli/src/snapshots/pixi_cli__completion__tests__bash_completion.snap b/crates/pixi_cli/src/snapshots/pixi_cli__completion__tests__bash_completion.snap index 6638926b27..4a3b3c8ec8 100644 --- a/crates/pixi_cli/src/snapshots/pixi_cli__completion__tests__bash_completion.snap +++ b/crates/pixi_cli/src/snapshots/pixi_cli__completion__tests__bash_completion.snap @@ -1,29 +1,31 @@ --- -source: src/cli/completion.rs +source: crates/pixi_cli/src/completion.rs expression: result --- - pixi__project__help__help) + pixi__subcmd__project__subcmd__help__subcmd__help) opts="" COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; - pixi__run) - opts="-v -q -h --manifest-path --locked --frozen --verbose --quiet --color --help [TASK]..." + pixi__subcmd__run) + opts="-e -v -q -h --manifest-path --locked --frozen --environment --verbose --quiet --color --help [TASK]..." if [[ ${cur} == -* ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 - elif [[ ${COMP_CWORD} -eq 2 ]]; then - local tasks=$(pixi task list --machine-readable 2> /dev/null) - if [[ $? -eq 0 ]]; then - COMPREPLY=( $(compgen -W "${tasks}" -- "${cur}") ) - return 0 - fi fi case "${prev}" in --manifest-path) COMPREPLY=($(compgen -f "${cur}")) return 0 ;; + --environment) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -e) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; --color) COMPREPLY=($(compgen -W "always never auto" -- "${cur}")) return 0 @@ -32,16 +34,29 @@ expression: result COMPREPLY=() ;; esac + local tasks + if tasks=$(pixi task list --machine-readable 2> /dev/null) && [[ -n "${tasks}" ]]; then + COMPREPLY=( $(compgen -W "${tasks}" -- "${cur}") ) + return 0 + fi COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; - pixi__search) + pixi__subcmd__search) opts="-c -l -v -q -h --channel --color --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 fi case "${prev}" in --channel) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; esac COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) - + return 0 ;; diff --git a/crates/pixi_cli/src/task.rs b/crates/pixi_cli/src/task.rs index a5bfc354d6..c7afe20ad4 100644 --- a/crates/pixi_cli/src/task.rs +++ b/crates/pixi_cli/src/task.rs @@ -396,6 +396,7 @@ async fn list_tasks( .values() .flat_map(|(_, tasks)| tasks.keys()) .sorted() + .dedup() .map(|name| name.as_str()) .join(" "); writeln!(std::io::stdout(), "{unformatted}") diff --git a/tests/integration_python/test_main_cli.py b/tests/integration_python/test_main_cli.py index b47a200530..4f37ee6290 100644 --- a/tests/integration_python/test_main_cli.py +++ b/tests/integration_python/test_main_cli.py @@ -1,5 +1,7 @@ import json +import os import platform +import shlex import shutil import sys import tomllib @@ -869,6 +871,55 @@ def test_shell_hook_autocompletion(pixi: Path, tmp_pixi_workspace: Path) -> None ) +@pytest.mark.skipif(platform.system() == "Windows", reason="requires bash") +def test_bash_run_task_completion(pixi: Path, tmp_pixi_workspace: Path) -> None: + manifest = tmp_pixi_workspace.joinpath("pixi.toml") + toml = f""" + {EMPTY_BOILERPLATE_PROJECT} + [tasks] + hello = "echo hello" + build = "echo build" + + [feature.test.tasks] + run-tests = "echo testing" + + [environments] + test = ["test"] + """ + manifest.write_text(toml) + + def complete(command_line: list[str]) -> list[str]: + """Simulate pressing in bash with the given words on the command line.""" + words = " ".join(shlex.quote(word) for word in command_line) + harness = "\n".join( + [ + f'eval "$({shlex.quote(str(pixi))} completion --shell bash)"', + f"COMP_WORDS=({words})", + "COMP_CWORD=$((${#COMP_WORDS[@]} - 1))", + "COMPREPLY=()", + '_pixi pixi "${COMP_WORDS[COMP_CWORD]}" "${COMP_WORDS[COMP_CWORD - 1]}"', + 'echo "${COMPREPLY[@]:-}"', + ] + ) + # The completion script invokes a bare `pixi task list`, so the binary + # under test must be first on PATH. + output = verify_cli_command( + ["bash", "-c", harness], + env={"PATH": f"{pixi.parent.resolve()}{os.pathsep}{os.environ['PATH']}"}, + cwd=tmp_pixi_workspace, + ) + return sorted(output.stdout.split()) + + all_tasks = ["build", "hello", "run-tests"] + assert complete(["pixi", "run", ""]) == all_tasks + # Tasks must also complete after flags (https://github.com/prefix-dev/pixi/issues/6494). + assert complete(["pixi", "run", "-e", "test", ""]) == all_tasks + assert complete(["pixi", "run", "--frozen", ""]) == all_tasks + assert complete(["pixi", "run", "-e", "test", "he"]) == ["hello"] + # Flags still complete. + assert complete(["pixi", "run", "--froz"]) == ["--frozen"] + + def test_pixi_info_tasks(pixi: Path, tmp_pixi_workspace: Path) -> None: manifest = tmp_pixi_workspace.joinpath("pixi.toml") toml = """ From 98f05f85738a4acaab3c20c8cf9f2576a76448fc Mon Sep 17 00:00:00 2001 From: Tobias Hunger Date: Wed, 8 Jul 2026 13:20:02 +0000 Subject: [PATCH 2/3] fix(completion): complete environments after `-e`/`--environment` in `pixi run` Bash, zsh, and fish completed the `-e`/`--environment` option value as file paths. Complete workspace environment names instead, mirroring the task completion. Add a hidden `--machine-readable` flag to `pixi workspace environment list` that the scripts call for the names. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/pixi_cli/src/completion.rs | 97 ++++++++++++++----- ...i__completion__tests__bash_completion.snap | 14 ++- crates/pixi_cli/src/workspace/environment.rs | 23 ++++- tests/integration_python/test_main_cli.py | 11 ++- 4 files changed, 115 insertions(+), 30 deletions(-) diff --git a/crates/pixi_cli/src/completion.rs b/crates/pixi_cli/src/completion.rs index 5f52d8af78..a82c9dd554 100644 --- a/crates/pixi_cli/src/completion.rs +++ b/crates/pixi_cli/src/completion.rs @@ -100,54 +100,103 @@ fn replace_bash_completion<'a>(script: &'a str, bin_name: &str) -> Cow<'a, str> // Keep the generated `case "${prev}"` block (option-value completion) and // run task completion after it, so tasks complete at any position. let pattern = format!( - r#"(?s){}__subcmd__run\).*?opts="(.*?)".*?if.*?fi\s*(case.*?esac)"#, + r#"(?s){}__subcmd__run\).*?opts="(?P.*?)".*?if.*?fi\s*(?Pcase.*?esac)"#, func_prefix ); - let replacement = r#"FUNC_PREFIX__subcmd__run) - opts="$1" - if [[ $${cur} == -* ]] ; then - COMPREPLY=( $$(compgen -W "$${opts}" -- "$${cur}") ) + let re = Regex::new(pattern.as_str()).expect("should be able to compile the regex"); + re.replace(script, |caps: &Captures| { + let opts = &caps["opts"]; + // Complete environment names after `-e`/`--environment` instead of files. + let case = complete_bash_environment(&caps["case"], bin_name); + format!( + r#"{func_prefix}__subcmd__run) + opts="{opts}" + if [[ ${{cur}} == -* ]] ; then + COMPREPLY=( $(compgen -W "${{opts}}" -- "${{cur}}") ) return 0 fi - $2 + {case} local tasks - if tasks=$$(BIN_NAME task list --machine-readable 2> /dev/null) && [[ -n "$${tasks}" ]]; then - COMPREPLY=( $$(compgen -W "$${tasks}" -- "$${cur}") ) + if tasks=$({bin_name} task list --machine-readable 2> /dev/null) && [[ -n "${{tasks}}" ]]; then + COMPREPLY=( $(compgen -W "${{tasks}}" -- "${{cur}}") ) return 0 - fi"#; - let re = Regex::new(pattern.as_str()).expect("should be able to compile the regex"); - re.replace( - script, - replacement - .replace("BIN_NAME", bin_name) - .replace("FUNC_PREFIX", &func_prefix), + fi"# + ) + }) +} + +/// Complete workspace environment names for the `-e`/`--environment` option +/// value in the bash `case "${prev}"` block, replacing the default file +/// completion. +fn complete_bash_environment(case_block: &str, bin_name: &str) -> String { + let re = Regex::new( + r#"(?m)^(?P\s*)(?P