diff --git a/CLAUDE.md b/CLAUDE.md index 9e43749c8..84ac33ccf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,4 +160,4 @@ When a change is user-facing (new feature, changed behavior, bug fix, removal, o - **Task Format**: `package#task` (e.g., `app#build`, `@test/utils#lint`) - **Config File**: `vite-task.json` in each package -- **Rust Edition**: 2024, MSRV 1.88.0 +- **Rust Edition**: 2024, MSRV 1.91.0 diff --git a/Cargo.lock b/Cargo.lock index 14cfa6dfe..78a4ad09e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3697,6 +3697,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syscalls" version = "0.8.1" @@ -4180,6 +4191,39 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39abd59bf32521c7f2301b52d05a6a2c975b6003521cbd0c6dc1582f0a22104" +[[package]] +name = "usage-argv" +version = "6.1.0" +source = "git+https://github.com/fengmk2/usage?rev=25e76738edda9b21c5b2268c1274a9fb63cb4aad#25e76738edda9b21c5b2268c1274a9fb63cb4aad" + +[[package]] +name = "usage-derive" +version = "6.1.0" +source = "git+https://github.com/fengmk2/usage?rev=25e76738edda9b21c5b2268c1274a9fb63cb4aad#25e76738edda9b21c5b2268c1274a9fb63cb4aad" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "usage-rs" +version = "6.1.0" +source = "git+https://github.com/fengmk2/usage?rev=25e76738edda9b21c5b2268c1274a9fb63cb4aad#25e76738edda9b21c5b2268c1274a9fb63cb4aad" +dependencies = [ + "usage-argv", + "usage-derive", + "usage-test", +] + +[[package]] +name = "usage-test" +version = "6.1.0" +source = "git+https://github.com/fengmk2/usage?rev=25e76738edda9b21c5b2268c1274a9fb63cb4aad#25e76738edda9b21c5b2268c1274a9fb63cb4aad" +dependencies = [ + "usage-argv", +] + [[package]] name = "utf8-chars" version = "3.0.6" @@ -4242,7 +4286,6 @@ dependencies = [ "anstream 1.0.0", "anyhow", "async-trait", - "clap", "ctrlc", "derive_more", "fspy", @@ -4266,6 +4309,7 @@ dependencies = [ "tokio-util", "tracing", "twox-hash", + "usage-rs", "uuid", "vt_client_napi", "vt_glob", @@ -4301,7 +4345,6 @@ dependencies = [ "anyhow", "async-trait", "cargo-platform", - "clap", "cow-utils", "cp_r", "ctrlc", @@ -4322,6 +4365,7 @@ dependencies = [ "tempfile", "tokio", "toml", + "usage-rs", "vec1", "vt", "vt_path", @@ -4426,7 +4470,6 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "clap", "copy_dir", "cow-utils", "futures-util", @@ -4543,7 +4586,6 @@ dependencies = [ name = "vt_workspace" version = "0.0.0" dependencies = [ - "clap", "petgraph", "rustc-hash", "serde", @@ -4552,6 +4594,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tracing", + "usage-rs", "vec1", "vt_glob", "vt_path", diff --git a/Cargo.toml b/Cargo.toml index fee91cf29..62c9a50ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ authors = ["Vite+ Authors"] edition = "2024" license = "MIT" publish = false -rust-version = "1.89.0" +rust-version = "1.91.0" [workspace.lints.rust] absolute_paths_not_starting_with_crate = "warn" @@ -56,7 +56,6 @@ bumpalo = "3.17.0" bytemuck = "1.23.0" cargo-platform = "=0.3.2" cc = "1.2.39" -clap = "4.5.53" color-eyre = "0.6.5" compact_str = "0.9.0" constcat = "0.6.1" @@ -159,6 +158,7 @@ tracing-subscriber = "0.3.19" ts-rs = { version = "12.0.0" } tui-term = "0.3.1" twox-hash = "2.1.1" +usage = { package = "usage-rs", git = "https://github.com/fengmk2/usage", rev = "25e76738edda9b21c5b2268c1274a9fb63cb4aad", default-features = false } uuid = "1.18.1" vec1 = "1.12.1" vt_glob = { path = "crates/vt_glob" } diff --git a/crates/vt/Cargo.toml b/crates/vt/Cargo.toml index 99e3f22c9..f39967a54 100644 --- a/crates/vt/Cargo.toml +++ b/crates/vt/Cargo.toml @@ -16,7 +16,6 @@ anstream = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } wincode = { workspace = true, features = ["derive"] } -clap = { workspace = true, features = ["derive"] } ctrlc = { workspace = true } derive_more = { workspace = true, features = ["debug", "from"] } futures-util = { workspace = true } @@ -43,6 +42,7 @@ tokio = { workspace = true, features = [ tokio-util = { workspace = true } tracing = { workspace = true } twox-hash = { workspace = true } +usage = { workspace = true, features = ["completions", "diagnostics", "help", "spec"] } materialized_artifact = { workspace = true } uuid = { workspace = true, features = ["v4"] } vt_glob = { workspace = true } @@ -65,6 +65,7 @@ zstd = { workspace = true } [dev-dependencies] tempfile = { workspace = true } +usage = { workspace = true, features = ["completions", "test"] } [target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))'.dependencies] fspy = { workspace = true } diff --git a/crates/vt/docs/boolean-flags.md b/crates/vt/docs/boolean-flags.md index 2c1062e10..46a7bf473 100644 --- a/crates/vt/docs/boolean-flags.md +++ b/crates/vt/docs/boolean-flags.md @@ -51,4 +51,4 @@ vp run build --no-cache ## Implementation Details -The flags use clap's argument parsing. The `--cache`/`--no-cache` pair uses clap's `conflicts_with` attribute to ensure they cannot be used together. +The flags use `usage-rs` argument parsing. The `--cache` and `--no-cache` fields declare a conflict so that they cannot be used together. diff --git a/crates/vt/src/cli/mod.rs b/crates/vt/src/cli/mod.rs index f2724163d..71450bd67 100644 --- a/crates/vt/src/cli/mod.rs +++ b/crates/vt/src/cli/mod.rs @@ -1,14 +1,13 @@ -use std::sync::Arc; +use std::{ffi::OsString, path::Path, sync::Arc}; -use clap::Parser; use vt_graph::{TaskSpecifier, query::TaskQuery}; use vt_path::AbsolutePath; use vt_plan::plan_request::{CacheOverride, PlanOptions, QueryPlanRequest}; use vt_str::Str; -use vt_workspace::package_filter::{PackageQueryArgs, PackageQueryError}; +use vt_workspace::package_filter::{PackageQueryArgs, PackageQueryCliArgs, PackageQueryError}; /// Controls how task output is displayed. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, usage::ValueEnum)] pub enum LogMode { /// Output streams directly to the terminal as tasks produce it. #[default] @@ -19,46 +18,47 @@ pub enum LogMode { Grouped, } -#[derive(Debug, Clone, clap::Subcommand)] +#[derive(Debug, Clone, usage::Subcommands)] pub enum CacheSubcommand { /// Clean up all the cache Clean, } /// Flags that control how a `run` command selects tasks. -#[derive(Debug, Clone, PartialEq, Eq, clap::Args)] +#[derive(Debug, Clone, Default, PartialEq, Eq, usage::Args)] +#[usage(args_override_self = false)] #[expect(clippy::struct_excessive_bools, reason = "CLI flags are naturally boolean")] pub struct RunFlags { - #[clap(flatten)] - pub package_query: PackageQueryArgs, + #[usage(flatten)] + pub package_query: PackageQueryCliArgs, /// Do not run dependencies specified in `dependsOn` fields. - #[clap(default_value = "false", long)] + #[usage(long)] pub ignore_depends_on: bool, /// Show full detailed summary after execution. - #[clap(default_value = "false", short = 'v', long)] + #[usage(short = 'v', long)] pub verbose: bool, /// Force caching on for all tasks and scripts. - #[clap(long, conflicts_with = "no_cache")] + #[usage(long, conflicts = "--no-cache")] pub cache: bool, /// Force caching off for all tasks and scripts. - #[clap(long, conflicts_with = "cache")] + #[usage(long, conflicts = "--cache")] pub no_cache: bool, /// How task output is displayed. - #[clap(long, default_value = "interleaved")] + #[usage(long, default = "interleaved", value_enum)] pub log: LogMode, /// Maximum number of tasks to run concurrently. Defaults to 4. - #[clap(long)] + #[usage(long)] pub concurrency_limit: Option, /// Run tasks without dependency ordering. Sets concurrency to unlimited /// unless `--concurrency-limit` is also specified. - #[clap(long, default_value = "false")] + #[usage(long)] pub parallel: bool, } @@ -76,58 +76,153 @@ impl RunFlags { } // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -// Public CLI types (clap-parsed) +// Public CLI types // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -/// Arguments for the `run` subcommand as parsed by clap. +/// Arguments for the `run` subcommand. /// /// Contains the `--last-details` flag which is resolved into a separate /// `ResolvedCommand::RunLastDetails` variant internally. /// -/// `trailing_var_arg` at the command level makes clap stop matching flags once -/// the trailing positional starts being filled. This means all tokens after the +/// The automatic double-dash mode stops matching flags once the trailing +/// positional starts being filled. This means all tokens after the /// task name are passed through to the task verbatim, preventing flags like `-v` /// from being intercepted. Flags intended for `vp` itself (e.g. `--verbose`, /// `-r`) must appear **before** the task name. /// /// See . -#[derive(Debug, clap::Parser)] -#[command(trailing_var_arg = true)] +#[derive(Debug, Default, usage::Args)] +#[usage(args_override_self = false, about = "Run tasks", long_about = "Run tasks")] pub struct RunCommand { - #[clap(flatten)] + #[usage(flatten)] pub(crate) flags: RunFlags, /// Display the detailed summary of the last run. - #[clap(long, exclusive = true)] + #[usage(long, exclusive)] pub(crate) last_details: bool, - #[clap( - allow_hyphen_values = true, - value_names = ["TASK_SPECIFIER", "ADDITIONAL_ARGS"], + #[usage( + double_dash = "automatic", + value_name = "TASK_SPECIFIER_OR_ADDITIONAL_ARG", long_help = "Task to run, as `packageName#taskName` or just `taskName`.\nAny arguments after the task name are forwarded to the task process.\nRunning `vp run` without a task name shows an interactive task selector." )] pub(crate) task_and_args: Vec, } -/// vite task CLI subcommands as parsed by clap. -/// -/// vite task CLI subcommands as parsed by clap. +/// Vite Task CLI subcommands. /// /// Pass directly to `Session::main` or `HandledCommand::ViteTaskCommand`. /// The `--last-details` flag on the `run` subcommand is resolved internally. -#[derive(Debug, Parser)] +#[derive(Debug, usage::Subcommands)] pub enum Command { /// Run tasks Run(RunCommand), /// Manage the task cache Cache { - #[clap(subcommand)] + #[usage(subcommand)] subcmd: CacheSubcommand, }, } +/// The Vite Task command-line parser. +#[derive(Debug, usage::Cli)] +#[usage( + bin = "vt", + about = "Run tasks with Vite Task", + long_about = "Run tasks with Vite Task", + completion, + unknown_flags = "error", + args_override_self = false, + view("vpr", root = "run") +)] +pub struct Cli { + #[usage(subcommand)] + pub command: Command, +} + +/// One runtime value offered by task or package completion. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompletionItem { + pub value: Str, +} + +/// Runtime completion values loaded from the current workspace. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CompletionData { + pub tasks: Vec, + pub packages: Vec, +} + +fn is_vpr(split: &usage::complete::Split) -> bool { + split.words.first().is_some_and(|word| { + Path::new(word).file_stem().is_some_and(|name| name.eq_ignore_ascii_case("vpr")) + }) +} + +/// Whether completion can need task or package names from the current workspace. +#[must_use] +pub fn completion_uses_workspace_data(split: &usage::complete::Split) -> bool { + is_vpr(split) + || split.words.iter().any(|word| word == "run" || word == "--filter" || word == "-F") +} + +/// Complete one command line from the static grammar and workspace values. +#[must_use] +pub fn complete( + split: &usage::complete::Split, + data: &CompletionData, +) -> usage::complete::Completions<'static> { + let spec = Cli::spec(); + let view = is_vpr(split) + .then_some(()) + .and_then(|()| spec.views.iter().find(|view| view.name == "vpr")); + let mut answer = view.map_or_else( + || usage::complete::complete(spec, split), + |view| usage::complete::complete_view(spec, split, view), + ); + let position = view.map_or_else( + || usage::complete::walk(spec.root.cmd, split.argv()), + |view| usage::complete::walk_view(spec.root.cmd, split.argv(), view), + ); + let runtime = if position.awaiting_value.is_some_and(|flag| { + flag.longs.contains(&"filter") || flag.name.eq_ignore_ascii_case("filters") + }) { + Some(&data.packages) + } else if position.next_arg.is_some_and(|arg| arg.name == "TASK_SPECIFIER_OR_ADDITIONAL_ARG") + && position.next_arg_values == 0 + { + Some(&data.tasks) + } else { + None + }; + + if let Some(runtime) = runtime { + answer.candidates.extend( + runtime.iter().filter(|item| item.value.starts_with(&split.prefix)).map(|item| { + usage::complete::Candidate { value: item.value.to_string(), description: None } + }), + ); + answer.candidates.sort(); + answer.candidates.dedup_by(|left, right| left.value == right.value); + answer.files = None; + } + + answer +} + +/// Answer a hidden completion request with workspace values. +#[must_use] +pub fn completion_request(argv: &[OsString], data: &CompletionData) -> Option { + let request = usage::complete::Request::parse(argv)?; + if request.candidates_for.is_some() { + return Cli::completion_request(argv); + } + let answer = complete(&request.split, data); + Some(usage::complete::render(&answer, request.shell)) +} + impl Command { - /// Resolve the clap-parsed command into the dispatched [`ResolvedCommand`] enum. + /// Resolve the parsed command into the dispatched [`ResolvedCommand`] enum. /// /// When `--last-details` is set on the `run` subcommand, this produces /// [`ResolvedCommand::RunLastDetails`] instead of [`ResolvedCommand::Run`], @@ -220,8 +315,9 @@ impl ResolvedRunCommand { // Read before `into_package_query` consumes the args. let fail_if_no_match = self.flags.package_query.fail_if_no_match; + let package_query_args = PackageQueryArgs::from(self.flags.package_query); let (package_query, is_cwd_only) = - self.flags.package_query.into_package_query(task_specifier.package_name, cwd)?; + package_query_args.into_package_query(task_specifier.package_name, cwd)?; Ok(( QueryPlanRequest { @@ -242,3 +338,169 @@ impl ResolvedRunCommand { )) } } + +#[cfg(test)] +mod tests { + use std::ffi::OsStr; + + use usage::test::{self, Outcome, Shell}; + + use super::*; + + fn parse(argv: &[&str]) -> Cli { + let argv = test::argv(argv); + test::parse(Cli::spec(), &argv.words(), Cli::parse_from) + .unwrap_or_else(|error| panic!("command must parse:\n{error}")) + } + + fn parse_run(argv: &[&str]) -> ResolvedRunCommand { + let Command::Run(command) = parse(argv).command else { panic!("expected the run command") }; + command.into_resolved() + } + + #[test] + fn parses_run_options_before_the_task() { + let command = parse_run(&[ + "run", + "-v", + "--cache", + "--log", + "grouped", + "--concurrency-limit", + "8", + "--parallel", + "build", + ]); + + assert_eq!(command.task_specifier.as_deref(), Some("build")); + assert!(command.additional_args.is_empty()); + assert!(command.flags.verbose); + assert!(command.flags.cache); + assert_eq!(command.flags.log, LogMode::Grouped); + assert_eq!(command.flags.concurrency_limit, Some(8)); + assert!(command.flags.parallel); + } + + #[test] + fn forwards_all_options_after_the_task() { + let command = parse_run(&["run", "build", "-v", "--log", "grouped", "--help"]); + + assert_eq!(command.task_specifier.as_deref(), Some("build")); + assert_eq!(command.additional_args, ["-v", "--log", "grouped", "--help"]); + assert!(!command.flags.verbose); + assert_eq!(command.flags.log, LogMode::Interleaved); + } + + #[test] + fn forwards_values_after_an_explicit_double_dash() { + let command = parse_run(&["run", "--", "--build", "--flag"]); + + assert_eq!(command.task_specifier.as_deref(), Some("--build")); + assert_eq!(command.additional_args, ["--flag"]); + } + + #[test] + fn rejects_conflicts_duplicates_and_unknown_options() { + for argv in [ + &["run", "--cache", "--no-cache", "build"][..], + &["run", "--log", "grouped", "--log", "labeled", "build"], + &["run", "--unknown", "build"], + &["run", "--last-details", "build"], + &["run", "--log"], + &["run", "--log", "unknown", "build"], + ] { + let words = test::argv(argv); + let outcome = test::outcome(Cli::spec(), &words.words(), Cli::parse_from); + assert!(matches!(outcome, Outcome::Failed(_)), "{argv:?}: {outcome:?}"); + } + } + + #[test] + fn parses_cache_and_last_details_commands() { + assert!(matches!(parse(&["cache", "clean"]).command, Command::Cache { .. })); + + let Command::Run(command) = parse(&["run", "--last-details"]).command else { + panic!("expected the run command") + }; + assert!(matches!(Command::Run(command).into_resolved(), ResolvedCommand::RunLastDetails)); + } + + #[test] + fn returns_help_without_starting_a_process() { + let words = test::argv(["run", "--help"]); + let outcome = test::outcome(Cli::spec(), &words.words(), Cli::parse_from); + let Outcome::Help(help) = outcome else { panic!("expected help, got {outcome:?}") }; + + assert_eq!(help.code, 0); + assert!(!help.stderr); + assert!(help.text.contains("--concurrency-limit"), "{}", help.text); + assert!(help.text.contains("TASK_SPECIFIER_OR_ADDITIONAL_ARG"), "{}", help.text); + } + + #[test] + fn parses_the_vpr_executable_view() { + let cli = + Cli::parse_from_argv(&[OsStr::new("vpr"), OsStr::new("build"), OsStr::new("--help")]) + .expect("vpr must parse as the run command"); + let Command::Run(command) = cli.command else { panic!("vpr must select run") }; + let command = command.into_resolved(); + + assert_eq!(command.task_specifier.as_deref(), Some("build")); + assert_eq!(command.additional_args, ["--help"]); + } + + #[test] + fn completes_commands_options_and_value_enums() { + assert!(test::candidates(Cli::spec(), "vt r").contains(&"run".to_owned())); + assert!(test::candidates(Cli::spec(), "vt run --l").contains(&"--log".to_owned())); + assert_eq!(test::candidates(Cli::spec(), "vt run --log g"), ["grouped"]); + assert!(test::candidates(Cli::spec(), "vt run build --").is_empty()); + + let completion = test::completion_at( + Cli::spec(), + "vt run --log g ignored", + "vt run --log g".len(), + Shell::Bash, + ); + assert!(completion.candidates.iter().any(|candidate| candidate.value == "grouped")); + } + + #[test] + fn completes_workspace_tasks_packages_and_vpr() { + let data = CompletionData { + tasks: vec![CompletionItem { value: "build".into() }], + packages: vec![CompletionItem { value: "@scope/app".into() }], + }; + let candidates = |line: &str| { + let split = usage::complete::split(line, line.len(), Shell::Bash); + complete(&split, &data) + .candidates + .into_iter() + .map(|candidate| candidate.value) + .collect::>() + }; + + assert_eq!(candidates("vt run bu"), ["build"]); + assert_eq!(candidates("vt run --filter @s"), ["@scope/app"]); + assert_eq!(candidates("vpr bu"), ["build"]); + assert!(candidates("vt run build -- bu").is_empty()); + } + + #[test] + fn loads_workspace_data_only_for_dynamic_positions() { + let split = |line: &str| usage::complete::split(line, line.len(), Shell::Bash); + + assert!(!completion_uses_workspace_data(&split("vt --lo"))); + assert!(completion_uses_workspace_data(&split("vt run bu"))); + assert!(completion_uses_workspace_data(&split("vt run --filter app"))); + assert!(completion_uses_workspace_data(&split("vpr bu"))); + } + + #[test] + fn generates_completion_scripts_for_supported_shells() { + for shell in [Shell::Bash, Shell::Zsh, Shell::Fish, Shell::Nu, Shell::PowerShell] { + assert!(!Cli::completion_script(shell).is_empty()); + } + assert!(Cli::completion_script_for_alias("vpr", Shell::Bash).contains("vpr")); + } +} diff --git a/crates/vt/src/lib.rs b/crates/vt/src/lib.rs index 7a59dfd3b..17db9f2e5 100644 --- a/crates/vt/src/lib.rs +++ b/crates/vt/src/lib.rs @@ -1,10 +1,22 @@ +#[expect( + clippy::allow_attributes, + reason = "usage-rs derive output does not inherit item-level lint attributes" +)] +#[allow( + clippy::disallowed_types, + clippy::pub_underscore_fields, + reason = "usage-rs generates parser state with String and underscore-prefixed fields" +)] mod cli; mod collections; mod napi_client; pub mod session; // Public exports for vt_bin -pub use cli::{CacheSubcommand, Command, RunCommand, RunFlags}; +pub use cli::{ + CacheSubcommand, Cli, Command, CompletionData, CompletionItem, LogMode, RunCommand, RunFlags, + complete, completion_request, completion_uses_workspace_data, +}; pub use session::{ CommandHandler, ExitStatus, HandledCommand, Session, SessionConfig, print_error, }; diff --git a/crates/vt/src/session/mod.rs b/crates/vt/src/session/mod.rs index b59cb17ea..11dea79e1 100644 --- a/crates/vt/src/session/mod.rs +++ b/crates/vt/src/session/mod.rs @@ -8,7 +8,6 @@ use std::{ffi::OsStr, fmt::Debug, io::IsTerminal, sync::Arc}; use cache::ExecutionCache; pub use cache::{CacheMiss, FingerprintMismatch}; -use clap::Parser as _; use once_cell::sync::OnceCell; pub use reporter::ExitStatus; use reporter::{ @@ -216,6 +215,46 @@ impl<'a> Session<'a> { self.lazy_task_graph.load_task_graph().await } + /// Load task and package names for shell completion without running a task. + /// + /// # Errors + /// + /// Returns an error if the task graph cannot be loaded from the workspace configuration. + pub async fn completion_data( + &mut self, + ) -> Result { + let cwd = Arc::clone(&self.cwd); + let graph = self.ensure_task_graph_loaded().await?; + let current_package_path = graph.get_package_path_from_cwd(&cwd).cloned(); + let mut tasks = Vec::new(); + + for entry in graph.list_tasks() { + let package_name = &entry.task_display.package_name; + if current_package_path.as_ref() == Some(&entry.task_display.package_path) { + tasks.push(crate::cli::CompletionItem { + value: entry.task_display.task_name.clone(), + }); + } + if !package_name.is_empty() { + tasks.push(crate::cli::CompletionItem { + value: vt_str::format!("{package_name}#{}", entry.task_display.task_name), + }); + } + } + tasks.sort_unstable_by(|left, right| left.value.cmp(&right.value)); + tasks.dedup_by(|left, right| left.value == right.value); + + let mut packages = graph + .list_package_names() + .into_iter() + .map(|value| crate::cli::CompletionItem { value }) + .collect::>(); + packages.sort_unstable_by(|left, right| left.value.cmp(&right.value)); + packages.dedup_by(|left, right| left.value == right.value); + + Ok(crate::cli::CompletionData { tasks, packages }) + } + /// Initialize a session with custom cwd, environment variables. Useful for testing. /// /// # Errors @@ -272,9 +311,6 @@ impl<'a> Session<'a> { } } - /// # Panics - /// - /// Panics if parsing a hardcoded bare `RunCommand` fails (should never happen). async fn main_inner(&mut self, command: Command) -> Result<(), SessionError> { match command.into_resolved() { ResolvedCommand::Cache { ref subcmd } => self.handle_cache_command(subcmd), @@ -318,9 +354,7 @@ impl<'a> Session<'a> { } else { // No task specifier (e.g. `vp run` or `vp run --verbose`). // Only bare `vp run` enters the selector; with extra flags, error. - let bare = RunCommand::try_parse_from::<_, &str>([]) - .expect("parsing hardcoded bare command should never fail") - .into_resolved(); + let bare = RunCommand::default().into_resolved(); // Normalize the run_command for comparison by ignoring cache flags, which don't affect task selection. let mut normalized_run_command = run_command.clone(); diff --git a/crates/vt_bin/Cargo.toml b/crates/vt_bin/Cargo.toml index d17c14984..6922aa562 100644 --- a/crates/vt_bin/Cargo.toml +++ b/crates/vt_bin/Cargo.toml @@ -22,10 +22,10 @@ libc = { workspace = true } notify = { workspace = true } pty_terminal_test_client = { workspace = true } async-trait = { workspace = true } -clap = { workspace = true, features = ["derive"] } jsonc-parser = { workspace = true, features = ["serde"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["full"] } +usage = { workspace = true, features = ["diagnostics", "help", "spec"] } vt_path = { workspace = true } vt_str = { workspace = true } vt = { workspace = true } diff --git a/crates/vt_bin/src/lib.rs b/crates/vt_bin/src/lib.rs index c18112e0d..6dfe2135b 100644 --- a/crates/vt_bin/src/lib.rs +++ b/crates/vt_bin/src/lib.rs @@ -5,9 +5,8 @@ use std::{ sync::Arc, }; -use clap::Parser; use vt::{ - Command, EnabledCacheConfig, HandledCommand, ScriptCommand, SessionConfig, UserCacheConfig, + Cli, EnabledCacheConfig, HandledCommand, ScriptCommand, SessionConfig, UserCacheConfig, get_path_env, plan_request::SyntheticPlanRequest, }; use vt_path::AbsolutePath; @@ -46,29 +45,25 @@ pub fn find_executable( Ok(executable_path.into_os_string().into()) } -/// Internal argument parser for `vt`/`vp` commands that appear inside task scripts. -/// -/// [`CommandHandler`] uses this to parse the command line when it intercepts a `vt` or `vp` -/// invocation during script execution. It extends [`Command`] with a `tool` subcommand that -/// forwards to the `vtt` test-utility binary — a subcommand that only makes sense within -/// script execution and is therefore not exposed on the top-level `vt` CLI entry point. -#[derive(Debug, Parser)] -#[command(name = "vt", version)] -enum Args { - /// Forward arguments to the `vtt` test-utility binary. - /// - /// Resolves `vtt` via `node_modules/.bin` lookup (same as any other script executable), - /// then synthesizes a cached invocation with the given arguments. The `--` separator, - /// if present, is stripped before forwarding. - Tool { - #[clap(trailing_var_arg = true, allow_hyphen_values = true)] - args: Vec, - }, - /// Any other `vt` subcommand, delegated to the standard [`Command`] parser. - #[command(flatten)] - Task(Command), +#[expect( + clippy::allow_attributes, + reason = "usage-rs derive output does not inherit item-level lint attributes" +)] +#[allow(clippy::disallowed_types, reason = "usage-rs generates parser state with String fields")] +mod tool_args { + use vt_str::Str; + + /// Arguments that the internal `tool` command forwards to `vtt`. + #[derive(Debug, usage::Cli)] + #[usage(bin = "vt tool", unknown_flags = "error", args_override_self = false)] + pub struct ToolArgs { + #[usage(double_dash = "automatic", value_name = "ARG")] + pub args: Vec, + } } +use tool_args::ToolArgs; + #[async_trait::async_trait(?Send)] impl vt::CommandHandler for CommandHandler { async fn handle_command( @@ -85,26 +80,30 @@ impl vt::CommandHandler for CommandHandler { } _ => return Ok(HandledCommand::Verbatim), } - let args = Args::try_parse_from( - std::iter::once(command.program.as_str()).chain(command.args.iter().map(Str::as_str)), - )?; - match args { - Args::Tool { args } => { - let program = find_executable(get_path_env(&command.envs), &command.cwd, "vtt")?; - Ok(HandledCommand::Synthesized(SyntheticPlanRequest { - program, - args: args.into_iter().filter(|a| a.as_str() != "--").collect(), - cache_config: UserCacheConfig::with_config(EnabledCacheConfig { - env: None, - untracked_env: None, - input: None, - output: None, - }), - envs: Arc::clone(&command.envs), - })) - } - Args::Task(parsed) => Ok(HandledCommand::ViteTaskCommand(parsed)), + if command.args.first().is_some_and(|arg| arg == "tool") { + let argv = + command.args[1..].iter().map(std::convert::AsRef::as_ref).collect::>(); + let ToolArgs { args } = ToolArgs::parse_from(&argv) + .map_err(|error| anyhow::anyhow!(ToolArgs::render_failure(&argv, &error)))?; + let program = find_executable(get_path_env(&command.envs), &command.cwd, "vtt")?; + return Ok(HandledCommand::Synthesized(SyntheticPlanRequest { + program, + args: args.into_iter().filter(|arg| arg != "--").collect(), + cache_config: UserCacheConfig::with_config(EnabledCacheConfig { + env: None, + untracked_env: None, + input: None, + output: None, + }), + envs: Arc::clone(&command.envs), + })); } + + let argv = command.args.iter().map(std::convert::AsRef::as_ref).collect::>(); + let parsed = Cli::parse_from(&argv) + .map_err(|error| anyhow::anyhow!(Cli::render_failure(&argv, &error)))? + .command; + Ok(HandledCommand::ViteTaskCommand(parsed)) } } diff --git a/crates/vt_bin/src/main.rs b/crates/vt_bin/src/main.rs index 9476c0f0b..6f0146258 100644 --- a/crates/vt_bin/src/main.rs +++ b/crates/vt_bin/src/main.rs @@ -1,5 +1,4 @@ -use clap::Parser as _; -use vt::{Command, ExitStatus, Session}; +use vt::{Cli, ExitStatus, Session}; use vt_bin::OwnedSessionConfig; fn main() -> ! { @@ -10,7 +9,27 @@ fn main() -> ! { } async fn run() -> ExitStatus { - let args = Command::parse(); + let raw_args = std::env::args_os().skip(1).collect::>(); + if raw_args.first().is_some_and(|arg| arg == "__complete_word__") { + let mut owned_config = OwnedSessionConfig::default(); + let request = usage::complete::Request::parse(&raw_args); + let data = match request { + Some(request) if vt::completion_uses_workspace_data(&request.split) => { + match Session::init(owned_config.as_config()) { + Ok(mut session) => session.completion_data().await.unwrap_or_default(), + Err(_) => vt::CompletionData::default(), + } + } + _ => vt::CompletionData::default(), + }; + if let Some(answer) = vt::completion_request(&raw_args, &data) { + use std::io::Write as _; + let _ = std::io::stdout().write_all(answer.as_bytes()); + } + return ExitStatus::SUCCESS; + } + + let command = Cli::parse().command; let mut owned_config = OwnedSessionConfig::default(); let session = match Session::init(owned_config.as_config()) { Ok(session) => session, @@ -19,5 +38,5 @@ async fn run() -> ExitStatus { return ExitStatus::FAILURE; } }; - session.main(args).await + session.main(command).await } diff --git a/crates/vt_graph/src/display.rs b/crates/vt_graph/src/display.rs index 0031d6311..ac9d0fc34 100644 --- a/crates/vt_graph/src/display.rs +++ b/crates/vt_graph/src/display.rs @@ -55,6 +55,17 @@ impl IndexedTaskGraph { }) .collect() } + + /// Return the names of all named packages in the workspace. + #[must_use] + pub fn list_package_names(&self) -> Vec { + self.indexed_package_graph + .package_graph() + .node_weights() + .map(|package| package.package_json.name.clone()) + .filter(|name| !name.is_empty()) + .collect() + } } // Display-only formatting for task list/selector descriptions. Execution planning keeps diff --git a/crates/vt_plan/Cargo.toml b/crates/vt_plan/Cargo.toml index 79ee3753f..95de833f7 100644 --- a/crates/vt_plan/Cargo.toml +++ b/crates/vt_plan/Cargo.toml @@ -34,7 +34,6 @@ vt_graph = { workspace = true } which = { workspace = true } [dev-dependencies] -clap = { workspace = true, features = ["derive"] } copy_dir = { workspace = true } libtest-mimic = { workspace = true } snapshot_test = { workspace = true } diff --git a/crates/vt_plan/tests/plan_snapshots/fixtures/cache_cli_override/snapshots/query___cache_and___no_cache_conflict.snap b/crates/vt_plan/tests/plan_snapshots/fixtures/cache_cli_override/snapshots/query___cache_and___no_cache_conflict.snap index e6f26f84c..866564232 100644 --- a/crates/vt_plan/tests/plan_snapshots/fixtures/cache_cli_override/snapshots/query___cache_and___no_cache_conflict.snap +++ b/crates/vt_plan/tests/plan_snapshots/fixtures/cache_cli_override/snapshots/query___cache_and___no_cache_conflict.snap @@ -1,5 +1,5 @@ error: the argument '--cache' cannot be used with '--no-cache' -Usage: vt run --cache ... +Usage: vt run [FLAGS] [TASK_SPECIFIER_OR_ADDITIONAL_ARG]… For more information, try '--help'. diff --git a/crates/vt_plan/tests/plan_snapshots/main.rs b/crates/vt_plan/tests/plan_snapshots/main.rs index 0d23f0a93..5c59ca48f 100644 --- a/crates/vt_plan/tests/plan_snapshots/main.rs +++ b/crates/vt_plan/tests/plan_snapshots/main.rs @@ -7,7 +7,6 @@ use std::{ sync::Arc, }; -use clap::Parser; use copy_dir::copy_dir; use cow_utils::CowUtils as _; use redact::redact_snapshot; @@ -15,21 +14,13 @@ use rustc_hash::FxHashMap; use serde::Serialize; use task_graph_markdown::render_task_graph_markdown; use tokio::runtime::Runtime; -use vt::{Command, Session}; +use vt::{Cli, Command, Session}; use vt_graph::display::TaskDisplay; use vt_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf}; use vt_plan::{ExecutionGraph, ExecutionItemKind}; use vt_str::Str; use vt_workspace::find_workspace_root; -/// Local parser wrapper for `BuiltInCommand` -#[derive(Parser)] -#[command(name = "vt")] -enum Cli { - #[clap(flatten)] - Command(Command), -} - #[derive(serde::Deserialize, Debug)] struct Plan { pub name: Str, @@ -240,20 +231,18 @@ fn run_case_inner( let args_display = plan.args.iter().map(vt_str::Str::as_str).collect::>().join(" "); - let cli = match Cli::try_parse_from( - std::iter::once("vt") // dummy program name - .chain(plan.args.iter().map(vt_str::Str::as_str)), - ) { + let argv = plan.args.iter().map(std::convert::AsRef::as_ref).collect::>(); + let cli = match Cli::parse_from(&argv) { Ok(ok) => ok, Err(err) => { snapshots.check_snapshot( vt_str::format!("{snapshot_base}.snap").as_str(), - &err.to_string(), + &Cli::render_failure(&argv, &err), )?; continue; } }; - let Cli::Command(parsed) = cli; + let parsed = cli.command; let Command::Run(run_command) = parsed else { panic!("only `run` commands supported in plan tests") }; diff --git a/crates/vt_str/src/lib.rs b/crates/vt_str/src/lib.rs index 6bebdab9f..484868105 100644 --- a/crates/vt_str/src/lib.rs +++ b/crates/vt_str/src/lib.rs @@ -1,6 +1,7 @@ #[expect(clippy::disallowed_types, reason = "vt_str defines Str using std types internally")] use std::{ borrow::Borrow, + convert::Infallible, ffi::OsStr, fmt::{Debug, Display}, mem::MaybeUninit, @@ -145,6 +146,14 @@ impl From<&str> for Str { } } +impl std::str::FromStr for Str { + type Err = Infallible; + + fn from_str(value: &str) -> Result { + Ok(Self::from(value)) + } +} + #[expect(clippy::disallowed_types, reason = "vt_str provides String conversion via From")] impl From for Str { #[expect(clippy::disallowed_types, reason = "fn signature uses std String")] diff --git a/crates/vt_workspace/Cargo.toml b/crates/vt_workspace/Cargo.toml index 54346e0c2..e244de7f4 100644 --- a/crates/vt_workspace/Cargo.toml +++ b/crates/vt_workspace/Cargo.toml @@ -8,7 +8,6 @@ publish = false rust-version.workspace = true [dependencies] -clap = { workspace = true, features = ["derive"] } petgraph = { workspace = true, features = ["serde-1"] } rustc-hash = { workspace = true } serde = { workspace = true, features = ["derive"] } @@ -17,6 +16,7 @@ serde_json = { workspace = true, features = ["preserve_order"] } serde_norway = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +usage = { workspace = true, features = ["spec"] } vec1 = { workspace = true, features = ["smallvec-v1"] } vt_glob = { workspace = true } vt_path = { workspace = true } @@ -25,6 +25,7 @@ wax = { workspace = true } [dev-dependencies] tempfile = { workspace = true } +usage = { workspace = true, features = ["test"] } [lints] workspace = true diff --git a/crates/vt_workspace/src/lib.rs b/crates/vt_workspace/src/lib.rs index 6d86c2167..aaab4b304 100644 --- a/crates/vt_workspace/src/lib.rs +++ b/crates/vt_workspace/src/lib.rs @@ -1,5 +1,14 @@ mod error; pub mod package; +#[expect( + clippy::allow_attributes, + reason = "usage-rs derive output does not inherit item-level lint attributes" +)] +#[allow( + clippy::disallowed_types, + clippy::pub_underscore_fields, + reason = "usage-rs generates parser state with String and underscore-prefixed fields" +)] pub mod package_filter; pub mod package_graph; mod package_manager; diff --git a/crates/vt_workspace/src/package_filter.rs b/crates/vt_workspace/src/package_filter.rs index af9cd9a5c..3fc783fe1 100644 --- a/crates/vt_workspace/src/package_filter.rs +++ b/crates/vt_workspace/src/package_filter.rs @@ -240,30 +240,41 @@ pub enum PackageQueryError { InvalidFilter(#[from] PackageFilterParseError), } -/// CLI arguments for selecting which packages a command applies to. +/// Parser-neutral input for selecting which packages a command applies to. /// -/// Use `#[clap(flatten)]` to embed these in a parent clap struct. -/// Call [`into_package_query`](Self::into_package_query) to convert into an opaque [`PackageQuery`]. -#[derive(Debug, Clone, PartialEq, Eq, clap::Args)] +/// Call [`into_package_query`](Self::into_package_query) to convert this value into an opaque +/// [`PackageQuery`]. CLI parsers can construct it from [`PackageQueryCliArgs`]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] #[expect(clippy::struct_excessive_bools, reason = "CLI flags are naturally boolean")] pub struct PackageQueryArgs { - /// Select all packages in the workspace. - #[clap(default_value = "false", short, long)] recursive: bool, + transitive: bool, + workspace_root: bool, + filters: Vec, + pub fail_if_no_match: bool, +} + +/// `usage-rs` arguments for selecting which packages a command applies to. +#[derive(Debug, Clone, Default, PartialEq, Eq, usage::Args)] +#[usage(args_override_self = false)] +#[expect(clippy::struct_excessive_bools, reason = "CLI flags are naturally boolean")] +pub struct PackageQueryCliArgs { + /// Select all packages in the workspace. + #[usage(short = 'r', long)] + pub recursive: bool, /// Select the current package and its transitive dependencies. - #[clap(default_value = "false", short, long)] - transitive: bool, + #[usage(short = 't', long)] + pub transitive: bool, /// Select the workspace root package. - #[clap(default_value = "false", short = 'w', long = "workspace-root")] - workspace_root: bool, + #[usage(short = 'w', long = "workspace-root")] + pub workspace_root: bool, /// Match packages by name, directory, or glob pattern. - #[clap( + #[usage( short = 'F', long = "filter", - num_args = 1, long_help = "\ Match packages by name, directory, or glob pattern. @@ -275,17 +286,29 @@ Match packages by name, directory, or glob pattern. --filter ^... Select only the dependencies (exclude the package itself) --filter ! Exclude packages matching the pattern" )] - filters: Vec, + pub filters: Vec, /// Exit with a non-zero status if a `--filter` expression matches no packages. /// /// Without this flag, an unmatched filter (a typo, an empty glob, or a /// traversal like `{.}^...` that collapses to zero on a leaf package) only /// produces a warning and the command exits successfully. - #[clap(long = "fail-if-no-match", default_value = "false")] + #[usage(long = "fail-if-no-match")] pub fail_if_no_match: bool, } +impl From for PackageQueryArgs { + fn from(value: PackageQueryCliArgs) -> Self { + Self { + recursive: value.recursive, + transitive: value.transitive, + workspace_root: value.workspace_root, + filters: value.filters, + fail_if_no_match: value.fail_if_no_match, + } + } +} + impl PackageQueryArgs { /// Convert CLI arguments into an opaque [`PackageQuery`]. /// @@ -625,6 +648,74 @@ fn build_name_pattern(name: &str) -> Result PackageQueryArgs { + let argv = usage::test::argv(argv); + usage::test::parse(PackageQueryCli::spec(), &argv.words(), PackageQueryCli::parse_from) + .unwrap_or_else(|error| panic!("package query must parse:\n{error}")) + .args + .into() + } + + #[test] + fn parses_all_package_query_short_options() { + let args = parse_package_args(&[ + "-r", + "-t", + "-w", + "-F", + "@scope/app", + "-F", + "./packages/*", + "--fail-if-no-match", + ]); + + assert!(args.recursive); + assert!(args.transitive); + assert!(args.workspace_root); + assert_eq!(args.filters, ["@scope/app", "./packages/*"]); + assert!(args.fail_if_no_match); + } + + #[test] + fn parses_all_package_query_long_options() { + let args = parse_package_args(&[ + "--recursive", + "--transitive", + "--workspace-root", + "--filter", + "app", + "--fail-if-no-match", + ]); + + assert!(args.recursive); + assert!(args.transitive); + assert!(args.workspace_root); + assert_eq!(args.filters, ["app"]); + assert!(args.fail_if_no_match); + } + + #[test] + fn rejects_unknown_options_and_missing_filter_values() { + for argv in [&["--unknown"][..], &["--filter"]] { + let words = usage::test::argv(argv); + assert!( + usage::test::parse( + PackageQueryCli::spec(), + &words.words(), + PackageQueryCli::parse_from, + ) + .is_err() + ); + } + } + /// Construct an [`AbsolutePath`] from a Unix-style literal (test helper). /// /// On Windows, a `C:` prefix is prepended so `/workspace` becomes `C:/workspace`.