From d0d99804c88a2bc2cc734c9f07d1f5709ea96abb Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Sat, 18 Jul 2026 22:36:41 +0200 Subject: [PATCH] feat(pacquet): implement native recursive command (#13129) Implemented native recursive command in Rust for the pacquet CLI. It prints the help and exits with status 1, matching the pnpm legacy behavior. Related to: #11633. --- .changeset/pacquet-recursive.md | 5 ++ pnpm/crates/cli/src/cli_args/cli_command.rs | 3 + pnpm/crates/cli/src/cli_args/dispatch.rs | 1 + .../crates/cli/src/cli_args/dispatch_query.rs | 10 +++ .../cli/src/cli_args/switch_cli_version.rs | 1 + pnpm/crates/cli/src/flag_relocation.rs | 77 ++++++++++++++++--- 6 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 .changeset/pacquet-recursive.md diff --git a/.changeset/pacquet-recursive.md b/.changeset/pacquet-recursive.md new file mode 100644 index 0000000000..7b3fb074a8 --- /dev/null +++ b/.changeset/pacquet-recursive.md @@ -0,0 +1,5 @@ +--- +"pacquet": minor +--- + +Implemented native `recursive`, `multi`, and `m` commands in the Rust CLI. diff --git a/pnpm/crates/cli/src/cli_args/cli_command.rs b/pnpm/crates/cli/src/cli_args/cli_command.rs index 052152771d..eed36aa99b 100644 --- a/pnpm/crates/cli/src/cli_args/cli_command.rs +++ b/pnpm/crates/cli/src/cli_args/cli_command.rs @@ -267,6 +267,9 @@ pub enum CliCommand { Access(AccessArgs), /// Initialize a package.json Init, + /// Concurrently runs a command in all subdirectory projects. + #[clap(visible_aliases = ["multi", "m"])] + Recursive, /// Add a package Add(AddArgs), /// Install packages diff --git a/pnpm/crates/cli/src/cli_args/dispatch.rs b/pnpm/crates/cli/src/cli_args/dispatch.rs index c4e15d1861..2782a77e86 100644 --- a/pnpm/crates/cli/src/cli_args/dispatch.rs +++ b/pnpm/crates/cli/src/cli_args/dispatch.rs @@ -316,6 +316,7 @@ fn route<'a>(command: CliCommand, ctx: &RunCtx<'a>) -> miette::Result dispatch_query::access(ctx, args), CliCommand::Init => dispatch_script::init(ctx), + CliCommand::Recursive => dispatch_query::recursive(ctx), CliCommand::Add(args) => dispatch_install::add(ctx, args), CliCommand::Install(args) => dispatch_install::install(ctx, args), CliCommand::InstallTest(args) => dispatch_install::install_test(ctx, args), diff --git a/pnpm/crates/cli/src/cli_args/dispatch_query.rs b/pnpm/crates/cli/src/cli_args/dispatch_query.rs index 5e5ff1fa22..931d433244 100644 --- a/pnpm/crates/cli/src/cli_args/dispatch_query.rs +++ b/pnpm/crates/cli/src/cli_args/dispatch_query.rs @@ -47,10 +47,20 @@ use super::{ why::WhyArgs, with::WithArgs, }; +use clap::CommandFactory; use pacquet_config::Config; use pacquet_default_reporter::DefaultReporter; use pacquet_reporter::{NdjsonReporter, SilentReporter}; +pub(super) fn recursive<'a>(_ctx: &RunCtx<'a>) -> miette::Result> { + Ok(Box::pin(async move { + let mut cmd = crate::cli_args::CliArgs::command(); + let _ = cmd.find_subcommand_mut("recursive").expect("recursive subcommand").print_help(); + #[expect(clippy::exit, reason = "`recursive` exits non-zero, mirroring pnpm")] + std::process::exit(1); + })) +} + // `outdated` is a read-only query: it prints a report to stdout and never // installs, so it has no reporter-typed install pipeline to dispatch on. It // reports back whether any dependency was outdated; process termination stays diff --git a/pnpm/crates/cli/src/cli_args/switch_cli_version.rs b/pnpm/crates/cli/src/cli_args/switch_cli_version.rs index 6e84eb71cd..a8322b207f 100644 --- a/pnpm/crates/cli/src/cli_args/switch_cli_version.rs +++ b/pnpm/crates/cli/src/cli_args/switch_cli_version.rs @@ -457,6 +457,7 @@ fn command_name(command: &CliCommand) -> &'static str { match command { CliCommand::Access(_) => "access", CliCommand::Init => "init", + CliCommand::Recursive => "recursive", CliCommand::Add(_) => "add", CliCommand::Install(_) => "install", CliCommand::InstallTest(_) => "install-test", diff --git a/pnpm/crates/cli/src/flag_relocation.rs b/pnpm/crates/cli/src/flag_relocation.rs index dbb3d12d8c..e716f1d3b4 100644 --- a/pnpm/crates/cli/src/flag_relocation.rs +++ b/pnpm/crates/cli/src/flag_relocation.rs @@ -35,18 +35,78 @@ use std::{ /// `cmd` must be the same [`Command`] the returned argv is parsed with /// (including the [`crate::boolean_negations`] augmentation), so the /// hidden `--no-` negations relocate like their positive forms. -pub fn relocate_pre_subcommand_flags(cmd: &Command, argv: Vec) -> Vec { +fn find_positional( + argv: &[OsString], + mut index: usize, + top_level: &ArgTable, + subcommand_union: &ArgTable, +) -> Option { + loop { + let token = argv.get(index).and_then(|token| token.to_str())?; + if token == "--" { + return None; + } + if let Some(rest) = token.strip_prefix("--") { + let (name, has_inline_value) = + rest.split_once('=').map_or((rest, false), |(name, _)| (name, true)); + if let Some(consumes_value) = top_level.long_consumes_value(name) { + index += token_width(consumes_value, has_inline_value); + } else { + let consumes_value = subcommand_union.long_consumes_value(name).unwrap_or(false); + index += token_width(consumes_value, has_inline_value); + } + } else if let Some(rest) = token.strip_prefix('-').filter(|rest| !rest.is_empty()) { + let short = rest.chars().next().expect("checked non-empty"); + let is_bare_short = rest.chars().count() == 1; + if let Some(consumes_value) = top_level.short_consumes_value(short) { + index += token_width(consumes_value && is_bare_short, false); + } else { + let consumes_value = subcommand_union.short_consumes_value(short).unwrap_or(false); + index += token_width(consumes_value && is_bare_short, false); + } + } else { + return Some(index); + } + } +} + +/// Move pre-subcommand option tokens that belong to a subcommand's +/// grammar to directly after the subcommand token. See the module docs. +/// +/// `cmd` must be the same [`Command`] the returned argv is parsed with +/// (including the [`crate::boolean_negations`] augmentation), so the +/// hidden `--no-` negations relocate like their positive forms. +pub fn relocate_pre_subcommand_flags(cmd: &Command, mut argv: Vec) -> Vec { let top_level = ArgTable::top_level(cmd); let subcommand_union = ArgTable::subcommand_union(cmd); + let mut current_idx = 1; + while let Some(pos_idx) = find_positional(&argv, current_idx, &top_level, &subcommand_union) { + if let Some(token) = argv.get(pos_idx).and_then(|t| t.to_str()) + && matches!(token, "recursive" | "multi" | "m") + && find_positional(&argv, pos_idx + 1, &top_level, &subcommand_union).is_some() + { + argv[pos_idx] = OsString::from("--recursive"); + current_idx = pos_idx + 1; + continue; + } + break; + } + let mut moved_indexes: HashSet = HashSet::new(); + let subcommand_index = find_positional(&argv, 1, &top_level, &subcommand_union); + let Some(subcommand_index) = subcommand_index else { + return argv; + }; + + // Now we must re-calculate moved_indexes, because find_positional just skipped. let mut index = 1; - let subcommand_index = loop { - let Some(token) = argv.get(index).and_then(|token| token.to_str()) else { - return argv; + while index < subcommand_index { + let Some(token) = argv.get(index).and_then(|t| t.to_str()) else { + break; }; if token == "--" { - return argv; + break; } if let Some(rest) = token.strip_prefix("--") { let (name, has_inline_value) = @@ -63,9 +123,6 @@ pub fn relocate_pre_subcommand_flags(cmd: &Command, argv: Vec) -> Vec< } } else if let Some(rest) = token.strip_prefix('-').filter(|rest| !rest.is_empty()) { let short = rest.chars().next().expect("checked non-empty"); - // A multi-character short token (`-Cdir`, combined bools) has - // its value / siblings attached, so it never consumes the - // next token regardless of the short's arity. let is_bare_short = rest.chars().count() == 1; if let Some(consumes_value) = top_level.short_consumes_value(short) { index += token_width(consumes_value && is_bare_short, false); @@ -78,9 +135,9 @@ pub fn relocate_pre_subcommand_flags(cmd: &Command, argv: Vec) -> Vec< index += width; } } else { - break index; + break; } - }; + } if moved_indexes.is_empty() || cmd.find_subcommand(&argv[subcommand_index]).is_none() { return argv;