diff --git a/Cargo.lock b/Cargo.lock index 62a21b92df..db81c35e69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1624,6 +1624,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" dependencies = [ "clap", + "clap_lex", + "is_executable", + "shlex 2.0.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d4a84f7c92..93188e7c96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ base64 = "0.23.0" cargo_toml = "1.0.0" chrono = "0.4.40" clap = { version = "4.6.1", default-features = false } -clap_complete = "4.6.3" +clap_complete = { version = "4.6.3", features = ["unstable-dynamic"] } clap_complete_nushell = "4.6.0" clap-verbosity-flag = "3.0.2" cmp_any = "0.8.1" diff --git a/crates/pixi_cli/src/completion.rs b/crates/pixi_cli/src/completion.rs index 21f1041900..168e42d47f 100644 --- a/crates/pixi_cli/src/completion.rs +++ b/crates/pixi_cli/src/completion.rs @@ -1,10 +1,16 @@ use crate::Args as CommandArgs; use clap::{Arg, Command, CommandFactory, Parser, ValueEnum}; -use clap_complete::{Generator, shells}; +use clap_complete::{ + Generator, + engine::{ArgValueCompleter, CompletionCandidate, ValueCompleter}, + shells, +}; use clap_complete_nushell::Nushell; use miette::IntoDiagnostic; +use pixi_core::WorkspaceLocator; use regex::{Captures, Regex}; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; +use std::ffi::OsStr; use std::io::Write; use std::iter; use std::sync::LazyLock; @@ -93,6 +99,76 @@ impl Generator for Shell { } } +/// Handle native, runtime completion requests made through `PIXI_COMPLETE`. +pub fn complete() { + clap_complete::CompleteEnv::with_factory(dynamic_completion_command) + .var("PIXI_COMPLETE") + .bin("pixi") + .complete(); +} + +fn dynamic_completion_command() -> Command { + dynamic_completion_command_with_tasks(current_workspace_task_names()) +} + +fn dynamic_completion_command_with_tasks(task_names: Vec) -> Command { + CommandArgs::command().mut_subcommand("run", move |command| { + command.trailing_var_arg(false).mut_arg("task", move |arg| { + // The runtime parser stores the task and its arguments in one + // trailing Vec. Narrow only the completion model to its first + // value, otherwise clap offers task names for task arguments too. + arg.action(clap::ArgAction::Set) + .num_args(1) + .trailing_var_arg(false) + .add(ArgValueCompleter::new(TaskNameCompleter { task_names })) + }) + }) +} + +fn current_workspace_task_names() -> Vec { + // Workspace-aware candidates are best-effort. A missing or invalid + // workspace must not disable clap's normal command and option completion. + let Ok(workspace) = WorkspaceLocator::for_cli() + .with_emit_warnings(false) + .locate() + else { + return Vec::new(); + }; + + workspace + .environments() + .into_iter() + .flat_map(|environment| environment.get_filtered_tasks()) + .map(|task| task.as_str().to_owned()) + .collect::>() + .into_iter() + .collect() +} + +struct TaskNameCompleter { + task_names: Vec, +} + +impl TaskNameCompleter { + fn candidates(&self, current: &OsStr) -> Vec { + let Some(prefix) = current.to_str() else { + return Vec::new(); + }; + + self.task_names + .iter() + .filter(|task| task.starts_with(prefix)) + .map(CompletionCandidate::new) + .collect() + } +} + +impl ValueCompleter for TaskNameCompleter { + fn complete(&self, current: &OsStr) -> Vec { + self.candidates(current) + } +} + /// Generate completions for the pixi cli, and print those to the stdout pub fn execute(args: Args) -> miette::Result<()> { let cli = Cli::new(); @@ -708,7 +784,7 @@ fn nushell_completion(cli: &Cli, cmd: &str, long: &str) -> Option { #[cfg(test)] mod tests { - use std::collections::HashSet; + use std::{collections::HashSet, ffi::OsString}; use clap::ValueHint; @@ -724,6 +800,44 @@ mod tests { Cli::with_bin_name("pixi") } + fn dynamic_candidates(args: &[&str], index: usize) -> HashSet { + let mut command = dynamic_completion_command_with_tasks(vec![ + "build-docs".to_owned(), + "lint".to_owned(), + "test".to_owned(), + ]); + clap_complete::engine::complete( + &mut command, + args.iter().map(OsString::from).collect(), + index, + None, + ) + .expect("dynamic completion should succeed") + .into_iter() + .map(|candidate| candidate.get_value().to_string_lossy().into_owned()) + .collect() + } + + #[test] + fn test_dynamic_completion_uses_the_full_clap_command_tree() { + let root = dynamic_candidates(&["pixi", "work"], 1); + assert!(root.contains("workspace")); + + let nested = dynamic_candidates(&["pixi", "workspace", "plat"], 2); + assert!(nested.contains("platform")); + } + + #[test] + fn test_dynamic_completion_adds_current_workspace_tasks_to_run() { + let tasks = dynamic_candidates(&["pixi", "run", "bu"], 2); + assert_eq!(tasks, HashSet::from(["build-docs".to_owned()])); + + let task_arguments = dynamic_candidates(&["pixi", "run", "build-docs", ""], 3); + assert!(!task_arguments.contains("build-docs")); + assert!(!task_arguments.contains("lint")); + assert!(!task_arguments.contains("test")); + } + #[test] pub(crate) fn test_zsh_completion() { let script = r#" diff --git a/crates/pixi_cli/src/lib.rs b/crates/pixi_cli/src/lib.rs index a2d158c146..9960a9b1a4 100644 --- a/crates/pixi_cli/src/lib.rs +++ b/crates/pixi_cli/src/lib.rs @@ -247,6 +247,8 @@ impl LockFileUsageConfig { } pub async fn execute() -> miette::Result<()> { + completion::complete(); + let args = Args::parse(); // Extract values we need before moving args