diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 42ff9e4f1..445243468 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -44,3 +44,4 @@ repos: rev: v2.2.5 hooks: - id: codespell +exclude: ".snap" diff --git a/Cargo.lock b/Cargo.lock index b0f9e6282..e0bddbd6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2111,6 +2111,7 @@ dependencies = [ "rattler_shell", "rattler_solve", "rattler_virtual_packages", + "regex", "reqwest", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 0b17653c2..30f7c73d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ rattler_repodata_gateway = { version = "0.11.0", default-features = false, featu rattler_shell = { version = "0.11.0", default-features = false, features = ["sysinfo"] } rattler_solve = { version = "0.11.0", default-features = false, features = ["resolvo"] } rattler_virtual_packages = { version = "0.11.0", default-features = false } +regex = "1.9.5" reqwest = { version = "0.11.22", default-features = false } serde = "1.0.188" serde_json = "1.0.107" diff --git a/src/cli/completion.rs b/src/cli/completion.rs new file mode 100644 index 000000000..1f4f554eb --- /dev/null +++ b/src/cli/completion.rs @@ -0,0 +1,181 @@ +use crate::cli::{Args, CompletionCommand}; +use clap::CommandFactory; +use miette::IntoDiagnostic; +use regex::Regex; +use std::borrow::Cow; +use std::io::Write; + +/// Generate completions for the pixi cli, and print those to the stdout +pub(crate) fn execute(args: CompletionCommand) -> miette::Result<()> { + let clap_shell = args + .shell + .or(clap_complete::Shell::from_env()) + .unwrap_or(clap_complete::Shell::Bash); + + // Generate the original completion script. + let script = get_completion_script(clap_shell); + + // For supported shells, modify the script to include more context sensitive completions. + let script = match clap_shell { + clap_complete::Shell::Bash => replace_bash_completion(&script), + clap_complete::Shell::Zsh => replace_zsh_completion(&script), + _ => Cow::Owned(script), + }; + + // Write the result to the standard output + std::io::stdout() + .write_all(script.as_bytes()) + .into_diagnostic()?; + + Ok(()) +} + +/// Generate the completion script using clap_complete for a specified shell. +fn get_completion_script(shell: clap_complete::Shell) -> String { + let mut buf = vec![]; + clap_complete::generate(shell, &mut Args::command(), "pixi", &mut buf); + String::from_utf8(buf).expect("clap_complete did not generate a valid UTF8 script") +} + +/// Replace the parts of the bash completion script that need different functionality. +fn replace_bash_completion(script: &str) -> Cow { + let pattern = r#"(?s)pixi__run\).*?opts="(.*?)".*?(if.*?fi)"#; + // Adds tab completion to the pixi run command. + // NOTE THIS IS FORMATTED BY HAND + let replacement = r#"pixi__run) + opts="$1" + if [[ $${cur} == -* ]] ; then + COMPREPLY=( $$(compgen -W "$${opts}" -- "$${cur}") ) + return 0 + elif [[ $${COMP_CWORD} -eq 2 ]]; then + local tasks=$$(pixi task list --summary 2> /dev/null) + if [[ $$? -eq 0 ]]; then + COMPREPLY=( $$(compgen -W "$${tasks}" -- "$${cur}") ) + return 0 + fi + fi"#; + let re = Regex::new(pattern).unwrap(); + re.replace(script, replacement) +} + +/// Replace the parts of the bash completion script that need different functionality. +fn replace_zsh_completion(script: &str) -> Cow { + let pattern = r#"(?ms)(\(run\))(?:.*?)(_arguments.*?)(\*::task)"#; + // Adds tab completion to the pixi run command. + // NOTE THIS IS FORMATTED BY HAND + let zsh_replacement = r#"$1 +_values 'task' $$( pixi task list --summary 2> /dev/null ) +$2::task"#; + + let re = Regex::new(pattern).unwrap(); + re.replace(script, zsh_replacement) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + pub fn test_zsh_completion() { + let mut script = r#" +(add) +_arguments "${_arguments_options[@]}" \ +'--manifest-path=[The path to '\''pixi.toml'\'']:MANIFEST_PATH:_files' \ +'*::specs -- Specify the dependencies you wish to add to the project:' \ +&& ret=0 +;; +(run) +_arguments "${_arguments_options[@]}" \ +'--manifest-path=[The path to '\''pixi.toml'\'']:MANIFEST_PATH:_files' \ +'--color=[Whether the log needs to be colored]:COLOR:(always never auto)' \ +'(--frozen)--locked[Require pixi.lock is up-to-date]' \ +'(--locked)--frozen[Don'\''t check if pixi.lock is up-to-date, install as lockfile states]' \ +'*-v[More output per occurrence]' \ +'*--verbose[More output per occurrence]' \ +'(-v --verbose)*-q[Less output per occurrence]' \ +'(-v --verbose)*--quiet[Less output per occurrence]' \ +'-h[Print help]' \ +'--help[Print help]' \ +'*::task -- The task you want to run in the projects environment:' \ +&& ret=0 +;; +(add) +_arguments "${_arguments_options[@]}" \ +&& ret=0 +;; +(run) +_arguments "${_arguments_options[@]}" \ +&& ret=0 +;; +(shell) +_arguments "${_arguments_options[@]}" \ +&& ret=0 +;; + + "#; + let result = replace_zsh_completion(&mut script); + insta::assert_snapshot!(result); + } + + #[test] + pub fn test_bash_completion() { + // NOTE THIS IS FORMATTED BY HAND! + let script = r#" + pixi__project__help__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} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --manifest-path) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --color) + COMPREPLY=($(compgen -W "always never auto" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + pixi__search) + opts="-c -l -v -q -h --channel --color --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + fi + case "${prev}" in + --channel) + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + + ;; + "#; + let result = replace_bash_completion(script); + insta::assert_snapshot!(result); + } + + #[test] + pub fn test_bash_completion_working_regex() { + // Generate the original completion script. + let script = get_completion_script(clap_complete::Shell::Bash); + // Test if there was a replacement done on the clap generated completions + assert_ne!(replace_bash_completion(&script), script); + } + + #[test] + pub fn test_zsh_completion_working_regex() { + // Generate the original completion script. + let script = get_completion_script(clap_complete::Shell::Zsh); + // Test if there was a replacement done on the clap generated completions + assert_ne!(replace_zsh_completion(&script), script); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 5ac6a3237..61a452652 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,6 +1,6 @@ use super::util::IndicatifWriter; use crate::progress; -use clap::{CommandFactory, Parser}; +use clap::Parser; use clap_complete; use clap_verbosity_flag::Verbosity; use miette::IntoDiagnostic; @@ -9,6 +9,7 @@ use tracing_subscriber::{filter::LevelFilter, util::SubscriberInitExt, EnvFilter pub mod add; pub mod auth; +pub mod completion; pub mod global; pub mod info; pub mod init; @@ -67,20 +68,6 @@ pub enum Command { Project(project::Args), } -fn completion(args: CompletionCommand) -> miette::Result<()> { - let clap_shell = args - .shell - .or(clap_complete::Shell::from_env()) - .unwrap_or(clap_complete::Shell::Bash); - clap_complete::generate( - clap_shell, - &mut Args::command(), - "pixi", - &mut std::io::stdout(), - ); - Ok(()) -} - pub async fn execute() -> miette::Result<()> { let args = Args::parse(); let use_colors = use_color_output(&args); @@ -135,7 +122,7 @@ pub async fn execute() -> miette::Result<()> { /// Execute the actual command pub async fn execute_command(command: Command) -> miette::Result<()> { match command { - Command::Completion(cmd) => completion(cmd), + Command::Completion(cmd) => completion::execute(cmd), Command::Init(cmd) => init::execute(cmd).await, Command::Add(cmd) => add::execute(cmd).await, Command::Run(cmd) => run::execute(cmd).await, diff --git a/src/cli/snapshots/pixi__cli__completion__tests__bash_completion.snap b/src/cli/snapshots/pixi__cli__completion__tests__bash_completion.snap new file mode 100644 index 000000000..8c5118032 --- /dev/null +++ b/src/cli/snapshots/pixi__cli__completion__tests__bash_completion.snap @@ -0,0 +1,49 @@ +--- +source: src/cli/completion.rs +expression: result +--- + + pixi__project__help__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 + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + elif [[ ${COMP_CWORD} -eq 2 ]]; then + local tasks=$(pixi task list --summary 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 + ;; + --color) + COMPREPLY=($(compgen -W "always never auto" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + pixi__search) + opts="-c -l -v -q -h --channel --color --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + fi + case "${prev}" in + --channel) + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + + ;; + diff --git a/src/cli/snapshots/pixi__cli__completion__tests__zsh_completion.snap b/src/cli/snapshots/pixi__cli__completion__tests__zsh_completion.snap new file mode 100644 index 000000000..bdd87a054 --- /dev/null +++ b/src/cli/snapshots/pixi__cli__completion__tests__zsh_completion.snap @@ -0,0 +1,41 @@ +--- +source: src/cli/completion.rs +expression: result +--- + +(add) +_arguments "${_arguments_options[@]}" \ +'--manifest-path=[The path to '\''pixi.toml'\'']:MANIFEST_PATH:_files' \ +'*::specs -- Specify the dependencies you wish to add to the project:' \ +&& ret=0 +;; +(run) +_values 'task' $( pixi task list --summary 2> /dev/null ) +_arguments "${_arguments_options[@]}" \ +'--manifest-path=[The path to '\''pixi.toml'\'']:MANIFEST_PATH:_files' \ +'--color=[Whether the log needs to be colored]:COLOR:(always never auto)' \ +'(--frozen)--locked[Require pixi.lock is up-to-date]' \ +'(--locked)--frozen[Don'\''t check if pixi.lock is up-to-date, install as lockfile states]' \ +'*-v[More output per occurrence]' \ +'*--verbose[More output per occurrence]' \ +'(-v --verbose)*-q[Less output per occurrence]' \ +'(-v --verbose)*--quiet[Less output per occurrence]' \ +'-h[Print help]' \ +'--help[Print help]' \ +'::task -- The task you want to run in the projects environment:' \ +&& ret=0 +;; +(add) +_arguments "${_arguments_options[@]}" \ +&& ret=0 +;; +(run) +_arguments "${_arguments_options[@]}" \ +&& ret=0 +;; +(shell) +_arguments "${_arguments_options[@]}" \ +&& ret=0 +;; + + diff --git a/src/cli/task.rs b/src/cli/task.rs index bf277c87d..c8c894e02 100644 --- a/src/cli/task.rs +++ b/src/cli/task.rs @@ -21,7 +21,7 @@ pub enum Operation { /// List all tasks #[clap(alias = "l")] - List, + List(ListArgs), } #[derive(Parser, Debug)] @@ -74,6 +74,12 @@ pub struct AliasArgs { pub platform: Option, } +#[derive(Parser, Debug, Clone)] +pub struct ListArgs { + #[arg(long, short)] + pub summary: bool, +} + impl From for Task { fn from(value: AddArgs) -> Self { let depends_on = value.depends_on.unwrap_or_default(); @@ -202,16 +208,23 @@ pub fn execute(args: Args) -> miette::Result<()> { task, ); } - Operation::List => { + Operation::List(args) => { let tasks = project.task_names(Some(Platform::current())); if tasks.is_empty() { eprintln!("No tasks found",); } else { - let mut formatted = String::new(); - for name in tasks { - formatted.push_str(&format!("* {}\n", console::style(name).bold(),)); - } - eprintln!("{}", formatted); + let formatted: String = tasks + .iter() + .map(|name| { + if args.summary { + format!("{} ", console::style(name)) + } else { + format!("* {}\n", console::style(name).bold()) + } + }) + .collect(); + + println!("{}", formatted); } } };