From 94507fce33f5002a89cfdd7153d48ebfbb03c6ba Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:08:40 +0000 Subject: [PATCH 1/3] feat(derive): generate command dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `match` from a parsed subcommand enum to the code that carries the command out is the one part of a CLI every adopter writes and nobody varies: one arm per command, 210 of them at mise's size, none of them checkable, because every arm has the same shape. `usage_argv::Run` and `RunWith` are the traits a command implements; `#[usage(run)]` / `#[usage(run_with)]` on the enum generate the match, and on a container struct generate the forward to its own subcommands. The output type is the first variant's and the others are bound to agree, so a command that returns something else — or that is added and not implemented — is reported on the command rather than inside generated code. Nothing reaches the spec. Which Rust function runs a command is not part of what the CLI is, and a spec recording it could be read by nothing but the program that wrote it, so this follows `#[usage(skip)]`'s rule rather than adding spec surface. usage-cli proves it: both of its matches are gone and `usage --usage-spec` is byte-identical, so no manpage, reference page or completion script changed. Opt-in, because the generated implementation is the only one an enum can have, and because asking is what makes an undispatchable variant an error where it is declared — a bare variant, an inline-fields variant and an `external_subcommand` all hold nothing a trait can be implemented for. Co-Authored-By: Claude Opus 5 --- PLAN.md | 34 ++++ argv/src/lib.rs | 5 + argv/src/run.rs | 113 +++++++++++ cli/src/cli/complete_word.rs | 84 ++++---- cli/src/cli/exec.rs | 18 +- cli/src/cli/generate/completion.rs | 6 +- cli/src/cli/generate/completion_init.rs | 6 +- cli/src/cli/generate/fig.rs | 40 ++-- cli/src/cli/generate/go.rs | 6 +- cli/src/cli/generate/json.rs | 6 +- cli/src/cli/generate/json_schema.rs | 6 +- cli/src/cli/generate/manpage.rs | 6 +- cli/src/cli/generate/markdown.rs | 6 +- cli/src/cli/generate/mod.rs | 19 +- cli/src/cli/generate/sdk.rs | 6 +- cli/src/cli/lint.rs | 52 ++--- cli/src/cli/mcp.rs | 6 +- cli/src/cli/mod.rs | 26 +-- cli/src/cli/shell.rs | 6 +- cli/src/cli/sponsors.rs | 25 ++- conformance/tests/dispatch.rs | 232 ++++++++++++++++++++++ derive/src/codegen.rs | 182 +++++++++++++++++ derive/src/lib.rs | 43 ++++- derive/src/model.rs | 247 +++++++++++++++++++++++- docs/.vitepress/config.mts | 1 + docs/rust/dispatch.md | 157 +++++++++++++++ docs/rust/index.md | 13 ++ docs/rust/migrating-from-clap.md | 7 + docs/rust/subcommands.md | 2 + usage-rs/src/lib.rs | 5 + usage-rs/tests/facade.rs | 52 +++++ 31 files changed, 1263 insertions(+), 154 deletions(-) create mode 100644 argv/src/run.rs create mode 100644 conformance/tests/dispatch.rs create mode 100644 docs/rust/dispatch.md diff --git a/PLAN.md b/PLAN.md index 83093148a..300f11307 100644 --- a/PLAN.md +++ b/PLAN.md @@ -354,6 +354,40 @@ tasks --usage"`, so task names are meant to come from running that. usage-argv d once in usage-argv and called from both places. A facade test asserts the two halves agree: the page `help(spec, &["build"], Page::Long)` renders is byte-for-byte the one `ex build --help` produces. +- [x] **Dispatch** — the `match` from the parsed enum to the code that carries the + command out, which every adopter writes and nobody varies: 210 arms of pure + routing at mise's size, none of them checkable, since every arm has the same + shape. `usage_argv::Run` / `RunWith` are the two traits a command + implements and `#[usage(run)]` / `#[usage(run_with)]` on the enum generate the + match. **Nothing reaches the spec** — which Rust function runs a command is not + part of what the CLI _is_, and a spec recording it could be read by nothing but + the program that wrote it, so this is `#[usage(skip)]`'s rule rather than a new + spec node. Proved on usage-cli itself: both its matches are gone and + `usage --usage-spec` is byte-identical, so no manpage, reference or completion + changed. Decisions, each because the alternative is a wrong program rather than + a missing one: + **two traits, not one with a defaulted context** — a hundred commands needing + nothing shared would each carry `fn run(self, _: ())`, and `RunWith`'s generated + impl is generic over `Ctx` so `&Config`, `&mut App` and an owned handle all work + from one emission; an enum may declare both. + **The output is the first variant's**, with the others bound to agree, so a + command returning something else is reported on the command rather than inside + a generated arm. + **Opt-in**, because the generated impl is the only one an enum can have: a CLI + that wants to act between the parse and the dispatch keeps its match, and asking + is what makes an undispatchable variant an error where it is declared. + **A `run` struct forwards and does nothing else**, so it holds one field, its + subcommands, not in an `Option` — a container like `usage generate` or mise's + `config`. A struct with arguments of its own has to decide what becomes of them, + and an `Option` has a state nothing generated can decide about; both implement + the trait by hand, which is the root's usual case. + **A variant that holds nothing — bare, inline-fields, or `external_subcommand` — + cannot be dispatched.** The first two are served by a struct the derive writes + under a name nothing else can name, and the third holds argv rather than a + command, so there is no type to implement the trait for. Naming the `Args` struct + is the fix, and is where `effect` belongs anyway. If an adopter wants the bare + spelling dispatched, the shape is a per-variant `#[usage(run = path::to::fn)]`; + not built, because one mechanism covers the fleet. ### What clap can say that we cannot diff --git a/argv/src/lib.rs b/argv/src/lib.rs index e9a8c16b0..95352937e 100644 --- a/argv/src/lib.rs +++ b/argv/src/lib.rs @@ -162,11 +162,16 @@ macro_rules! __usage_needs_complete_feature { } #[cfg(feature = "spec")] pub mod help; +// Behind no feature: two traits and no code, so there is nothing here for a binary that +// does not dispatch to pay for, and a hand-written CLI on the bare runtime can use them. +pub mod run; #[cfg(feature = "spec")] pub mod spec; #[cfg(feature = "spec")] pub mod warn; +pub use run::{Run, RunWith}; + /// How deep a command tree this parser will descend. /// /// The ancestor chain is kept in a fixed-size array so that a parse allocates diff --git a/argv/src/run.rs b/argv/src/run.rs new file mode 100644 index 000000000..8fe589328 --- /dev/null +++ b/argv/src/run.rs @@ -0,0 +1,113 @@ +//! Dispatch: handing a parsed command to the code that carries it out. +//! +//! A parse ends with a value — an enum whose selected variant holds the command's own +//! struct — and every CLI then writes the same thing: a `match` over that enum, one arm per +//! command, each arm calling the one function that command exists to call. At mise's size +//! that match is 210 arms of pure routing, and the compiler cannot tell that an arm calling +//! the wrong function is wrong, because every arm has the same shape. +//! +//! So the derive writes it. A command implements [`Run`] (or [`RunWith`], when the CLI hands +//! its commands shared state), the enum says `#[usage(run)]`, and the match is generated from +//! the same declaration the parser and the spec come from. Nothing about it reaches the +//! spec: which Rust function carries out a command is not part of what the CLI *is*, and a +//! spec that recorded it could not be read by anything that is not this program. It is the +//! same rule `#[usage(skip)]` follows. +//! +//! Both traits take `self` by value. A command is finished when it has run, and the values +//! it parsed are its own — taking them by reference would mean every handler borrowing what +//! nothing else can want. +//! +//! # Which one +//! +//! [`Run`] is for a CLI whose commands need nothing but what they parsed. [`RunWith`] is for +//! one that hands them something — a resolved config, an output handle, a database +//! connection — and is generic over what that something is, so `RunWith<&mut App>` and +//! `RunWith>` are both ordinary implementations rather than a shape this crate has +//! to anticipate. +//! +//! They are two traits rather than one with a defaulted context because the noise falls on +//! the wrong side of a CLI otherwise: a hundred commands that need no context would each +//! carry `fn run(self, _: ())`, which says nothing and cannot be left out. A type may +//! implement both, and an enum may dispatch both, when some invocations have a context and +//! others do not. +//! +//! # An example +//! +//! ``` +//! use usage_argv::Run; +//! +//! struct Install { +//! force: bool, +//! } +//! struct Sponsors; +//! +//! impl Run for Install { +//! type Output = Result<(), String>; +//! fn run(self) -> Self::Output { +//! if self.force { +//! Ok(()) +//! } else { +//! Err("refusing without --force".into()) +//! } +//! } +//! } +//! +//! impl Run for Sponsors { +//! type Output = Result<(), String>; +//! fn run(self) -> Self::Output { +//! println!("thanks"); +//! Ok(()) +//! } +//! } +//! +//! // What `#[usage(run)]` on the subcommand enum generates, written out. +//! enum Command { +//! Install(Install), +//! Sponsors(Sponsors), +//! } +//! +//! impl Run for Command +//! where +//! Install: Run, +//! Sponsors: Run::Output>, +//! { +//! type Output = ::Output; +//! fn run(self) -> Self::Output { +//! match self { +//! Command::Install(inner) => Run::run(inner), +//! Command::Sponsors(inner) => Run::run(inner), +//! } +//! } +//! } +//! +//! assert!(Command::Install(Install { force: true }).run().is_ok()); +//! ``` + +/// A command that can be carried out with nothing but what it parsed. +/// +/// The output is the implementation's own: `Result<(), E>` for a CLI whose commands can +/// fail, `()` for one whose commands cannot, [`ExitCode`](std::process::ExitCode) for one +/// that decides its own status. A generated dispatcher takes its output from the first +/// command it routes to and requires the rest to agree, since a `match` has one type. +pub trait Run { + /// What running the command produces. + type Output; + + /// Carry out the command. + fn run(self) -> Self::Output; +} + +/// A command that is handed something shared when it runs. +/// +/// `Ctx` is whatever the CLI has to give: `&Config`, `&mut App`, an owned handle. It is a +/// parameter of the trait rather than of the method so that one command may be runnable with +/// several — a leaf that needs only a config can implement `RunWith<&Config>` while its +/// siblings implement `RunWith<&mut App>`, as long as the enum dispatching them agrees on +/// one. +pub trait RunWith { + /// What running the command produces. + type Output; + + /// Carry out the command, with `ctx`. + fn run_with(self, ctx: Ctx) -> Self::Output; +} diff --git a/cli/src/cli/complete_word.rs b/cli/src/cli/complete_word.rs index 7d09dbf82..2fcc6f330 100644 --- a/cli/src/cli/complete_word.rs +++ b/cli/src/cli/complete_word.rs @@ -128,46 +128,6 @@ pub fn answer( } impl CompleteWord { - pub fn run(&self) -> miette::Result<()> { - let spec = generate::file_or_spec(&self.file, &self.spec)?; - let choices = self.complete_word(&spec)?; - let shell = self.shell.as_ref(); - let any_descriptions = choices.iter().any(|(_, d)| !d.is_empty()); - for (c, description) in choices { - match shell { - "bash" => println!("{c}"), - "fish" | "nu" | "powershell" => { - if any_descriptions { - println!("{c}\t{description}") - } else { - println!("{c}") - } - } - "zsh" => { - // Three tab-separated columns per line: - // 1. The raw value (used as the menu display label). - // 2. The description (may be empty). - // 3. The shell-quoted form that `compadd -Q` should - // insert verbatim — wrapped in single quotes when - // the value contains shell metacharacters, raw - // otherwise. - // The generated zsh script builds the formatted display - // (`value -- description`) from columns 1 and 2 and uses - // column 3 as the inserted match. Keeping these as three - // distinct fields avoids the `\:`-escaping acrobatics - // that `_describe`'s `value:description` format required. - let insert = zsh_shell_quote(&c); - println!("{c}\t{description}\t{insert}") - } - _ => { - miette::bail!("unsupported shell: {}", shell); - } - } - } - - Ok(()) - } - pub fn complete_word(&self, spec: &Spec) -> miette::Result> { Ok(self.complete_word_answer(spec)?.candidates) } @@ -783,6 +743,50 @@ impl CompleteWord { } } +impl usage_rs::Run for CompleteWord { + type Output = miette::Result<()>; + + fn run(self) -> Self::Output { + let spec = generate::file_or_spec(&self.file, &self.spec)?; + let choices = self.complete_word(&spec)?; + let shell = self.shell.as_ref(); + let any_descriptions = choices.iter().any(|(_, d)| !d.is_empty()); + for (c, description) in choices { + match shell { + "bash" => println!("{c}"), + "fish" | "nu" | "powershell" => { + if any_descriptions { + println!("{c}\t{description}") + } else { + println!("{c}") + } + } + "zsh" => { + // Three tab-separated columns per line: + // 1. The raw value (used as the menu display label). + // 2. The description (may be empty). + // 3. The shell-quoted form that `compadd -Q` should + // insert verbatim — wrapped in single quotes when + // the value contains shell metacharacters, raw + // otherwise. + // The generated zsh script builds the formatted display + // (`value -- description`) from columns 1 and 2 and uses + // column 3 as the inserted match. Keeping these as three + // distinct fields avoids the `\:`-escaping acrobatics + // that `_describe`'s `value:description` format required. + let insert = zsh_shell_quote(&c); + println!("{c}\t{description}\t{insert}") + } + _ => { + miette::bail!("unsupported shell: {}", shell); + } + } + } + + Ok(()) + } +} + /// Existing directories described by a possibly abbreviated path. /// /// Exact parents keep the old single-directory fast path. When one does not exist, resolve its diff --git a/cli/src/cli/exec.rs b/cli/src/cli/exec.rs index ef525290c..5577fd9e7 100644 --- a/cli/src/cli/exec.rs +++ b/cli/src/cli/exec.rs @@ -33,7 +33,17 @@ pub struct Exec { } impl Exec { - pub fn run(&mut self) -> miette::Result<()> { + pub fn help(&self, spec: &Spec, args: &[String], long: bool) -> miette::Result<()> { + let parsed = usage::parse::parse_partial(spec, args)?; + println!("{}", usage::docs::cli::render_help(spec, &parsed.cmd, long)); + Ok(()) + } +} + +impl usage_rs::Run for Exec { + type Output = miette::Result<()>; + + fn run(self) -> Self::Output { let parent = self .bin .parent() @@ -85,10 +95,4 @@ impl Exec { Ok(()) } - - pub fn help(&self, spec: &Spec, args: &[String], long: bool) -> miette::Result<()> { - let parsed = usage::parse::parse_partial(spec, args)?; - println!("{}", usage::docs::cli::render_help(spec, &parsed.cmd, long)); - Ok(()) - } } diff --git a/cli/src/cli/generate/completion.rs b/cli/src/cli/generate/completion.rs index 0bc857958..8b3359310 100644 --- a/cli/src/cli/generate/completion.rs +++ b/cli/src/cli/generate/completion.rs @@ -37,8 +37,10 @@ pub struct Completion { usage_cmd: Option, } -impl Completion { - pub fn run(&self) -> miette::Result<()> { +impl usage_rs::Run for Completion { + type Output = miette::Result<()>; + + fn run(self) -> Self::Output { // TODO: refactor this let spec = match &self.file { Some(file) => parse_file_or_stdin(file)?, diff --git a/cli/src/cli/generate/completion_init.rs b/cli/src/cli/generate/completion_init.rs index 66e273268..c2db8a4e8 100644 --- a/cli/src/cli/generate/completion_init.rs +++ b/cli/src/cli/generate/completion_init.rs @@ -24,8 +24,10 @@ pub struct CompletionInit { usage_bin: String, } -impl CompletionInit { - pub fn run(&self) -> miette::Result<()> { +impl usage_rs::Run for CompletionInit { + type Output = miette::Result<()>; + + fn run(self) -> Self::Output { println!("{}", complete_init(&self.shell, &self.usage_bin)?.trim()); Ok(()) } diff --git a/cli/src/cli/generate/fig.rs b/cli/src/cli/generate/fig.rs index b41486f87..4bd5e45ad 100644 --- a/cli/src/cli/generate/fig.rs +++ b/cli/src/cli/generate/fig.rs @@ -357,7 +357,28 @@ impl FigCommand { } impl Fig { - pub fn run(&self) -> miette::Result<()> { + fn get_prescript() -> String { + format!( + "// @generated by usage-cli from usage spec\n{}", + include_str!("../../../assets/fig/generators.ts") + ) + } + + fn get_postscript() -> String { + "export default completionSpec;".to_string() + } + + fn fill_args_complete(args: Vec<&mut FigArg>, completes: IndexMap) { + args.into_iter() + .filter_map(|a| completes.get(&a.name).map(|v| (a, v.clone()))) + .for_each(|(arg, complete)| arg.update_from_complete(complete)); + } +} + +impl usage_rs::Run for Fig { + type Output = miette::Result<()>; + + fn run(self) -> Self::Output { let write = |path: &PathBuf, md: &str| -> miette::Result<()> { generate::write_or_stdout(Some(path), &format!("{}\n", md.trim()))?; Ok(()) @@ -424,21 +445,4 @@ impl Fig { Ok(()) } - - fn get_prescript() -> String { - format!( - "// @generated by usage-cli from usage spec\n{}", - include_str!("../../../assets/fig/generators.ts") - ) - } - - fn get_postscript() -> String { - "export default completionSpec;".to_string() - } - - fn fill_args_complete(args: Vec<&mut FigArg>, completes: IndexMap) { - args.into_iter() - .filter_map(|a| completes.get(&a.name).map(|v| (a, v.clone()))) - .for_each(|(arg, complete)| arg.update_from_complete(complete)); - } } diff --git a/cli/src/cli/generate/go.rs b/cli/src/cli/generate/go.rs index 76bfb7c47..4170eacb4 100644 --- a/cli/src/cli/generate/go.rs +++ b/cli/src/cli/generate/go.rs @@ -38,8 +38,10 @@ pub struct Go { spec: Option, } -impl Go { - pub fn run(&self) -> Result<()> { +impl usage_rs::Run for Go { + type Output = Result<()>; + + fn run(self) -> Self::Output { // Checked here rather than sanitized, because this one came from a person: // quietly turning `--package my-pkg` into `mypkg` is a surprise waiting in // somebody's build script, and the file would not compile if it were not diff --git a/cli/src/cli/generate/json.rs b/cli/src/cli/generate/json.rs index 83a972da0..5387e90a7 100644 --- a/cli/src/cli/generate/json.rs +++ b/cli/src/cli/generate/json.rs @@ -20,8 +20,10 @@ pub struct Json { view: Option, } -impl Json { - pub fn run(&self) -> Result<()> { +impl usage_rs::Run for Json { + type Output = Result<()>; + + fn run(self) -> Self::Output { let spec = generate::select_view( generate::file_or_spec(&self.file, &self.spec)?, self.view.as_deref(), diff --git a/cli/src/cli/generate/json_schema.rs b/cli/src/cli/generate/json_schema.rs index ae5053918..2897e1b84 100644 --- a/cli/src/cli/generate/json_schema.rs +++ b/cli/src/cli/generate/json_schema.rs @@ -35,8 +35,10 @@ pub struct JsonSchema { url: Option, } -impl JsonSchema { - pub fn run(&self) -> Result<()> { +impl usage_rs::Run for JsonSchema { + type Output = Result<()>; + + fn run(self) -> Self::Output { let spec = generate::file_or_spec(&self.file, &self.spec)?; let options = SchemaOptions { title: self diff --git a/cli/src/cli/generate/manpage.rs b/cli/src/cli/generate/manpage.rs index 19c4507c7..0f00e0b92 100644 --- a/cli/src/cli/generate/manpage.rs +++ b/cli/src/cli/generate/manpage.rs @@ -36,8 +36,10 @@ pub struct Manpage { section: u8, } -impl Manpage { - pub fn run(&self) -> miette::Result<()> { +impl usage_rs::Run for Manpage { + type Output = miette::Result<()>; + + fn run(self) -> Self::Output { let spec = select_view(parse_file_or_stdin(&self.file)?, self.view.as_deref())?; let renderer = ManpageRenderer::new(spec).with_section(self.section); let manpage = renderer.render()?; diff --git a/cli/src/cli/generate/markdown.rs b/cli/src/cli/generate/markdown.rs index 0acec4bd9..6a2acc1c5 100644 --- a/cli/src/cli/generate/markdown.rs +++ b/cli/src/cli/generate/markdown.rs @@ -53,8 +53,10 @@ pub struct Markdown { url_prefix: Option, } -impl Markdown { - pub fn run(&self) -> miette::Result<()> { +impl usage_rs::Run for Markdown { + type Output = miette::Result<()>; + + fn run(self) -> Self::Output { // The banner belongs to every generated document, so build it in one place rather // than once per output path. let render = |md: &str| { diff --git a/cli/src/cli/generate/mod.rs b/cli/src/cli/generate/mod.rs index 0bb7ecfdc..e04976350 100644 --- a/cli/src/cli/generate/mod.rs +++ b/cli/src/cli/generate/mod.rs @@ -17,7 +17,7 @@ mod sdk; /// Generate completions, documentation, and other artifacts from usage specs // Cannot run alone, and every child starts at `read`, so the parent is `read` too. #[derive(usage_rs::Args)] -#[usage(alias = "g", effect = "read")] +#[usage(alias = "g", effect = "read", run)] pub struct Generate { #[usage(subcommand)] pub command: Command, @@ -28,6 +28,7 @@ pub struct Generate { /// Each command's help is its struct's doc comment rather than a second one here, which the /// derive would let win: one description, in the file that owns the command. #[derive(usage_rs::Subcommands)] +#[usage(run)] pub enum Command { Completion(completion::Completion), CompletionInit(completion_init::CompletionInit), @@ -40,22 +41,6 @@ pub enum Command { Sdk(sdk::Sdk), } -impl Generate { - pub fn run(&self) -> miette::Result<()> { - match &self.command { - Command::Completion(cmd) => cmd.run(), - Command::CompletionInit(cmd) => cmd.run(), - Command::Fig(cmd) => cmd.run(), - Command::Go(cmd) => cmd.run(), - Command::Json(cmd) => cmd.run(), - Command::JsonSchema(cmd) => cmd.run(), - Command::Manpage(cmd) => cmd.run(), - Command::Markdown(cmd) => cmd.run(), - Command::Sdk(cmd) => cmd.run(), - } - } -} - pub fn file_or_spec(file: &Option, spec: &Option) -> Result { if let Some(file) = file { if file.as_os_str() == "-" { diff --git a/cli/src/cli/generate/sdk.rs b/cli/src/cli/generate/sdk.rs index 13c4e3f1c..f1b2f948a 100644 --- a/cli/src/cli/generate/sdk.rs +++ b/cli/src/cli/generate/sdk.rs @@ -34,8 +34,10 @@ pub struct Sdk { spec: Option, } -impl Sdk { - pub fn run(&self) -> miette::Result<()> { +impl usage_rs::Run for Sdk { + type Output = miette::Result<()>; + + fn run(self) -> Self::Output { let spec = generate::file_or_spec(&self.file, &self.spec)?; let language = match self.language.as_str() { diff --git a/cli/src/cli/lint.rs b/cli/src/cli/lint.rs index 13c69bcd5..d7aa62892 100644 --- a/cli/src/cli/lint.rs +++ b/cli/src/cli/lint.rs @@ -99,30 +99,6 @@ impl std::fmt::Display for LintIssue { } impl Lint { - pub fn run(&self) -> miette::Result<()> { - let spec = parse_file_or_stdin(&self.file)?; - let issues = lint_spec( - &spec, - LintOptions { - sorted: self.sorted, - }, - ); - - match self.format { - OutputFormat::Text => self.print_text(&issues), - OutputFormat::Json => self.print_json(&issues)?, - } - - let has_errors = issues.iter().any(|i| i.severity == Severity::Error); - let has_warnings = issues.iter().any(|i| i.severity == Severity::Warning); - - if has_errors || (self.warnings_as_errors && has_warnings) { - std::process::exit(1); - } - - Ok(()) - } - fn print_text(&self, issues: &[LintIssue]) { if issues.is_empty() { println!("No issues found."); @@ -161,6 +137,34 @@ impl Lint { } } +impl usage_rs::Run for Lint { + type Output = miette::Result<()>; + + fn run(self) -> Self::Output { + let spec = parse_file_or_stdin(&self.file)?; + let issues = lint_spec( + &spec, + LintOptions { + sorted: self.sorted, + }, + ); + + match self.format { + OutputFormat::Text => self.print_text(&issues), + OutputFormat::Json => self.print_json(&issues)?, + } + + let has_errors = issues.iter().any(|i| i.severity == Severity::Error); + let has_warnings = issues.iter().any(|i| i.severity == Severity::Warning); + + if has_errors || (self.warnings_as_errors && has_warnings) { + std::process::exit(1); + } + + Ok(()) + } +} + pub fn lint_spec(spec: &Spec, opts: LintOptions) -> Vec { let mut issues = Vec::new(); diff --git a/cli/src/cli/mcp.rs b/cli/src/cli/mcp.rs index b23b8c989..bb11415bd 100644 --- a/cli/src/cli/mcp.rs +++ b/cli/src/cli/mcp.rs @@ -56,8 +56,10 @@ pub struct Mcp { spec: Option, } -impl Mcp { - pub fn run(&self) -> Result<()> { +impl usage_rs::Run for Mcp { + type Output = Result<()>; + + fn run(self) -> Self::Output { // `-f -` reads stdin to EOF, which is the transport this then wants to // serve on. Saying so beats a server that starts and instantly ends. if self.file.as_deref().is_some_and(|f| f.as_os_str() == "-") { diff --git a/cli/src/cli/mod.rs b/cli/src/cli/mod.rs index 93b9569ea..272b0e1f9 100644 --- a/cli/src/cli/mod.rs +++ b/cli/src/cli/mod.rs @@ -92,9 +92,11 @@ pub(crate) fn version() -> String { /// What `usage` can be asked to do. /// -/// Each command's description is its struct's doc comment, in the file that owns it, except -/// where a variant holds nothing and there is no struct to carry one. +/// Each command's description is its struct's doc comment, in the file that owns it — and so +/// is the code that carries it out: `run` generates the match that hands the selected command +/// to its `usage_rs::Run` implementation, so this list is the only place a command is named. #[derive(Subcommands)] +#[usage(run)] enum Command { Bash(shell::Bash), CompleteWord(complete_word::CompleteWord), @@ -105,9 +107,7 @@ enum Command { Mcp(mcp::Mcp), #[usage(name = "powershell")] PowerShell(shell::PowerShell), - /// Show the companies sponsoring usage and the jdx.dev open source tools - #[usage(effect = "read")] - Sponsors, + Sponsors(sponsors::Sponsors), Zsh(shell::Zsh), } @@ -149,17 +149,9 @@ impl Cli { if cli.usage_spec { return crate::usage_spec::generate(); } - match cli.command { - Command::Bash(mut cmd) => cmd.run(), - Command::Fish(mut cmd) => cmd.run(), - Command::PowerShell(mut cmd) => cmd.run(), - Command::Zsh(mut cmd) => cmd.run(), - Command::Generate(cmd) => cmd.run(), - Command::Exec(mut cmd) => cmd.run(), - Command::CompleteWord(cmd) => cmd.run(), - Command::Lint(cmd) => cmd.run(), - Command::Mcp(cmd) => cmd.run(), - Command::Sponsors => sponsors::run(), - } + // The match that used to be here, one arm per command, is generated from the enum + // above — so a command added there cannot be left unrouted, and no arm can route to + // the wrong handler. + usage_rs::Run::run(cli.command) } } diff --git a/cli/src/cli/shell.rs b/cli/src/cli/shell.rs index 3f308fd3c..44e15bbf2 100644 --- a/cli/src/cli/shell.rs +++ b/cli/src/cli/shell.rs @@ -61,8 +61,10 @@ macro_rules! shell_command { pub shell: Shell, } - impl $ty { - pub fn run(&mut self) -> miette::Result<()> { + impl usage_rs::Run for $ty { + type Output = miette::Result<()>; + + fn run(mut self) -> Self::Output { self.shell.run($program) } } diff --git a/cli/src/cli/sponsors.rs b/cli/src/cli/sponsors.rs index 11a712a64..c7d2efea6 100644 --- a/cli/src/cli/sponsors.rs +++ b/cli/src/cli/sponsors.rs @@ -1,12 +1,21 @@ //! Show the companies sponsoring usage and the jdx.dev open source tools. //! -//! A command that takes nothing, so it is a bare variant of the command enum rather than a -//! struct with no fields: the derive writes the struct such a variant implies, and the help -//! text and `effect` are declared on the variant. +//! A command that takes nothing, so it is a unit struct: nothing to declare, and a struct is +//! what the dispatched command enum hands its work to. `effect` and the description live here +//! with it, where every other command's do. -pub fn run() -> miette::Result<()> { - println!( - "usage and the jdx.dev open source tools are sponsored by:\n\n entire.io - https://entire.io\n 37signals - https://37signals.com\n\nView all sponsors: https://jdx.dev/sponsors.html" - ); - Ok(()) +/// Show the companies sponsoring usage and the jdx.dev open source tools +#[derive(usage_rs::Args)] +#[usage(effect = "read")] +pub struct Sponsors; + +impl usage_rs::Run for Sponsors { + type Output = miette::Result<()>; + + fn run(self) -> Self::Output { + println!( + "usage and the jdx.dev open source tools are sponsored by:\n\n entire.io - https://entire.io\n 37signals - https://37signals.com\n\nView all sponsors: https://jdx.dev/sponsors.html" + ); + Ok(()) + } } diff --git a/conformance/tests/dispatch.rs b/conformance/tests/dispatch.rs new file mode 100644 index 000000000..a3eaec79c --- /dev/null +++ b/conformance/tests/dispatch.rs @@ -0,0 +1,232 @@ +//! Handing a parsed command to the code that carries it out. +//! +//! The `match` over a subcommand enum is the one part of a CLI that every adopter writes and +//! nobody varies: one arm per command, each calling the one function that command exists for. +//! `#[usage(run)]` generates it from the same declaration the parser and the spec come from, +//! so an arm cannot route to the wrong handler and a new command cannot be forgotten — the +//! match is exhaustive because it is generated. +//! +//! Nothing about it reaches the spec, which is the point these tests hold: a dispatched CLI +//! emits exactly the KDL an undispatched one does. + +use std::ffi::OsStr; + +use usage_argv::{Run, RunWith}; +use usage_derive::{Args, Cli, Subcommands}; + +/// Install a tool +#[derive(Args)] +struct Install { + /// Overwrite what is there + #[usage(long)] + force: bool, + /// What to install + tools: Vec, +} + +/// Show who pays for this +#[derive(Args)] +struct Sponsors; + +/// List the configuration +#[derive(Args)] +struct ConfigLs { + /// Leave the header off + #[usage(long)] + no_header: bool, +} + +/// Work with the configuration +#[derive(Subcommands)] +#[usage(run, run_with)] +enum ConfigCommand { + /// List the configuration + Ls(ConfigLs), +} + +/// Work with the configuration +#[derive(Args)] +#[usage(run, run_with)] +struct Config { + #[usage(subcommand)] + command: ConfigCommand, +} + +#[derive(Subcommands)] +#[usage(run, run_with)] +enum Command { + /// Install a tool + Install(Box), + /// Show who pays for this + Sponsors(Sponsors), + /// Work with the configuration + Config(Config), +} + +/// A tool that does things +#[derive(Cli)] +#[usage(bin = "ex")] +struct Ex { + /// Print more + #[usage(short = 'v', long, global)] + verbose: bool, + #[usage(subcommand)] + command: Command, +} + +// What each command does when it is run. The output type is the first variant's and every +// other command has to agree, which is what makes one of these returning something else a +// compile error rather than a mismatch inside a generated arm. +impl Run for Install { + type Output = Result; + fn run(self) -> Self::Output { + if self.force { + Ok(format!("install --force {}", self.tools.join(","))) + } else { + Err(format!("refusing to install {}", self.tools.join(","))) + } + } +} + +impl Run for Sponsors { + type Output = Result; + fn run(self) -> Self::Output { + Ok("sponsors".to_string()) + } +} + +impl Run for ConfigLs { + type Output = Result; + fn run(self) -> Self::Output { + Ok(format!("config ls no_header={}", self.no_header)) + } +} + +/// What a CLI hands its commands, when it hands them anything. +#[derive(Default)] +struct Log { + lines: Vec, +} + +impl RunWith<&mut Log> for Install { + type Output = Result; + fn run_with(self, log: &mut Log) -> Self::Output { + log.lines.push("install".to_string()); + Ok(format!("install force={}", self.force)) + } +} + +impl RunWith<&mut Log> for Sponsors { + type Output = Result; + fn run_with(self, log: &mut Log) -> Self::Output { + log.lines.push("sponsors".to_string()); + Ok("sponsors".to_string()) + } +} + +impl RunWith<&mut Log> for ConfigLs { + type Output = Result; + fn run_with(self, log: &mut Log) -> Self::Output { + log.lines.push("config ls".to_string()); + Ok(format!("config ls no_header={}", self.no_header)) + } +} + +fn parse(words: &[&str]) -> Ex { + let argv: Vec<&OsStr> = words.iter().map(OsStr::new).collect(); + Ex::parse_from(&argv).expect("valid command line") +} + +#[test] +fn the_selected_command_is_the_one_that_runs() { + let ex = parse(&["install", "--force", "node", "python"]); + assert_eq!( + ex.command.run(), + Ok("install --force node,python".to_string()) + ); +} + +#[test] +fn a_commands_own_failure_is_its_output_not_a_parse_error() { + let ex = parse(&["install", "node"]); + assert_eq!( + ex.command.run(), + Err("refusing to install node".to_string()) + ); +} + +/// A `Box` is how the variant holds the struct, and the struct is what implements the trait, +/// so a boxed command dispatches like any other. mise boxes its largest commands. +#[test] +fn a_boxed_variant_dispatches() { + let ex = parse(&["install", "--force"]); + assert!(ex.command.run().is_ok()); +} + +/// A command with nothing to parse still has work to do. +#[test] +fn a_command_with_no_arguments_dispatches() { + let ex = parse(&["sponsors"]); + assert_eq!(ex.command.run(), Ok("sponsors".to_string())); +} + +/// The group in the middle — `ex config ls` — where the enum's dispatch reaches a struct +/// whose own dispatch forwards to the next enum. Neither level is written by hand. +#[test] +fn a_nested_command_dispatches_through_its_group() { + let ex = parse(&["config", "ls", "--no-header"]); + assert_eq!(ex.command.run(), Ok("config ls no_header=true".to_string())); +} + +/// The root declares a global flag, so it decides for itself what to do with it before +/// dispatching — which is why a struct with arguments of its own implements the trait rather +/// than getting a generated forward. +#[test] +fn a_root_with_flags_of_its_own_dispatches_after_reading_them() { + let ex = parse(&["--verbose", "sponsors"]); + assert!(ex.verbose); + assert_eq!(ex.command.run(), Ok("sponsors".to_string())); +} + +#[test] +fn a_context_reaches_the_command_that_ran() { + let mut log = Log::default(); + let ex = parse(&["install", "--force"]); + assert_eq!( + ex.command.run_with(&mut log), + Ok("install force=true".to_string()) + ); + assert_eq!(log.lines, ["install"]); +} + +#[test] +fn a_context_reaches_a_nested_command() { + let mut log = Log::default(); + let ex = parse(&["config", "ls"]); + assert_eq!( + ex.command.run_with(&mut log), + Ok("config ls no_header=false".to_string()) + ); + assert_eq!(log.lines, ["config ls"]); +} + +/// Both dispatches on one enum: an invocation with a context and one without are the same +/// command set, and a CLI part-way through adopting a context needs both to exist at once. +#[test] +fn one_enum_dispatches_with_and_without_a_context() { + let mut log = Log::default(); + assert!(parse(&["sponsors"]).command.run().is_ok()); + assert!(parse(&["sponsors"]).command.run_with(&mut log).is_ok()); + assert_eq!(log.lines, ["sponsors"]); +} + +/// Which Rust function carries out a command is not part of what the CLI *is*, so `run` says +/// nothing in the emitted spec — the same rule `#[usage(skip)]` follows. This is the check +/// that keeps a dispatch attribute from quietly becoming spec surface. +#[test] +fn dispatch_says_nothing_in_the_spec() { + let kdl = Ex::to_kdl(); + assert!(kdl.contains("cmd install"), "{kdl}"); + assert!(kdl.contains("cmd config"), "{kdl}"); + assert!(!kdl.contains("run"), "{kdl}"); +} diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 12a84de54..61cb5a286 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -120,6 +120,7 @@ fn validation_path() -> TokenStream { pub fn emit(cli: &Cli) -> TokenStream { let ident = &cli.ident; let runtime = runtime_path(); + let dispatch = emit_command_dispatch(cli, &runtime); let validation = validation_path(); let validation_import = cli .fields @@ -1206,6 +1207,8 @@ pub fn emit(cli: &Cli) -> TokenStream { } } }; + + #dispatch } } @@ -4563,6 +4566,7 @@ fn subcommand_parts(cli: &Cli) -> Option { pub fn emit_args(cli: &Cli) -> TokenStream { let ident = &cli.ident; let runtime = runtime_path(); + let dispatch = emit_command_dispatch(cli, &runtime); let validation = validation_path(); let validation_import = cli .fields @@ -5082,6 +5086,8 @@ pub fn emit_args(cli: &Cli) -> TokenStream { } } }; + + #dispatch } } @@ -5165,10 +5171,184 @@ fn meta_controls_field_presence(meta: &syn::Meta) -> bool { .is_ok_and(|nested| nested.iter().skip(1).any(meta_controls_field_presence)) } +/// The dispatch a `#[usage(run)]` enum gets: the `match` every CLI writes by hand. +/// +/// One arm per variant, handing the command's own struct to the trait that carries it out. +/// Nothing here reaches the spec, the parse tables, or help: which Rust function runs a +/// command is not part of what the CLI *is*, and a spec recording it could be read by nothing +/// but this program. `#[usage(skip)]` follows the same rule. +/// +/// The output type is the first variant's, and every other variant is required to agree — +/// stated as a bound naming that variant's type, so a command returning something else is +/// reported on the command rather than inside a generated arm. Both bounds are written as +/// `where` clauses rather than checked here, which is also what lets the enum be declared +/// before the implementations it dispatches to exist. +fn emit_subcommands_dispatch(subs: &Subcommands, runtime: &TokenStream) -> TokenStream { + if !subs.run && !subs.run_with { + return TokenStream::new(); + } + let ident = &subs.ident; + // Checked in `Subcommands::from_input`: a dispatched enum has variants, and each holds a + // named struct rather than nothing or its fields inline. + let first = &subs.variants[0].ty; + + // `*inner` is the one place a `Box` shows: the box is how the variant holds the struct, + // and the struct is what implements the trait. + let arm = |v: &crate::model::Variant| { + let variant = &v.ident; + let inner = if v.boxed { + quote!(*__usage_inner) + } else { + quote!(__usage_inner) + }; + (quote!(#ident::#variant(__usage_inner)), inner) + }; + + let run = subs.run.then(|| { + let bounds = subs.variants.iter().enumerate().map(|(i, v)| { + let ty = &v.ty; + if i == 0 { + quote!(#ty: usage_argv::Run) + } else { + quote!(#ty: usage_argv::Run::Output>) + } + }); + let arms = subs.variants.iter().map(|v| { + let (pattern, inner) = arm(v); + quote!(#pattern => usage_argv::Run::run(#inner),) + }); + quote! { + impl usage_argv::Run for #ident + where + #(#bounds,)* + { + type Output = <#first as usage_argv::Run>::Output; + + fn run(self) -> Self::Output { + match self { + #(#arms)* + } + } + } + } + }); + + // Generic over the context, so one generated implementation serves `&Config`, `&mut App` + // and an owned handle alike — the CLI decides what its commands are handed, and this + // crate never has to know. + let run_with = subs.run_with.then(|| { + let bounds = subs.variants.iter().enumerate().map(|(i, v)| { + let ty = &v.ty; + if i == 0 { + quote!(#ty: usage_argv::RunWith<__UsageCtx>) + } else { + quote! { + #ty: usage_argv::RunWith< + __UsageCtx, + Output = <#first as usage_argv::RunWith<__UsageCtx>>::Output, + > + } + } + }); + let arms = subs.variants.iter().map(|v| { + let (pattern, inner) = arm(v); + quote!(#pattern => usage_argv::RunWith::run_with(#inner, __usage_ctx),) + }); + quote! { + impl<__UsageCtx> usage_argv::RunWith<__UsageCtx> for #ident + where + #(#bounds,)* + { + type Output = <#first as usage_argv::RunWith<__UsageCtx>>::Output; + + fn run_with(self, __usage_ctx: __UsageCtx) -> Self::Output { + match self { + #(#arms)* + } + } + } + } + }); + + quote! { + #[doc(hidden)] + const _: () = { + use #runtime as usage_argv; + + #run + #run_with + }; + } +} + +/// The dispatch a `#[usage(run)]` struct gets: a forward to its subcommands. +/// +/// The `config`-style group that has no work of its own — declared as a struct holding +/// nothing but its subcommand field, which is what +/// [`Cli::check`](crate::model::Cli::check) holds it to, since forwarding is all this can do +/// and a struct with arguments of its own has to decide what becomes of them. +fn emit_command_dispatch(cli: &Cli, runtime: &TokenStream) -> TokenStream { + if !cli.run && !cli.run_with { + return TokenStream::new(); + } + let ident = &cli.ident; + // Checked in `Cli::check`: a struct asking for a dispatch holds exactly one field, and it + // is a non-optional subcommand. + let Some((field, ty)) = cli.fields.iter().find_map(|field| match &field.kind { + Kind::Subcommand { + ty, + optional: false, + } => Some((&field.ident, ty)), + _ => None, + }) else { + return TokenStream::new(); + }; + + let run = cli.run.then(|| { + quote! { + impl usage_argv::Run for #ident + where + #ty: usage_argv::Run, + { + type Output = <#ty as usage_argv::Run>::Output; + + fn run(self) -> Self::Output { + usage_argv::Run::run(self.#field) + } + } + } + }); + let run_with = cli.run_with.then(|| { + quote! { + impl<__UsageCtx> usage_argv::RunWith<__UsageCtx> for #ident + where + #ty: usage_argv::RunWith<__UsageCtx>, + { + type Output = <#ty as usage_argv::RunWith<__UsageCtx>>::Output; + + fn run_with(self, __usage_ctx: __UsageCtx) -> Self::Output { + usage_argv::RunWith::run_with(self.#field, __usage_ctx) + } + } + } + }); + + quote! { + #[doc(hidden)] + const _: () = { + use #runtime as usage_argv; + + #run + #run_with + }; + } +} + pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { let ident = &subs.ident; let runtime = runtime_path(); let derive = derive_path(); + let dispatch = emit_subcommands_dispatch(subs, &runtime); // The structs bare and inline variants imply, written here so everything downstream keeps // speaking to one Args struct. Clap-shaped `arg` attributes are rewritten to the native @@ -6036,6 +6216,8 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { } } }; + + #dispatch } } diff --git a/derive/src/lib.rs b/derive/src/lib.rs index a794e29eb..65472d099 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -119,6 +119,42 @@ //! unclaimed, and `Spec::to_kdl` asserts the tree holds no duplicate keys, so a //! collision fails a test rather than quietly doing the wrong thing. //! +//! # Dispatch +//! +//! `#[usage(run)]` on the enum writes the `match` that hands the selected command to the code +//! that carries it out — one arm per variant, each calling +//! [`Run::run`](usage_argv::Run::run) on the struct the variant holds: +//! +//! ```ignore +//! #[derive(Subcommands)] +//! #[usage(run)] +//! enum Commands { +//! Install(Install), +//! Sponsors(Sponsors), +//! } +//! +//! impl usage::Run for Install { +//! type Output = miette::Result<()>; +//! fn run(self) -> Self::Output { install(&self.tools, self.force) } +//! } +//! ``` +//! +//! `#[usage(run_with)]` is the same for [`RunWith`](usage_argv::RunWith), whose implementations +//! are handed a context — a config, an output handle, a client — and whose generated dispatch is +//! generic over what that is. An enum may say both. +//! +//! The output type is the first variant's, and each of the others is required to agree, so a +//! command returning something else is reported on the command. A `#[usage(run)]` *struct* gets +//! a forward to its own subcommands, which is all it can get: it holds one field, its +//! subcommands and not in an `Option`, since anything else would mean dropping what the struct +//! declared or deciding what no command means. A variant holding nothing, holding its fields +//! inline, or holding an `external_subcommand`'s argv cannot be dispatched — there is no type to +//! implement the trait for — and says so where it is declared. +//! +//! Nothing about any of this reaches the spec, the parse tables, or help: which Rust function +//! carries out a command is not part of what the CLI *is*. `#[usage(skip)]` follows the same +//! rule. +//! //! # What is decided after the parse //! //! The parser binds tokens. Whether what it bound is *acceptable* needs to know the @@ -222,7 +258,9 @@ //! spec, declared rather than worked out — `effect` — what running this command does to the world, on an `Args` //! rather than on the root, which does nothing itself — `completion`, which adds the hidden command a generated shell //! script calls, and needs usage-argv's `complete` feature enabled where it is depended on — -//! and `settings`, for a CLI whose bound flags all live in a flattened group (see [Settings]). +//! `settings`, for a CLI whose bound flags all live in a flattened group (see [Settings]) — +//! and `run` / `run_with`, which write the forward from a container command to its subcommands +//! (see [Dispatch](#dispatch)). //! //! [Settings]: #settings-and-the-flags-that-set-them //! @@ -411,6 +449,9 @@ pub fn derive_args(input: TokenStream) -> TokenStream { /// /// Each variant may wrap a struct deriving [`Args`] or declare its fields inline, /// clap-style. A field holding this enum is marked `#[usage(subcommand)]`. +/// +/// `#[usage(run)]` or `#[usage(run_with)]` on the enum also writes the `match` that hands the +/// selected command to its implementation; see the [crate docs](crate#dispatch). #[proc_macro_derive(Subcommands, attributes(usage, command, arg))] pub fn derive_subcommands(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); diff --git a/derive/src/model.rs b/derive/src/model.rs index 149acbb8b..e17e7f164 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -62,6 +62,15 @@ pub struct Cli { /// CLI with a subcommand depend on `usage-config`. A root that binds a setting of its own has /// already said it, and does not need this. pub settings: bool, + /// Whether the derive writes this command's dispatch, for a container struct. + /// + /// `#[usage(run)]` on a struct whose only field holds its subcommands generates the + /// `Run` implementation that forwards to the selected one — the `config`-style group that + /// does nothing itself. A struct that declares arguments of its own is refused, because + /// forwarding would drop them; see [`check`](Self::check). + pub run: bool, + /// The same, for [`RunWith`](usage_argv::RunWith): a dispatch that carries a context. + pub run_with: bool, /// The oldest `usage` that can read the emitted spec, when the CLI says. /// /// Declared rather than computed. Working it out would mean a table from every property to @@ -658,6 +667,8 @@ impl Cli { runtime_bin: None, completion: false, settings: false, + run: false, + run_with: false, min_usage_version: None, usage: None, effect: None, @@ -783,6 +794,8 @@ impl Cli { // decorative after it. "completion" => cli.completion = flag_value(&meta)?, "settings" => cli.settings = flag_value(&meta)?, + "run" => cli.run = flag_value(&meta)?, + "run_with" => cli.run_with = flag_value(&meta)?, "verbatim_doc_comment" => verbatim_doc_comment = flag_value(&meta)?, "effect" => cli.effect = Some(effect_value(&meta)?), "alias" | "aliases" if clap_attr => { @@ -972,7 +985,7 @@ impl Cli { `name`, `name_spec`, `bin`, `bin_spec`, `version`, `version_spec`, `long_version`, `long_version_spec`, `author`, `license`, `repository`, `source_code_link_template`, `usage`, `alias`, `alias_hidden`, `visible_alias`, `hide`, `deprecated`, `deprecated_warn_at`, `deprecated_remove_at`, `verbatim_doc_comment`, `unknown_flags`, \ `default_subcommand`, `multicall`, `no_binary_name`, `arg_required_else_help`, `disable_help_flag`, `disable_help_subcommand`, `disable_version_flag`, `dont_delimit_trailing_values`, `args_override_self`, `subcommand_negates_reqs`, `args_conflicts_with_subcommands`, `subcommand_precedence_over_arg`, `allow_missing_positional`, \ `next_help_heading`, `subcommand_help_heading`, `next_line_help`, `flatten_help`, `term_width`, `max_term_width`, \ - `subcommand_value_name`, `restart_token`, `mount`, `example` and \ + `subcommand_value_name`, `restart_token`, `mount`, `example`, `run`, `run_with` and \ `group` and `view` here, and the description comes from the doc \ comment" ), @@ -1482,6 +1495,46 @@ impl Cli { } } + // What `run` on a *struct* can generate is a forwarder to its subcommands, and only + // that: a struct that declares arguments of its own has to decide what to do with + // them, and a generated `run` that quietly dropped them would be a wrong program + // rather than a missing one. So the attribute belongs on a container — the + // `config`-style group that is nothing but its subcommand field — and every other + // shape says so where it is written, next to the alternative, which is to implement + // the trait by hand. + if self.run || self.run_with { + let attr = self.attr_span.unwrap_or_else(Span::call_site); + match self.fields.iter().find(|field| { + !matches!( + field.kind, + Kind::Subcommand { + optional: false, + .. + } + ) + }) { + Some(field) => { + return Err(syn::Error::new( + field.span, + "`run` on a struct forwards to its subcommands and can do nothing \ + else, so the struct holds one field: its subcommands, not in an \ + `Option`. A command that has arguments of its own — or that decides \ + what no subcommand means — implements `usage::Run` itself, and \ + `self..run()` is the forward this would have written", + )); + } + None if self.fields.is_empty() => { + return Err(syn::Error::new( + attr, + "`run` on a struct forwards to its subcommands, and this struct has \ + none. A command that does the work itself implements `usage::Run` \ + for it: that is the point the generated dispatch calls", + )); + } + None => {} + } + } + // A delimiter splits one word into several values, so the field has to be able to // hold several. Anything else would drop everything after the first separator — // silently, and only at run time, which is the worst way to find out. @@ -4480,6 +4533,16 @@ fn strip_dashes(s: &str) -> String { pub struct Subcommands { pub ident: syn::Ident, pub variants: Vec, + /// Whether the derive writes the `match` that hands the selected command to its code. + /// + /// Opt in rather than always: the generated implementation is the only one this enum can + /// have, so a CLI that wants to do something of its own before dispatching — or that + /// dispatches to a trait of its own — has to be able to keep writing the match. Asking + /// for it is also what makes a variant that cannot be dispatched an error where it is + /// declared rather than a missing implementation somewhere else. + pub run: bool, + /// The same, for [`RunWith`](usage_argv::RunWith): a dispatch that carries a context. + pub run_with: bool, } /// The name of the struct a bare variant implies. @@ -4591,17 +4654,22 @@ impl Subcommands { } let mut rename_all = None; + let mut run = false; + let mut run_with = false; for attr in attrs(&input.attrs) { for meta in nested(attr)? { let path = meta.path().clone(); match ident_of(&path).as_str() { "rename_all" => rename_all = Some(CasingStyle::parse(&meta)?), + "run" => run = flag_value(&meta)?, + "run_with" => run_with = flag_value(&meta)?, other => { return Err(syn::Error::new_spanned( path, format!( "unknown option `{other}` on a subcommand enum; \ - usage::Subcommands takes `rename_all` here" + usage::Subcommands takes `rename_all`, `run` and \ + `run_with` here" ), )); } @@ -4659,9 +4727,51 @@ impl Subcommands { } } + if run || run_with { + // A dispatch is a `match` over the variants, so every variant has to name + // something that can run. An `external_subcommand` variant names argv — the words + // nothing here claimed — and there is no type to implement the trait for, so the + // enum keeps its hand-written match rather than getting a generated one that + // cannot be exhaustive. + if let Some(external) = variants.iter().find(|v| v.external) { + return Err(syn::Error::new_spanned( + &external.ident, + "`run` generates a match over every variant, and this one holds the argv \ + of a command that is not declared here — there is nothing to implement \ + `usage::Run` for. Write the match by hand, or move the catch-all out of \ + the dispatched enum", + )); + } + if variants.is_empty() { + return Err(syn::Error::new_spanned( + &input.ident, + "`run` generates a match over the variants, and this enum has none", + )); + } + // A dispatched arm hands the command's own value to the trait, so the variant has + // to hold a type the CLI can write an implementation for. A bare variant and a + // variant declaring its fields inline are both served by a struct this derive + // writes for them, under a name nothing else can name — so the command that has + // work to do says where that work lives by holding its `Args` struct. + if let Some(variant) = variants + .iter() + .find(|v| v.unit || v.inline_fields.is_some()) + { + return Err(syn::Error::new_spanned( + &variant.ident, + "a dispatched command holds the struct its work is implemented on, and \ + this variant holds nothing this crate can name: write \ + `#[derive(usage::Args)] struct Sponsors;` and hold it — \ + `Sponsors(Sponsors)` — or leave `run` off and write the match by hand", + )); + } + } + Ok(Subcommands { ident: input.ident.clone(), variants, + run, + run_with, }) } } @@ -7675,4 +7785,137 @@ mod tests { "unhelpful message: {err}" ); } + + #[test] + fn a_container_struct_may_have_its_dispatch_written() { + let cli = cli(r#" + #[usage(run, run_with)] + struct Config { + #[usage(subcommand)] + command: ConfigCommand, + } + "#) + .expect("a struct holding only its subcommands can be dispatched"); + + assert!(cli.run); + assert!(cli.run_with); + } + + /// Forwarding is all a generated `run` on a struct can do, so a struct with arguments of + /// its own is refused rather than having them dropped on the way past. + #[test] + fn a_dispatched_struct_holds_nothing_but_its_subcommands() { + let err = rejection( + r#" + #[usage(run)] + struct Ex { + #[usage(long)] + verbose: bool, + #[usage(subcommand)] + command: Command, + } + "#, + ); + assert!( + err.contains("forwards to its subcommands"), + "unhelpful: {err}" + ); + assert!( + err.contains("implements `usage::Run` itself"), + "unhelpful: {err}" + ); + } + + /// An `Option` has a state — no command at all — that nothing generated can decide what to + /// do with. Whoever knows what an empty command line means writes the implementation. + #[test] + fn a_dispatched_struct_refuses_an_optional_subcommand() { + let err = rejection( + r#" + #[usage(run)] + struct Ex { + #[usage(subcommand)] + command: Option, + } + "#, + ); + assert!(err.contains("not in an `Option`"), "unhelpful: {err}"); + } + + #[test] + fn a_dispatched_struct_with_no_subcommands_says_where_the_work_goes() { + let err = rejection( + r#" + #[usage(run)] + struct Ex; + "#, + ); + assert!(err.contains("this struct has none"), "unhelpful: {err}"); + } + + #[test] + fn a_dispatched_enum_keeps_its_rename_policy() { + let subs = subcommands( + r#" + #[usage(rename_all = "snake_case", run)] + enum Command { + Install(Install), + } + "#, + ) + .expect("`run` sits beside the other enum options"); + + assert!(subs.run); + assert!(!subs.run_with); + assert_eq!(subs.variants[0].name, "install"); + } + + /// The catch-all holds the argv of a command that is not declared here, so there is no type + /// to implement the trait for and no exhaustive match to generate. + #[test] + fn a_dispatched_enum_refuses_an_external_subcommand() { + let err = enum_rejection( + r#" + #[usage(run)] + enum Command { + Install(Install), + #[usage(external_subcommand)] + Other(Vec), + } + "#, + ); + assert!(err.contains("nothing to implement"), "unhelpful: {err}"); + } + + /// A bare variant's struct is written by the derive, under a name nothing else can name — + /// so the command that has work to do holds its own `Args` struct instead. + #[test] + fn a_dispatched_enum_refuses_a_variant_that_holds_nothing() { + let err = enum_rejection( + r#" + #[usage(run)] + enum Command { + Install(Install), + Sponsors, + } + "#, + ); + assert!( + err.contains("holds nothing this crate can name"), + "unhelpful: {err}" + ); + + let inline = enum_rejection( + r#" + #[usage(run_with)] + enum Command { + Add { path: String }, + } + "#, + ); + assert!( + inline.contains("holds nothing this crate can name"), + "unhelpful: {inline}" + ); + } } diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 7c05f617f..0c6d30388 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -65,6 +65,7 @@ export default defineConfig({ items: [ { text: "Args and Flags", link: "/rust/args-and-flags" }, { text: "Subcommands", link: "/rust/subcommands" }, + { text: "Dispatch", link: "/rust/dispatch" }, { text: "Migrating from clap", link: "/rust/migrating-from-clap" }, { text: "clap Compatibility", link: "/rust/clap-compatibility" }, { text: "Validation", link: "/rust/validation" }, diff --git a/docs/rust/dispatch.md b/docs/rust/dispatch.md new file mode 100644 index 000000000..503171a62 --- /dev/null +++ b/docs/rust/dispatch.md @@ -0,0 +1,157 @@ +# Dispatch + +::: warning Draft +This page is a draft. Some of what it documents is still in open pull requests, and details may +change before release. +::: + +A parse ends with a value: an enum whose selected variant holds the command's own struct. What +every CLI then writes is the same `match` — one arm per command, each calling the one function +that command exists for. At mise's size that is 210 arms of pure routing, and nothing checks +that an arm calls the right thing, because every arm has the same shape. + +`#[usage(run)]` generates it. A command implements `Run`, the enum says it dispatches, and the +match comes from the same declaration the parser and the spec come from: + +```rust +use usage::{Args, Cli, Run, Subcommands}; + +#[derive(Subcommands)] +#[usage(run)] +enum Commands { + /// Install a tool + Install(Install), + /// Show who pays for this + Sponsors(Sponsors), +} + +#[derive(Args)] +struct Install { + /// Overwrite an existing install + #[usage(short = 'f', long)] + force: bool, + /// What to install + tools: Vec, +} + +/// Show who pays for this +#[derive(Args)] +struct Sponsors; + +impl Run for Install { + type Output = miette::Result<()>; + fn run(self) -> Self::Output { + install(&self.tools, self.force) + } +} + +impl Run for Sponsors { + type Output = miette::Result<()>; + fn run(self) -> Self::Output { + print_sponsors(); + Ok(()) + } +} + +fn main() -> miette::Result<()> { + Cli::parse().command.run() +} +``` + +A command added to the enum and not implemented is a compile error naming the command. A +command whose output disagrees with its siblings is a compile error naming that command too: a +`match` has one type, and the dispatch takes its `Output` from the first variant and requires +the rest to agree. + +`Output` is yours. `miette::Result<()>`, `anyhow::Result<()>`, `Result<(), MyError>`, `()`, +`std::process::ExitCode` — whatever the CLI's commands actually produce. + +## A context + +Most CLIs hand their commands something: a resolved config, an output handle, a client. That is +`RunWith`, and `#[usage(run_with)]` dispatches it: + +```rust +use usage::{RunWith, Subcommands}; + +#[derive(Subcommands)] +#[usage(run_with)] +enum Commands { + Install(Install), + Sponsors(Sponsors), +} + +impl RunWith<&mut App> for Install { + type Output = miette::Result<()>; + fn run_with(self, app: &mut App) -> Self::Output { + app.install(&self.tools, self.force) + } +} +``` + +```rust +fn main() -> miette::Result<()> { + let cli = Cli::parse(); + let mut app = App::new(cli.verbose)?; + cli.command.run_with(&mut app) +} +``` + +The generated implementation is generic over the context, so `RunWith<&Config>`, +`RunWith<&mut App>` and `RunWith>` are all ordinary implementations rather than shapes +this crate has to anticipate. An enum may say both `run` and `run_with`, which is what a CLI +part-way through adopting a context needs. + +Two traits rather than one with a defaulted context, because otherwise the noise lands on the +wrong side: a hundred commands that need nothing shared would each carry `fn run(self, _: ())`. + +## Groups in the middle + +A command that exists only to hold other commands — `usage generate`, `mise config` — gets its +forward generated too. `#[usage(run)]` on a struct whose one field is its subcommands: + +```rust +/// Generate completions, documentation, and other artifacts +#[derive(Args)] +#[usage(alias = "g", run)] +pub struct Generate { + #[usage(subcommand)] + pub command: Command, +} +``` + +That is all a generated `run` on a struct can do, so the struct holds one field: its +subcommands, not in an `Option`. Any other shape is a compile error, because forwarding past +arguments the struct declared would drop them: + +- A struct with **flags or arguments of its own** has to decide what to do with them, so it + implements `Run` by hand — usually reading them and then calling `self.command.run()`. That + is the root's usual case: `--verbose` is set up before anything dispatches. +- An **`Option` subcommand** has a state — no command at all — that nothing generated can + decide about. Whoever knows what an empty command line means writes the implementation. + +## What cannot be dispatched + +A dispatched arm hands the command's own value to the trait, so every variant has to hold a +type you can implement the trait for: + +- A **unit variant** (`Sponsors,`) and a **variant declaring its fields inline** + (`Add { path: String }`) are both served by a struct the derive writes for them, under a name + nothing else can name. Hold an `Args` struct instead — `Sponsors(Sponsors)` — which is where + the command's `effect` and description belong anyway. +- An **`external_subcommand`** variant holds the argv of a command that is not declared here. + There is nothing to implement `Run` for and no exhaustive match to generate, so an enum with + a catch-all keeps its hand-written match. + +Both are compile errors on the variant rather than a missing implementation somewhere else, +which is the reason dispatch is opt-in rather than always generated. The other reason is that +the generated implementation is the only one the enum can have: a CLI that wants to do +something of its own between the parse and the dispatch leaves `run` off. + +## What it says in the spec + +Nothing. Which Rust function carries out a command is not part of what the CLI _is_, and a spec +recording it could be read by nothing but this program — so `run` and `run_with` reach the parse +tables, the help output and the emitted KDL exactly as much as `#[usage(skip)]` does, which is +not at all. `usage`'s own CLI moved to a generated dispatch without one byte of its spec, its +manpage or its completions changing. diff --git a/docs/rust/index.md b/docs/rust/index.md index 198d5bdce..1e39b9826 100644 --- a/docs/rust/index.md +++ b/docs/rust/index.md @@ -105,6 +105,18 @@ failure to stderr and exits `2` — clap's exit status, so scripts that check fo `parse_from` gives you the same machinery without the process control; see [Help, version, and errors](/rust/help) for handling its `Err` variants. +What runs afterwards can be generated too. A command implements `Run` (or `RunWith`, when +the CLI hands its commands shared state), the subcommand enum says `#[usage(run)]`, and the +`match` that routes argv to the code carrying it out is written from the same declaration: + +```rust +fn main() -> miette::Result<()> { + Cli::parse().command.run() +} +``` + +See [Dispatch](/rust/dispatch). + ## One declaration, every artifact Because the derive also emits a usage spec, everything on this site that consumes a spec works @@ -137,6 +149,7 @@ See [Spec output](/rust/spec) for the round-trip guarantees and what the emitted - [Args and flags](/rust/args-and-flags) — field types, attributes, env vars, defaults - [Subcommands](/rust/subcommands) — command enums, nesting, `flatten`, value enums +- [Dispatch](/rust/dispatch) — `Run`, `RunWith`, and the generated `match` - [Validation](/rust/validation) — choices, groups, `exclusive`, `delimiter`, conflicts - [Help, version, and errors](/rust/help) — what the parser renders and how to hook it - [Completions](/rust/completions) — static scripts and runtime completion diff --git a/docs/rust/migrating-from-clap.md b/docs/rust/migrating-from-clap.md index e4246a8b2..a0c3432f0 100644 --- a/docs/rust/migrating-from-clap.md +++ b/docs/rust/migrating-from-clap.md @@ -200,6 +200,13 @@ clap tests usually include argv0. Choose the matching entry point explicitly: routing. Handle `usage::Error::Help` and `usage::Error::Version` before dispatch when an embedder must intercept those built-ins. +The `match cli.command { … }` a clap CLI writes after parsing can go too: implement +`usage::Run` on each command struct, say `#[usage(run)]` on the enum, and the routing is +generated. Commands that need shared state implement `usage::RunWith` instead. A variant +holding nothing — clap's unit or inline-struct variants, and `external_subcommand` — has no type +to implement the trait for, so those keep their hand-written arms; see +[Dispatch](/rust/dispatch). + ## Help, specs, and completions Doc comments remain the source of short and long help. `Cli::to_kdl()` emits the portable spec; diff --git a/docs/rust/subcommands.md b/docs/rust/subcommands.md index c0a9bf7a9..ad69c2292 100644 --- a/docs/rust/subcommands.md +++ b/docs/rust/subcommands.md @@ -49,6 +49,8 @@ struct Install { `effect` go directly on the variant. - Nesting is unbounded in practice: an `Args` struct can carry its own `#[usage(subcommand)]` field, up to a maximum depth of 16. +- `#[usage(run)]` on the enum generates the `match` that hands the selected command to the code + that carries it out; see [Dispatch](/rust/dispatch). Variant attributes: `name`, `alias`, `alias_hidden`, `hide`, `effect`, `help`, `long_help`, `verbatim_doc_comment`, `external_subcommand`, `arg_required_else_help`. Aliases declared on the variant and on the `Args` diff --git a/usage-rs/src/lib.rs b/usage-rs/src/lib.rs index da8ff8f98..33bc34aa8 100644 --- a/usage-rs/src/lib.rs +++ b/usage-rs/src/lib.rs @@ -10,6 +10,11 @@ //! usage = { package = "usage-rs", version = "5.1" } //! ``` //! +//! What happens after a parse can come from the same declaration: a command implements +//! [`Run`] — or [`RunWith`], when the CLI hands its commands shared state — the subcommand enum +//! says `#[usage(run)]`, and the `match` that routes argv to the code carrying it out is +//! generated rather than written. Nothing about it reaches the spec. +//! //! Enable portable expression validation only when a CLI declares `validate` rules: //! //! ```toml diff --git a/usage-rs/tests/facade.rs b/usage-rs/tests/facade.rs index 417952d7c..51b3dfca5 100644 --- a/usage-rs/tests/facade.rs +++ b/usage-rs/tests/facade.rs @@ -2598,3 +2598,55 @@ fn what_a_user_reads_says_what_to_do_about_it() { "warning: --output is deprecated, removed at 3.0.0: use --out\n", ); } + +/// The facade is the one package an application depends on, so the dispatch traits reach it +/// through the same `usage::` path as the derives — a CLI that hands its commands to their +/// implementations does not learn the crate split to do it. +#[derive(Args)] +struct DispatchLeaf { + #[usage(long)] + force: bool, +} + +#[derive(Subcommands)] +#[usage(run, run_with)] +enum DispatchCommand { + /// Do the one thing + Go(DispatchLeaf), +} + +#[derive(Cli)] +#[usage(bin = "dispatch-ex")] +struct DispatchEx { + #[usage(subcommand)] + command: DispatchCommand, +} + +impl usage::Run for DispatchLeaf { + type Output = bool; + fn run(self) -> Self::Output { + self.force + } +} + +impl usage::RunWith<&mut usize> for DispatchLeaf { + type Output = bool; + fn run_with(self, calls: &mut usize) -> Self::Output { + *calls += 1; + self.force + } +} + +#[test] +fn the_facade_exposes_the_dispatch_traits() { + use usage::{Run, RunWith}; + + let ex = DispatchEx::parse_from(&[OsStr::new("go"), OsStr::new("--force")]) + .expect("valid command line"); + assert!(ex.command.run()); + + let mut calls = 0; + let ex = DispatchEx::parse_from(&[OsStr::new("go")]).expect("valid command line"); + assert!(!ex.command.run_with(&mut calls)); + assert_eq!(calls, 1); +} From 3ee5a97147a83cb0b14b5ee39ebe4888b807b006 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:23:10 +0000 Subject: [PATCH 2/3] test(derive): cover async command dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Output` is whatever the command produces, so an async command names a boxed future and the generated match returns one to await. Held by a test rather than asserted: two commands whose futures yield before finishing, dispatched plain and with a borrowed context whose lifetime the future carries, driven by a spinning executor so a future that is never resumed cannot pass. Also says why neither trait is `async` itself — an `async fn` in a public trait cannot promise `Send`, and `-> impl Future + Send` would commit every command in every CLI to one. Co-Authored-By: Claude Opus 5 --- argv/src/run.rs | 13 +++ conformance/tests/dispatch_async.rs | 154 ++++++++++++++++++++++++++++ docs/rust/dispatch.md | 35 +++++++ 3 files changed, 202 insertions(+) create mode 100644 conformance/tests/dispatch_async.rs diff --git a/argv/src/run.rs b/argv/src/run.rs index 8fe589328..9479cba52 100644 --- a/argv/src/run.rs +++ b/argv/src/run.rs @@ -31,6 +31,19 @@ //! implement both, and an enum may dispatch both, when some invocations have a context and //! others do not. //! +//! # Async commands +//! +//! [`Output`](Run::Output) is whatever the command produces, and a future is a value like any +//! other: an async command names a boxed future — `Pin + Send>>` — +//! and `main` awaits what the dispatch returns. The box is because an `async` block's type +//! cannot be named and an associated type has to be. +//! +//! Neither trait is `async` itself, deliberately. An `async fn` in a public trait cannot say +//! `+ Send` about the future it returns, so a caller that needs to spawn it cannot require one, +//! and desugaring to `-> impl Future + Send` instead would commit every command in every CLI to +//! a `Send` future — ruling out the single-threaded runtimes some of them use. A native +//! `async fn run` belongs in a third trait beside these two rather than in a change to them. +//! //! # An example //! //! ``` diff --git a/conformance/tests/dispatch_async.rs b/conformance/tests/dispatch_async.rs new file mode 100644 index 000000000..03fc8a3e0 --- /dev/null +++ b/conformance/tests/dispatch_async.rs @@ -0,0 +1,154 @@ +//! Async commands, dispatched. +//! +//! `Output` is whatever the command produces, and a future is a value like any other — so an +//! async command's dispatch is the same generated match, returning a future to await rather +//! than a result to inspect. There is nothing async in the traits themselves: they would have +//! to name a future type the CLI owns, and boxing one is the CLI's decision rather than this +//! crate's. + +use std::ffi::OsStr; +use std::future::Future; +use std::pin::Pin; + +use usage_argv::{Run, RunWith}; +use usage_derive::{Args, Cli, Subcommands}; + +/// What an async command returns: a future the caller awaits. +/// +/// Boxed because an `async` block's type cannot be named, and an associated type has to be. +/// One allocation per invocation, on the path that is about to do I/O anyway. +type Task<'a, T> = Pin + Send + 'a>>; + +/// Install a tool +#[derive(Args)] +struct Install { + #[usage(long)] + force: bool, +} + +/// Show who pays for this +#[derive(Args)] +struct Sponsors; + +#[derive(Subcommands)] +#[usage(run, run_with)] +enum Command { + Install(Install), + Sponsors(Sponsors), +} + +/// A tool that does things +#[derive(Cli)] +#[usage(bin = "ex")] +struct Ex { + #[usage(subcommand)] + command: Command, +} + +impl Run for Install { + type Output = Task<'static, Result>; + fn run(self) -> Self::Output { + Box::pin(async move { + yield_once().await; + Ok(format!("install force={}", self.force)) + }) + } +} + +impl Run for Sponsors { + type Output = Task<'static, Result>; + fn run(self) -> Self::Output { + Box::pin(async move { + yield_once().await; + Ok("sponsors".to_string()) + }) + } +} + +/// What a CLI hands its commands. A borrowed context is what ties the future's lifetime, which +/// is why `Task` takes one. +struct App { + jobs: usize, +} + +impl<'a> RunWith<&'a App> for Install { + type Output = Task<'a, Result>; + fn run_with(self, app: &'a App) -> Self::Output { + Box::pin(async move { + yield_once().await; + Ok(format!("install force={} jobs={}", self.force, app.jobs)) + }) + } +} + +impl<'a> RunWith<&'a App> for Sponsors { + type Output = Task<'a, Result>; + fn run_with(self, _: &'a App) -> Self::Output { + Box::pin(async move { + yield_once().await; + Ok("sponsors".to_string()) + }) + } +} + +fn parse(words: &[&str]) -> Ex { + let argv: Vec<&OsStr> = words.iter().map(OsStr::new).collect(); + Ex::parse_from(&argv).expect("valid command line") +} + +#[test] +fn an_async_command_dispatches_to_a_future() { + let ex = parse(&["install", "--force"]); + assert_eq!( + block_on(ex.command.run()), + Ok("install force=true".to_string()) + ); +} + +#[test] +fn an_async_command_dispatches_with_a_borrowed_context() { + let app = App { jobs: 4 }; + let ex = parse(&["install"]); + assert_eq!( + block_on(ex.command.run_with(&app)), + Ok("install force=false jobs=4".to_string()) + ); + let ex = parse(&["sponsors"]); + assert_eq!(block_on(ex.command.run_with(&app)), Ok("sponsors".into())); +} + +/// The smallest executor that proves these are real futures: no runtime dependency in the +/// conformance crate, and a command that yields is resumed rather than run to completion on +/// the first poll. +fn block_on(future: F) -> F::Output { + use std::task::{Context, Poll, Waker}; + + let mut future = Box::pin(future); + // Spinning rather than parking, which is the whole executor this needs: nothing here waits + // on anything outside the test. + let waker = Waker::noop(); + let mut cx = Context::from_waker(waker); + loop { + if let Poll::Ready(value) = future.as_mut().poll(&mut cx) { + return value; + } + } +} + +/// One `Pending` before finishing, so a future that is not resumed cannot pass these tests. +async fn yield_once() { + struct YieldOnce(bool); + impl Future for YieldOnce { + type Output = (); + fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<()> { + if self.0 { + std::task::Poll::Ready(()) + } else { + self.0 = true; + cx.waker().wake_by_ref(); + std::task::Poll::Pending + } + } + } + YieldOnce(false).await +} diff --git a/docs/rust/dispatch.md b/docs/rust/dispatch.md index 503171a62..1ea0b6ba2 100644 --- a/docs/rust/dispatch.md +++ b/docs/rust/dispatch.md @@ -105,6 +105,41 @@ part-way through adopting a context needs. Two traits rather than one with a defaulted context, because otherwise the noise lands on the wrong side: a hundred commands that need nothing shared would each carry `fn run(self, _: ())`. +## Async commands + +`Output` is whatever the command produces, and a future is a value like any other — so an async +command's dispatch is the same generated match, returning a future for `main` to await: + +```rust +type Task = Pin + Send>>; + +impl Run for Install { + type Output = Task>; + fn run(self) -> Self::Output { + Box::pin(async move { install(&self.tools, self.force).await }) + } +} + +#[tokio::main] +async fn main() -> miette::Result<()> { + Cli::parse().command.run().await +} +``` + +The box is because an `async` block's type cannot be named and an associated type has to be. One +allocation, on a path that is about to do I/O. A borrowed context works the same way, with the +future's lifetime tied to it: `impl<'a> RunWith<&'a App> for Install { type Output = Task<'a, …> }`. + +The traits are deliberately not `async` themselves. An `async fn` in a public trait cannot say +`+ Send` about the future it returns, so callers that need to spawn it have no way to require +one — and desugaring to `-> impl Future + Send` instead would commit every command in every CLI +to a `Send` future, which rules out the single-threaded runtimes some of them use. A CLI that +wants `async fn run(self)` without the box can say so; the shape is a third trait beside these +two, not a change to them. + +A CLI whose commands are mostly synchronous can also keep `Output = Result<()>` and hold a +runtime handle in its context, which is what `RunWith` is for. + ## Groups in the middle A command that exists only to hold other commands — `usage generate`, `mise config` — gets its From 822853ffd3c66ac2fb49ac93067cf4f4318f781d Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:06:31 +0000 Subject: [PATCH 3/3] feat(derive): add async command dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RunAsync` and `RunAsyncWith`, asked for by `#[usage(run_async)]` and `#[usage(run_async_with)]`: an implementation writes `async fn` and the generated dispatch is an `async fn` that awaits the selected command. Four traits now, differing only in whether a command is handed a context and whether it is awaited, which is why the emitters describe one rather than repeating four — a context makes the generated impl generic, and being awaited puts `async` on the signature and `.await` on each arm. The async pair declares `-> impl Future` rather than `async fn`, which is the same signature to implement against and imposes no `Send` bound. A CLI that spawns gets `Send` by inference out of the concrete commands the dispatch reaches; one on a single-threaded runtime keeps a future holding an `Rc` across an await. Both are held by tests. Also from review: - Diagnostics quote back the attribute the author wrote and the trait it generates, rather than naming `run` at someone who wrote `run_async_with`. - The `Send` in the boxed-future examples is documented as the CLI's choice rather than a contract, since `Output` has no bound. - The dispatch page's context example implements every variant, as a dispatched enum requires, and the Rust index example declares the `command` field it dispatches through. - Both opt-ins are named wherever dispatch is mentioned in passing. - Grammar in the `Sponsors` module doc. Co-Authored-By: Claude Opus 5 --- PLAN.md | 26 ++- argv/src/lib.rs | 2 +- argv/src/run.rs | 95 +++++++-- cli/src/cli/sponsors.rs | 2 +- conformance/tests/dispatch_async.rs | 270 +++++++++++++++++++----- derive/src/codegen.rs | 308 ++++++++++++++++++---------- derive/src/lib.rs | 19 +- derive/src/model.rs | 192 +++++++++++++---- docs/rust/dispatch.md | 111 ++++++++-- docs/rust/index.md | 34 ++- docs/rust/migrating-from-clap.md | 9 +- docs/rust/subcommands.md | 3 +- usage-rs/src/lib.rs | 9 +- 13 files changed, 800 insertions(+), 280 deletions(-) diff --git a/PLAN.md b/PLAN.md index 300f11307..7f03b16ba 100644 --- a/PLAN.md +++ b/PLAN.md @@ -357,8 +357,9 @@ tasks --usage"`, so task names are meant to come from running that. usage-argv d - [x] **Dispatch** — the `match` from the parsed enum to the code that carries the command out, which every adopter writes and nobody varies: 210 arms of pure routing at mise's size, none of them checkable, since every arm has the same - shape. `usage_argv::Run` / `RunWith` are the two traits a command - implements and `#[usage(run)]` / `#[usage(run_with)]` on the enum generate the + shape. `usage_argv::Run`, `RunWith`, `RunAsync` and `RunAsyncWith` are + the traits a command implements, and `#[usage(run)]`, `#[usage(run_with)]`, + `#[usage(run_async)]` and `#[usage(run_async_with)]` on the enum generate the match. **Nothing reaches the spec** — which Rust function runs a command is not part of what the CLI _is_, and a spec recording it could be read by nothing but the program that wrote it, so this is `#[usage(skip)]`'s rule rather than a new @@ -366,10 +367,23 @@ tasks --usage"`, so task names are meant to come from running that. usage-argv d `usage --usage-spec` is byte-identical, so no manpage, reference or completion changed. Decisions, each because the alternative is a wrong program rather than a missing one: - **two traits, not one with a defaulted context** — a hundred commands needing - nothing shared would each carry `fn run(self, _: ())`, and `RunWith`'s generated - impl is generic over `Ctx` so `&Config`, `&mut App` and an owned handle all work - from one emission; an enum may declare both. + **a trait per shape, not one with defaults** — four of them, differing only in + whether a command is handed a context and whether it is awaited: `Run`, + `RunWith`, `RunAsync`, `RunAsyncWith`, asked for by the matching + attribute. A defaulted context would make a hundred commands needing nothing + shared each carry `fn run(self, _: ())`; the `With` pair is generic over `Ctx`, + so `&Config`, `&mut App` and an owned handle all work from one emission. An enum + may declare several, which is what a CLI part-way through adopting a context or a + runtime needs. + **The async pair declares `-> impl Future` rather than `async fn`**, which is the + same signature to implement against and imposes no `Send` bound: a CLI that spawns + gets `Send` by inference out of the concrete commands, and one on a single-threaded + runtime keeps a future holding an `Rc` across an await. `-> impl Future + Send` + would buy the ability to _demand_ `Send` in generic code at the cost of the second, + and there is no way to have both without a fifth trait. The sync pair can still + carry a boxed future as its `Output`, which is what to reach for when the future + has to be a value; the async traits exist so that neither the box nor the name is + necessary. **The output is the first variant's**, with the others bound to agree, so a command returning something else is reported on the command rather than inside a generated arm. diff --git a/argv/src/lib.rs b/argv/src/lib.rs index 95352937e..4624f7257 100644 --- a/argv/src/lib.rs +++ b/argv/src/lib.rs @@ -170,7 +170,7 @@ pub mod spec; #[cfg(feature = "spec")] pub mod warn; -pub use run::{Run, RunWith}; +pub use run::{Run, RunAsync, RunAsyncWith, RunWith}; /// How deep a command tree this parser will descend. /// diff --git a/argv/src/run.rs b/argv/src/run.rs index 9479cba52..0cc546cbc 100644 --- a/argv/src/run.rs +++ b/argv/src/run.rs @@ -13,36 +13,61 @@ //! spec that recorded it could not be read by anything that is not this program. It is the //! same rule `#[usage(skip)]` follows. //! -//! Both traits take `self` by value. A command is finished when it has run, and the values -//! it parsed are its own — taking them by reference would mean every handler borrowing what -//! nothing else can want. +//! Every one of these traits takes `self` by value. A command is finished when it has run, and +//! the values it parsed are its own — taking them by reference would mean every handler +//! borrowing what nothing else can want. //! //! # Which one //! -//! [`Run`] is for a CLI whose commands need nothing but what they parsed. [`RunWith`] is for -//! one that hands them something — a resolved config, an output handle, a database -//! connection — and is generic over what that something is, so `RunWith<&mut App>` and -//! `RunWith>` are both ordinary implementations rather than a shape this crate has -//! to anticipate. +//! | | no context | a context | +//! | -------------- | ---------- | -------------------- | +//! | **sync** | [`Run`] | [`RunWith`] | +//! | **async** | [`RunAsync`] | [`RunAsyncWith`] | //! -//! They are two traits rather than one with a defaulted context because the noise falls on -//! the wrong side of a CLI otherwise: a hundred commands that need no context would each -//! carry `fn run(self, _: ())`, which says nothing and cannot be left out. A type may -//! implement both, and an enum may dispatch both, when some invocations have a context and -//! others do not. +//! The context is whatever the CLI has to give — a resolved config, an output handle, a +//! database connection — and the `With` traits are generic over it, so `RunWith<&mut App>` and +//! `RunAsyncWith>` are ordinary implementations rather than shapes this crate has to +//! anticipate. +//! +//! A context is a separate trait rather than one defaulted to `()` because the noise otherwise +//! falls on the wrong side of a CLI: a hundred commands that need no context would each carry +//! `fn run(self, _: ())`, which says nothing and cannot be left out. One type may implement +//! several of these, and one enum may dispatch several, which is what a CLI part-way through +//! adopting a context — or an async runtime — needs. //! //! # Async commands //! -//! [`Output`](Run::Output) is whatever the command produces, and a future is a value like any -//! other: an async command names a boxed future — `Pin + Send>>` — -//! and `main` awaits what the dispatch returns. The box is because an `async` block's type -//! cannot be named and an associated type has to be. +//! [`RunAsync`] and [`RunAsyncWith`] are the async pair: an implementation writes `async fn`, +//! and the generated dispatch is an `async fn` that awaits the selected command. +//! +//! ``` +//! use usage_argv::RunAsync; +//! +//! struct Install { +//! force: bool, +//! } +//! +//! impl RunAsync for Install { +//! type Output = Result<(), String>; +//! async fn run_async(self) -> Self::Output { +//! // .await here +//! Ok(()) +//! } +//! } +//! ``` +//! +//! The trait declares `-> impl Future` rather than `async fn`, which is +//! the same thing on the implementing side and **deliberately imposes no `Send` bound**: a CLI +//! on a single-threaded runtime keeps futures that hold an `Rc` across an await, and one that +//! spawns gets `Send` by inference, since it leaks out of the concrete commands the dispatch +//! reaches. What this cannot do is *demand* `Send` in generic code, which is the trade the +//! alternative — `-> impl Future + Send` in the trait — makes in the other direction, and +//! there is no way to have both without duplicating the trait. //! -//! Neither trait is `async` itself, deliberately. An `async fn` in a public trait cannot say -//! `+ Send` about the future it returns, so a caller that needs to spawn it cannot require one, -//! and desugaring to `-> impl Future + Send` instead would commit every command in every CLI to -//! a `Send` future — ruling out the single-threaded runtimes some of them use. A native -//! `async fn run` belongs in a third trait beside these two rather than in a change to them. +//! The sync pair can carry a future too, since [`Output`](Run::Output) is whatever the command +//! produces: a boxed `Pin>>` (plus `+ Send` if the CLI wants it) is +//! a value like any other. That costs an allocation and names a type; the async traits exist so +//! that neither is necessary. //! //! # An example //! @@ -124,3 +149,29 @@ pub trait RunWith { /// Carry out the command, with `ctx`. fn run_with(self, ctx: Ctx) -> Self::Output; } + +/// An async command: [`Run`], awaited. +/// +/// The signature is `-> impl Future` rather than `async fn` so that no `Send` bound is implied +/// either way — an implementation still writes `async fn run_async(self)`, and whether its +/// future is `Send` is decided by what the command does rather than by this trait. See the +/// [module docs](self#async-commands). +pub trait RunAsync { + /// What running the command produces, once awaited. + type Output; + + /// Carry out the command. + fn run_async(self) -> impl core::future::Future; +} + +/// An async command that is handed something shared when it runs: [`RunWith`], awaited. +/// +/// A borrowed context is the ordinary case, and the future borrows it for as long as it runs: +/// `impl<'a> RunAsyncWith<&'a App> for Install`. +pub trait RunAsyncWith { + /// What running the command produces, once awaited. + type Output; + + /// Carry out the command, with `ctx`. + fn run_async_with(self, ctx: Ctx) -> impl core::future::Future; +} diff --git a/cli/src/cli/sponsors.rs b/cli/src/cli/sponsors.rs index c7d2efea6..45d5cafb8 100644 --- a/cli/src/cli/sponsors.rs +++ b/cli/src/cli/sponsors.rs @@ -2,7 +2,7 @@ //! //! A command that takes nothing, so it is a unit struct: nothing to declare, and a struct is //! what the dispatched command enum hands its work to. `effect` and the description live here -//! with it, where every other command's do. +//! with it, the way every other command declares its own. /// Show the companies sponsoring usage and the jdx.dev open source tools #[derive(usage_rs::Args)] diff --git a/conformance/tests/dispatch_async.rs b/conformance/tests/dispatch_async.rs index 03fc8a3e0..004520b5c 100644 --- a/conformance/tests/dispatch_async.rs +++ b/conformance/tests/dispatch_async.rs @@ -1,24 +1,22 @@ //! Async commands, dispatched. //! -//! `Output` is whatever the command produces, and a future is a value like any other — so an -//! async command's dispatch is the same generated match, returning a future to await rather -//! than a result to inspect. There is nothing async in the traits themselves: they would have -//! to name a future type the CLI owns, and boxing one is the CLI's decision rather than this -//! crate's. +//! Two ways to have one, and both are held here. `RunAsync` / `RunAsyncWith` are the async +//! pair: an implementation writes `async fn` and the generated dispatch awaits the selected +//! command. The sync pair can also carry a future, since `Output` is whatever the command +//! produces — at the cost of naming and boxing it. +//! +//! What the async traits deliberately do *not* impose is `Send`: a CLI on a single-threaded +//! runtime keeps futures that hold an `Rc` across an await, which is what +//! `a_future_that_is_not_send_still_dispatches` is for. use std::ffi::OsStr; use std::future::Future; use std::pin::Pin; +use std::rc::Rc; -use usage_argv::{Run, RunWith}; +use usage_argv::{Run, RunAsync, RunAsyncWith, RunWith}; use usage_derive::{Args, Cli, Subcommands}; -/// What an async command returns: a future the caller awaits. -/// -/// Boxed because an `async` block's type cannot be named, and an associated type has to be. -/// One allocation per invocation, on the path that is about to do I/O anyway. -type Task<'a, T> = Pin + Send + 'a>>; - /// Install a tool #[derive(Args)] struct Install { @@ -30,11 +28,37 @@ struct Install { #[derive(Args)] struct Sponsors; +/// List the configuration +#[derive(Args)] +struct ConfigLs { + #[usage(long)] + no_header: bool, +} + #[derive(Subcommands)] -#[usage(run, run_with)] +#[usage(run_async, run_async_with)] +enum ConfigCommand { + /// List the configuration + Ls(ConfigLs), +} + +/// Work with the configuration +#[derive(Args)] +#[usage(run_async, run_async_with)] +struct Config { + #[usage(subcommand)] + command: ConfigCommand, +} + +#[derive(Subcommands)] +#[usage(run_async, run_async_with)] enum Command { - Install(Install), + /// Install a tool + Install(Box), + /// Show who pays for this Sponsors(Sponsors), + /// Work with the configuration + Config(Config), } /// A tool that does things @@ -45,49 +69,57 @@ struct Ex { command: Command, } -impl Run for Install { - type Output = Task<'static, Result>; - fn run(self) -> Self::Output { - Box::pin(async move { - yield_once().await; - Ok(format!("install force={}", self.force)) - }) +impl RunAsync for Install { + type Output = Result; + async fn run_async(self) -> Self::Output { + yield_once().await; + Ok(format!("install force={}", self.force)) } } -impl Run for Sponsors { - type Output = Task<'static, Result>; - fn run(self) -> Self::Output { - Box::pin(async move { - yield_once().await; - Ok("sponsors".to_string()) - }) +impl RunAsync for Sponsors { + type Output = Result; + async fn run_async(self) -> Self::Output { + yield_once().await; + Ok("sponsors".to_string()) } } -/// What a CLI hands its commands. A borrowed context is what ties the future's lifetime, which -/// is why `Task` takes one. +impl RunAsync for ConfigLs { + type Output = Result; + async fn run_async(self) -> Self::Output { + yield_once().await; + Ok(format!("config ls no_header={}", self.no_header)) + } +} + +/// What a CLI hands its commands. Borrowed, which is the ordinary case: the future borrows it +/// for as long as it runs. struct App { jobs: usize, } -impl<'a> RunWith<&'a App> for Install { - type Output = Task<'a, Result>; - fn run_with(self, app: &'a App) -> Self::Output { - Box::pin(async move { - yield_once().await; - Ok(format!("install force={} jobs={}", self.force, app.jobs)) - }) +impl RunAsyncWith<&App> for Install { + type Output = Result; + async fn run_async_with(self, app: &App) -> Self::Output { + yield_once().await; + Ok(format!("install force={} jobs={}", self.force, app.jobs)) } } -impl<'a> RunWith<&'a App> for Sponsors { - type Output = Task<'a, Result>; - fn run_with(self, _: &'a App) -> Self::Output { - Box::pin(async move { - yield_once().await; - Ok("sponsors".to_string()) - }) +impl RunAsyncWith<&App> for Sponsors { + type Output = Result; + async fn run_async_with(self, _: &App) -> Self::Output { + yield_once().await; + Ok("sponsors".to_string()) + } +} + +impl RunAsyncWith<&App> for ConfigLs { + type Output = Result; + async fn run_async_with(self, app: &App) -> Self::Output { + yield_once().await; + Ok(format!("config ls jobs={}", app.jobs)) } } @@ -97,24 +129,159 @@ fn parse(words: &[&str]) -> Ex { } #[test] -fn an_async_command_dispatches_to_a_future() { +fn the_selected_command_is_the_one_awaited() { let ex = parse(&["install", "--force"]); assert_eq!( - block_on(ex.command.run()), + block_on(ex.command.run_async()), Ok("install force=true".to_string()) ); + let ex = parse(&["sponsors"]); + assert_eq!(block_on(ex.command.run_async()), Ok("sponsors".to_string())); +} + +/// Both levels generated: the enum's dispatch reaches a struct whose own dispatch awaits the +/// next enum. +#[test] +fn a_nested_async_command_dispatches_through_its_group() { + let ex = parse(&["config", "ls", "--no-header"]); + assert_eq!( + block_on(ex.command.run_async()), + Ok("config ls no_header=true".to_string()) + ); } #[test] -fn an_async_command_dispatches_with_a_borrowed_context() { +fn a_context_reaches_an_async_command() { let app = App { jobs: 4 }; let ex = parse(&["install"]); assert_eq!( - block_on(ex.command.run_with(&app)), + block_on(ex.command.run_async_with(&app)), Ok("install force=false jobs=4".to_string()) ); - let ex = parse(&["sponsors"]); - assert_eq!(block_on(ex.command.run_with(&app)), Ok("sponsors".into())); + let ex = parse(&["config", "ls"]); + assert_eq!( + block_on(ex.command.run_async_with(&app)), + Ok("config ls jobs=4".to_string()) + ); +} + +/// `Send` is what a spawning CLI needs and what a single-threaded one cannot always give, so +/// the traits ask for neither. It is inferred where it holds — asserted here on the future the +/// generated dispatch returns — and a command whose future is not `Send` still dispatches. +#[test] +fn send_is_inferred_and_never_required() { + fn assert_send(_: &T) {} + + let app = App { jobs: 1 }; + let sendable = parse(&["sponsors"]).command.run_async_with(&app); + assert_send(&sendable); + assert_eq!(block_on(sendable), Ok("sponsors".to_string())); +} + +/// The command that proves the point above: its future holds an `Rc` across an await, so it is +/// not `Send`, and it is dispatched by the same generated code. +#[derive(Args)] +struct Local; + +#[derive(Subcommands)] +#[usage(run_async)] +enum LocalCommand { + Local(Local), +} + +/// A tool with one local command +#[derive(Cli)] +#[usage(bin = "local-ex")] +struct LocalEx { + #[usage(subcommand)] + command: LocalCommand, +} + +impl RunAsync for Local { + type Output = String; + async fn run_async(self) -> Self::Output { + let counter = Rc::new(1); + yield_once().await; + format!("local {}", *counter) + } +} + +#[test] +fn a_future_that_is_not_send_still_dispatches() { + let argv = [OsStr::new("local")]; + let ex = LocalEx::parse_from(&argv).expect("valid command line"); + assert_eq!(block_on(ex.command.run_async()), "local 1"); +} + +/// The other way to be async: a boxed future as the sync traits' `Output`. Nothing in either +/// trait says `Send`, so a CLI that wants it puts it in the type it names — as this one does, +/// since spawning is the usual reason to reach for the box at all. +type Task<'a, T> = Pin + Send + 'a>>; + +#[derive(Args)] +struct Boxed { + #[usage(long)] + force: bool, +} + +#[derive(Subcommands)] +#[usage(run, run_with)] +enum BoxedCommand { + Boxed(Boxed), +} + +/// A tool whose commands return futures +#[derive(Cli)] +#[usage(bin = "boxed-ex")] +struct BoxedEx { + #[usage(subcommand)] + command: BoxedCommand, +} + +impl Run for Boxed { + type Output = Task<'static, Result>; + fn run(self) -> Self::Output { + Box::pin(async move { + yield_once().await; + Ok(format!("boxed force={}", self.force)) + }) + } +} + +impl<'a> RunWith<&'a App> for Boxed { + type Output = Task<'a, Result>; + fn run_with(self, app: &'a App) -> Self::Output { + Box::pin(async move { + yield_once().await; + Ok(format!("boxed jobs={}", app.jobs)) + }) + } +} + +#[test] +fn a_boxed_future_is_an_output_like_any_other() { + let argv = [OsStr::new("boxed"), OsStr::new("--force")]; + let ex = BoxedEx::parse_from(&argv).expect("valid command line"); + assert_eq!( + block_on(ex.command.run()), + Ok("boxed force=true".to_string()) + ); + + let app = App { jobs: 8 }; + let argv = [OsStr::new("boxed")]; + let ex = BoxedEx::parse_from(&argv).expect("valid command line"); + assert_eq!( + block_on(ex.command.run_with(&app)), + Ok("boxed jobs=8".to_string()) + ); +} + +/// Dispatch is still invisible to the spec, async or not. +#[test] +fn async_dispatch_says_nothing_in_the_spec() { + let kdl = Ex::to_kdl(); + assert!(kdl.contains("cmd install"), "{kdl}"); + assert!(!kdl.contains("run"), "{kdl}"); } /// The smallest executor that proves these are real futures: no runtime dependency in the @@ -126,8 +293,7 @@ fn block_on(future: F) -> F::Output { let mut future = Box::pin(future); // Spinning rather than parking, which is the whole executor this needs: nothing here waits // on anything outside the test. - let waker = Waker::noop(); - let mut cx = Context::from_waker(waker); + let mut cx = Context::from_waker(Waker::noop()); loop { if let Poll::Ready(value) = future.as_mut().poll(&mut cx) { return value; diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 61cb5a286..f7a230ea1 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -17,8 +17,8 @@ use quote::{format_ident, quote}; use crate::crate_name::{crate_name, FoundCrate}; use crate::model::{ - rendered_path, to_kebab, Cli, ConditionalDefault, DoubleDash, ExampleDecl, Field, Kind, Shape, - Subcommands, ValueEnum, ViewDecl, + rendered_path, to_kebab, Cli, ConditionalDefault, Dispatch, DoubleDash, ExampleDecl, Field, + Kind, Shape, Subcommands, ValueEnum, ViewDecl, }; /// Construct the user's command type after its generated partial has been checked. @@ -5171,6 +5171,132 @@ fn meta_controls_field_presence(meta: &syn::Meta) -> bool { .is_ok_and(|nested| nested.iter().skip(1).any(meta_controls_field_presence)) } +/// One of the four dispatch traits, as the generated code needs to speak about it. +/// +/// They differ in two bits — whether the command is handed a context, and whether it is +/// awaited — and in nothing else, so they are described here rather than written out four +/// times over. +struct DispatchTrait { + /// Whether the enum or struct asked for this one. + wanted: bool, + /// `Run`, `RunWith`, `RunAsync`, `RunAsyncWith`. + name: &'static str, + /// `run`, `run_with`, `run_async`, `run_async_with`. + method: &'static str, + /// Whether the trait takes a context, which is what makes the generated implementation + /// generic — and, being generic, inert until something calls it. + ctx: bool, + /// Whether the implementation is an `async fn` whose arms are awaited. + is_async: bool, +} + +impl DispatchTrait { + /// The four, in the order a CLI meets them. + fn all(dispatch: Dispatch) -> [Self; 4] { + [ + DispatchTrait { + wanted: dispatch.run, + name: "Run", + method: "run", + ctx: false, + is_async: false, + }, + DispatchTrait { + wanted: dispatch.run_with, + name: "RunWith", + method: "run_with", + ctx: true, + is_async: false, + }, + DispatchTrait { + wanted: dispatch.run_async, + name: "RunAsync", + method: "run_async", + ctx: false, + is_async: true, + }, + DispatchTrait { + wanted: dispatch.run_async_with, + name: "RunAsyncWith", + method: "run_async_with", + ctx: true, + is_async: true, + }, + ] + } + + /// `usage_argv::Run`, or `usage_argv::RunWith<__UsageCtx>` for one that takes a context. + fn path(&self) -> TokenStream { + let name = format_ident!("{}", self.name); + if self.ctx { + quote!(usage_argv::#name<__UsageCtx>) + } else { + quote!(usage_argv::#name) + } + } + + /// The same, of another type: ``. + fn as_of(&self, ty: &TokenStream) -> TokenStream { + let path = self.path(); + quote!(<#ty as #path>) + } + + /// The trait with the output it has to produce, which is one variant's whole bound. + /// + /// The binding goes inside the same angle brackets as the context, since + /// `RunWith<__UsageCtx>` is two generic lists rather than one bound. + fn path_with_output(&self, output: &TokenStream) -> TokenStream { + let name = format_ident!("{}", self.name); + if self.ctx { + quote!(usage_argv::#name<__UsageCtx, Output = #output>) + } else { + quote!(usage_argv::#name) + } + } + + /// `impl`, or `impl<__UsageCtx>` for one that takes a context. + fn impl_generics(&self) -> TokenStream { + if self.ctx { + quote!(impl<__UsageCtx>) + } else { + quote!(impl) + } + } + + /// The method's declaration, up to its body. + /// + /// The async traits declare `-> impl Future` and an implementation + /// answers with an `async fn`, which is the same signature and imposes no `Send` bound. + fn signature(&self) -> TokenStream { + let method = format_ident!("{}", self.method); + let asyncness = self.is_async.then(|| quote!(async)); + if self.ctx { + quote!(#asyncness fn #method(self, __usage_ctx: __UsageCtx) -> Self::Output) + } else { + quote!(#asyncness fn #method(self) -> Self::Output) + } + } + + /// A call into this trait for one command's value. + /// + /// The context's type is turbofished rather than written as `RunWith<__UsageCtx>::run_with`, + /// which is a chain of comparisons in expression position rather than a path. + fn call(&self, value: TokenStream) -> TokenStream { + let name = format_ident!("{}", self.name); + let method = format_ident!("{}", self.method); + let call = if self.ctx { + quote!(usage_argv::#name::<__UsageCtx>::#method(#value, __usage_ctx)) + } else { + quote!(usage_argv::#name::#method(#value)) + }; + if self.is_async { + quote!(#call.await) + } else { + call + } + } +} + /// The dispatch a `#[usage(run)]` enum gets: the `match` every CLI writes by hand. /// /// One arm per variant, handing the command's own struct to the trait that carries it out. @@ -5184,99 +5310,67 @@ fn meta_controls_field_presence(meta: &syn::Meta) -> bool { /// `where` clauses rather than checked here, which is also what lets the enum be declared /// before the implementations it dispatches to exist. fn emit_subcommands_dispatch(subs: &Subcommands, runtime: &TokenStream) -> TokenStream { - if !subs.run && !subs.run_with { + if !subs.dispatch.any() { return TokenStream::new(); } let ident = &subs.ident; // Checked in `Subcommands::from_input`: a dispatched enum has variants, and each holds a // named struct rather than nothing or its fields inline. - let first = &subs.variants[0].ty; - - // `*inner` is the one place a `Box` shows: the box is how the variant holds the struct, - // and the struct is what implements the trait. - let arm = |v: &crate::model::Variant| { - let variant = &v.ident; - let inner = if v.boxed { - quote!(*__usage_inner) - } else { - quote!(__usage_inner) - }; - (quote!(#ident::#variant(__usage_inner)), inner) - }; - - let run = subs.run.then(|| { - let bounds = subs.variants.iter().enumerate().map(|(i, v)| { - let ty = &v.ty; - if i == 0 { - quote!(#ty: usage_argv::Run) - } else { - quote!(#ty: usage_argv::Run::Output>) - } - }); - let arms = subs.variants.iter().map(|v| { - let (pattern, inner) = arm(v); - quote!(#pattern => usage_argv::Run::run(#inner),) - }); - quote! { - impl usage_argv::Run for #ident - where - #(#bounds,)* - { - type Output = <#first as usage_argv::Run>::Output; + let first_ty = &subs.variants[0].ty; + let first = quote!(#first_ty); - fn run(self) -> Self::Output { - match self { - #(#arms)* - } - } - } - } - }); - - // Generic over the context, so one generated implementation serves `&Config`, `&mut App` - // and an owned handle alike — the CLI decides what its commands are handed, and this - // crate never has to know. - let run_with = subs.run_with.then(|| { - let bounds = subs.variants.iter().enumerate().map(|(i, v)| { - let ty = &v.ty; - if i == 0 { - quote!(#ty: usage_argv::RunWith<__UsageCtx>) - } else { - quote! { - #ty: usage_argv::RunWith< - __UsageCtx, - Output = <#first as usage_argv::RunWith<__UsageCtx>>::Output, - > + let impls = DispatchTrait::all(subs.dispatch) + .into_iter() + .filter(|kind| kind.wanted) + .map(|kind| { + let generics = kind.impl_generics(); + let path = kind.path(); + let signature = kind.signature(); + let output = kind.as_of(&first); + let bounds = subs.variants.iter().enumerate().map(|(i, v)| { + let ty = &v.ty; + if i == 0 { + let path = kind.path(); + quote!(#ty: #path) + } else { + let bound = kind.path_with_output("e!(#output::Output)); + quote!(#ty: #bound) } - } - }); - let arms = subs.variants.iter().map(|v| { - let (pattern, inner) = arm(v); - quote!(#pattern => usage_argv::RunWith::run_with(#inner, __usage_ctx),) - }); - quote! { - impl<__UsageCtx> usage_argv::RunWith<__UsageCtx> for #ident - where - #(#bounds,)* - { - type Output = <#first as usage_argv::RunWith<__UsageCtx>>::Output; + }); + // `*inner` is the one place a `Box` shows: the box is how the variant holds the + // struct, and the struct is what implements the trait. + let arms = subs.variants.iter().map(|v| { + let variant = &v.ident; + let inner = if v.boxed { + quote!(*__usage_inner) + } else { + quote!(__usage_inner) + }; + let call = kind.call(inner); + quote!(#ident::#variant(__usage_inner) => #call,) + }); + quote! { + #generics #path for #ident + where + #(#bounds,)* + { + type Output = #output::Output; - fn run_with(self, __usage_ctx: __UsageCtx) -> Self::Output { - match self { - #(#arms)* + #signature { + match self { + #(#arms)* + } } } } - } - }); + }); quote! { #[doc(hidden)] const _: () = { use #runtime as usage_argv; - #run - #run_with + #(#impls)* }; } } @@ -5284,17 +5378,17 @@ fn emit_subcommands_dispatch(subs: &Subcommands, runtime: &TokenStream) -> Token /// The dispatch a `#[usage(run)]` struct gets: a forward to its subcommands. /// /// The `config`-style group that has no work of its own — declared as a struct holding -/// nothing but its subcommand field, which is what -/// [`Cli::check`](crate::model::Cli::check) holds it to, since forwarding is all this can do -/// and a struct with arguments of its own has to decide what becomes of them. +/// nothing but its subcommand field, which is what [`Cli::check`](crate::model::Cli::check) +/// holds it to, since forwarding is all this can do and a struct with arguments of its own has +/// to decide what becomes of them. fn emit_command_dispatch(cli: &Cli, runtime: &TokenStream) -> TokenStream { - if !cli.run && !cli.run_with { + if !cli.dispatch.any() { return TokenStream::new(); } let ident = &cli.ident; // Checked in `Cli::check`: a struct asking for a dispatch holds exactly one field, and it // is a non-optional subcommand. - let Some((field, ty)) = cli.fields.iter().find_map(|field| match &field.kind { + let Some((field, held_ty)) = cli.fields.iter().find_map(|field| match &field.kind { Kind::Subcommand { ty, optional: false, @@ -5303,43 +5397,37 @@ fn emit_command_dispatch(cli: &Cli, runtime: &TokenStream) -> TokenStream { }) else { return TokenStream::new(); }; + let held_ty = quote!(#held_ty); - let run = cli.run.then(|| { - quote! { - impl usage_argv::Run for #ident - where - #ty: usage_argv::Run, - { - type Output = <#ty as usage_argv::Run>::Output; - - fn run(self) -> Self::Output { - usage_argv::Run::run(self.#field) - } - } - } - }); - let run_with = cli.run_with.then(|| { - quote! { - impl<__UsageCtx> usage_argv::RunWith<__UsageCtx> for #ident - where - #ty: usage_argv::RunWith<__UsageCtx>, - { - type Output = <#ty as usage_argv::RunWith<__UsageCtx>>::Output; + let impls = DispatchTrait::all(cli.dispatch) + .into_iter() + .filter(|kind| kind.wanted) + .map(|kind| { + let generics = kind.impl_generics(); + let path = kind.path(); + let signature = kind.signature(); + let held = kind.as_of(&held_ty); + let call = kind.call(quote!(self.#field)); + quote! { + #generics #path for #ident + where + #held_ty: #path, + { + type Output = #held::Output; - fn run_with(self, __usage_ctx: __UsageCtx) -> Self::Output { - usage_argv::RunWith::run_with(self.#field, __usage_ctx) + #signature { + #call + } } } - } - }); + }); quote! { #[doc(hidden)] const _: () = { use #runtime as usage_argv; - #run - #run_with + #(#impls)* }; } } diff --git a/derive/src/lib.rs b/derive/src/lib.rs index 65472d099..aacff00e2 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -139,9 +139,13 @@ //! } //! ``` //! -//! `#[usage(run_with)]` is the same for [`RunWith`](usage_argv::RunWith), whose implementations -//! are handed a context — a config, an output handle, a client — and whose generated dispatch is -//! generic over what that is. An enum may say both. +//! Four attributes, one per trait, differing only in whether a command is handed a context and +//! whether it is awaited: `run` for [`Run`](usage_argv::Run), `run_with` for +//! [`RunWith`](usage_argv::RunWith), `run_async` for [`RunAsync`](usage_argv::RunAsync), and +//! `run_async_with` for [`RunAsyncWith`](usage_argv::RunAsyncWith). A context is whatever the CLI +//! has to give, and the generated dispatch is generic over it. The async pair's implementations +//! are written `async fn` and the generated dispatch awaits the selected command, with no `Send` +//! bound imposed either way. An enum may say several. //! //! The output type is the first variant's, and each of the others is required to agree, so a //! command returning something else is reported on the command. A `#[usage(run)]` *struct* gets @@ -259,8 +263,8 @@ //! rather than on the root, which does nothing itself — `completion`, which adds the hidden command a generated shell //! script calls, and needs usage-argv's `complete` feature enabled where it is depended on — //! `settings`, for a CLI whose bound flags all live in a flattened group (see [Settings]) — -//! and `run` / `run_with`, which write the forward from a container command to its subcommands -//! (see [Dispatch](#dispatch)). +//! and `run`, `run_with`, `run_async` and `run_async_with`, which write the forward from a +//! container command to its subcommands (see [Dispatch](#dispatch)). //! //! [Settings]: #settings-and-the-flags-that-set-them //! @@ -450,8 +454,9 @@ pub fn derive_args(input: TokenStream) -> TokenStream { /// Each variant may wrap a struct deriving [`Args`] or declare its fields inline, /// clap-style. A field holding this enum is marked `#[usage(subcommand)]`. /// -/// `#[usage(run)]` or `#[usage(run_with)]` on the enum also writes the `match` that hands the -/// selected command to its implementation; see the [crate docs](crate#dispatch). +/// `#[usage(run)]`, `#[usage(run_with)]`, `#[usage(run_async)]` or `#[usage(run_async_with)]` on +/// the enum also writes the `match` that hands the selected command to its implementation; see +/// the [crate docs](crate#dispatch). #[proc_macro_derive(Subcommands, attributes(usage, command, arg))] pub fn derive_subcommands(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); diff --git a/derive/src/model.rs b/derive/src/model.rs index e17e7f164..e529e9f9b 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -62,15 +62,13 @@ pub struct Cli { /// CLI with a subcommand depend on `usage-config`. A root that binds a setting of its own has /// already said it, and does not need this. pub settings: bool, - /// Whether the derive writes this command's dispatch, for a container struct. + /// Which dispatches the derive writes for this command, for a container struct. /// /// `#[usage(run)]` on a struct whose only field holds its subcommands generates the - /// `Run` implementation that forwards to the selected one — the `config`-style group that - /// does nothing itself. A struct that declares arguments of its own is refused, because + /// implementation that forwards to the selected one — the `config`-style group that does + /// nothing itself. A struct that declares arguments of its own is refused, because /// forwarding would drop them; see [`check`](Self::check). - pub run: bool, - /// The same, for [`RunWith`](usage_argv::RunWith): a dispatch that carries a context. - pub run_with: bool, + pub dispatch: Dispatch, /// The oldest `usage` that can read the emitted spec, when the CLI says. /// /// Declared rather than computed. Working it out would mean a table from every property to @@ -667,8 +665,7 @@ impl Cli { runtime_bin: None, completion: false, settings: false, - run: false, - run_with: false, + dispatch: Dispatch::default(), min_usage_version: None, usage: None, effect: None, @@ -794,8 +791,10 @@ impl Cli { // decorative after it. "completion" => cli.completion = flag_value(&meta)?, "settings" => cli.settings = flag_value(&meta)?, - "run" => cli.run = flag_value(&meta)?, - "run_with" => cli.run_with = flag_value(&meta)?, + "run" => cli.dispatch.run = flag_value(&meta)?, + "run_with" => cli.dispatch.run_with = flag_value(&meta)?, + "run_async" => cli.dispatch.run_async = flag_value(&meta)?, + "run_async_with" => cli.dispatch.run_async_with = flag_value(&meta)?, "verbatim_doc_comment" => verbatim_doc_comment = flag_value(&meta)?, "effect" => cli.effect = Some(effect_value(&meta)?), "alias" | "aliases" if clap_attr => { @@ -985,7 +984,7 @@ impl Cli { `name`, `name_spec`, `bin`, `bin_spec`, `version`, `version_spec`, `long_version`, `long_version_spec`, `author`, `license`, `repository`, `source_code_link_template`, `usage`, `alias`, `alias_hidden`, `visible_alias`, `hide`, `deprecated`, `deprecated_warn_at`, `deprecated_remove_at`, `verbatim_doc_comment`, `unknown_flags`, \ `default_subcommand`, `multicall`, `no_binary_name`, `arg_required_else_help`, `disable_help_flag`, `disable_help_subcommand`, `disable_version_flag`, `dont_delimit_trailing_values`, `args_override_self`, `subcommand_negates_reqs`, `args_conflicts_with_subcommands`, `subcommand_precedence_over_arg`, `allow_missing_positional`, \ `next_help_heading`, `subcommand_help_heading`, `next_line_help`, `flatten_help`, `term_width`, `max_term_width`, \ - `subcommand_value_name`, `restart_token`, `mount`, `example`, `run`, `run_with` and \ + `subcommand_value_name`, `restart_token`, `mount`, `example`, `run`, `run_with`, `run_async`, `run_async_with` and \ `group` and `view` here, and the description comes from the doc \ comment" ), @@ -1502,8 +1501,11 @@ impl Cli { // `config`-style group that is nothing but its subcommand field — and every other // shape says so where it is written, next to the alternative, which is to implement // the trait by hand. - if self.run || self.run_with { - let attr = self.attr_span.unwrap_or_else(Span::call_site); + if self.dispatch.any() { + let span = self.attr_span.unwrap_or_else(Span::call_site); + // Quoted back rather than hardcoded, so an author who wrote `run_async_with` is not + // told about `run`. + let (attr, dispatch_trait) = self.dispatch.named(); match self.fields.iter().find(|field| { !matches!( field.kind, @@ -1516,19 +1518,24 @@ impl Cli { Some(field) => { return Err(syn::Error::new( field.span, - "`run` on a struct forwards to its subcommands and can do nothing \ - else, so the struct holds one field: its subcommands, not in an \ - `Option`. A command that has arguments of its own — or that decides \ - what no subcommand means — implements `usage::Run` itself, and \ - `self..run()` is the forward this would have written", + format!( + "`{attr}` on a struct forwards to its subcommands and can do \ + nothing else, so the struct holds one field: its subcommands, not \ + in an `Option`. A command that has arguments of its own — or that \ + decides what no subcommand means — implements `{dispatch_trait}` \ + itself, and forwarding to the field is what this would have written" + ), )); } None if self.fields.is_empty() => { return Err(syn::Error::new( - attr, - "`run` on a struct forwards to its subcommands, and this struct has \ - none. A command that does the work itself implements `usage::Run` \ - for it: that is the point the generated dispatch calls", + span, + format!( + "`{attr}` on a struct forwards to its subcommands, and this struct \ + has none. A command that does the work itself implements \ + `{dispatch_trait}` for it: that is the point the generated dispatch \ + calls" + ), )); } None => {} @@ -4540,9 +4547,44 @@ pub struct Subcommands { /// dispatches to a trait of its own — has to be able to keep writing the match. Asking /// for it is also what makes a variant that cannot be dispatched an error where it is /// declared rather than a missing implementation somewhere else. + pub dispatch: Dispatch, +} + +/// Which of the four dispatches a command or a command set asked the derive to write. +/// +/// Four rather than one with a switch, because each names a different trait and the pair a CLI +/// wants is not something the derive can infer: a context is not implied by being async, and an +/// enum part-way through adopting either needs both spellings at once. +#[derive(Default, Clone, Copy)] +pub struct Dispatch { pub run: bool, - /// The same, for [`RunWith`](usage_argv::RunWith): a dispatch that carries a context. pub run_with: bool, + pub run_async: bool, + pub run_async_with: bool, +} + +impl Dispatch { + /// Whether any dispatch was asked for. + pub fn any(self) -> bool { + self.run || self.run_with || self.run_async || self.run_async_with + } + + /// The attribute the author wrote and the trait it generates, for a diagnostic that quotes + /// them back rather than naming `run` at someone who wrote `run_async_with`. + /// + /// The first one set, which is the whole answer where one is, and the one the rest of the + /// message applies equally to where several are. + pub fn named(self) -> (&'static str, &'static str) { + if self.run { + ("run", "usage::Run") + } else if self.run_with { + ("run_with", "usage::RunWith") + } else if self.run_async { + ("run_async", "usage::RunAsync") + } else { + ("run_async_with", "usage::RunAsyncWith") + } + } } /// The name of the struct a bare variant implies. @@ -4654,22 +4696,23 @@ impl Subcommands { } let mut rename_all = None; - let mut run = false; - let mut run_with = false; + let mut dispatch = Dispatch::default(); for attr in attrs(&input.attrs) { for meta in nested(attr)? { let path = meta.path().clone(); match ident_of(&path).as_str() { "rename_all" => rename_all = Some(CasingStyle::parse(&meta)?), - "run" => run = flag_value(&meta)?, - "run_with" => run_with = flag_value(&meta)?, + "run" => dispatch.run = flag_value(&meta)?, + "run_with" => dispatch.run_with = flag_value(&meta)?, + "run_async" => dispatch.run_async = flag_value(&meta)?, + "run_async_with" => dispatch.run_async_with = flag_value(&meta)?, other => { return Err(syn::Error::new_spanned( path, format!( "unknown option `{other}` on a subcommand enum; \ - usage::Subcommands takes `rename_all`, `run` and \ - `run_with` here" + usage::Subcommands takes `rename_all`, `run`, \ + `run_with`, `run_async` and `run_async_with` here" ), )); } @@ -4727,7 +4770,10 @@ impl Subcommands { } } - if run || run_with { + if dispatch.any() { + // Quoted back rather than hardcoded, so an author who wrote `run_async_with` is not + // told about `run`. + let (attr, dispatch_trait) = dispatch.named(); // A dispatch is a `match` over the variants, so every variant has to name // something that can run. An `external_subcommand` variant names argv — the words // nothing here claimed — and there is no type to implement the trait for, so the @@ -4736,16 +4782,18 @@ impl Subcommands { if let Some(external) = variants.iter().find(|v| v.external) { return Err(syn::Error::new_spanned( &external.ident, - "`run` generates a match over every variant, and this one holds the argv \ - of a command that is not declared here — there is nothing to implement \ - `usage::Run` for. Write the match by hand, or move the catch-all out of \ - the dispatched enum", + format!( + "`{attr}` generates a match over every variant, and this one holds the \ + argv of a command that is not declared here — there is nothing to \ + implement `{dispatch_trait}` for. Write the match by hand, or move the \ + catch-all out of the dispatched enum" + ), )); } if variants.is_empty() { return Err(syn::Error::new_spanned( &input.ident, - "`run` generates a match over the variants, and this enum has none", + format!("`{attr}` generates a match over the variants, and this enum has none"), )); } // A dispatched arm hands the command's own value to the trait, so the variant has @@ -4759,10 +4807,13 @@ impl Subcommands { { return Err(syn::Error::new_spanned( &variant.ident, - "a dispatched command holds the struct its work is implemented on, and \ - this variant holds nothing this crate can name: write \ - `#[derive(usage::Args)] struct Sponsors;` and hold it — \ - `Sponsors(Sponsors)` — or leave `run` off and write the match by hand", + format!( + "a dispatched command holds the struct its work is implemented on, and \ + this variant holds nothing this crate can name: write \ + `#[derive(usage::Args)] struct Sponsors;` and hold it — \ + `Sponsors(Sponsors)` — or leave `{attr}` off and write the match by \ + hand" + ), )); } } @@ -4770,8 +4821,7 @@ impl Subcommands { Ok(Subcommands { ident: input.ident.clone(), variants, - run, - run_with, + dispatch, }) } } @@ -7797,8 +7847,8 @@ mod tests { "#) .expect("a struct holding only its subcommands can be dispatched"); - assert!(cli.run); - assert!(cli.run_with); + assert!(cli.dispatch.run); + assert!(cli.dispatch.run_with); } /// Forwarding is all a generated `run` on a struct can do, so a struct with arguments of @@ -7865,11 +7915,63 @@ mod tests { ) .expect("`run` sits beside the other enum options"); - assert!(subs.run); - assert!(!subs.run_with); + assert!(subs.dispatch.run); + assert!(!subs.dispatch.run_with); assert_eq!(subs.variants[0].name, "install"); } + #[test] + fn an_enum_may_ask_for_every_dispatch() { + let subs = subcommands( + r#" + #[usage(run, run_with, run_async, run_async_with)] + enum Command { + Install(Install), + } + "#, + ) + .expect("the four dispatches are independent"); + + let dispatch = subs.dispatch; + assert!(dispatch.run && dispatch.run_with && dispatch.run_async && dispatch.run_async_with); + } + + /// A diagnostic quotes back the attribute the author wrote. Naming `run` at someone who + /// wrote `run_async_with` sends them looking for an attribute they do not have. + #[test] + fn a_dispatch_refusal_names_the_attribute_that_was_written() { + let err = enum_rejection( + r#" + #[usage(run_async_with)] + enum Command { + Sponsors, + } + "#, + ); + assert!(err.contains("`run_async_with`"), "unhelpful: {err}"); + assert!(!err.contains("`run`"), "names the wrong attribute: {err}"); + + let struct_err = rejection( + r#" + #[usage(run_async)] + struct Ex { + #[usage(long)] + verbose: bool, + #[usage(subcommand)] + command: Command, + } + "#, + ); + assert!( + struct_err.contains("`run_async`"), + "unhelpful: {struct_err}" + ); + assert!( + struct_err.contains("`usage::RunAsync`"), + "names the wrong trait: {struct_err}" + ); + } + /// The catch-all holds the argv of a command that is not declared here, so there is no type /// to implement the trait for and no exhaustive match to generate. #[test] diff --git a/docs/rust/dispatch.md b/docs/rust/dispatch.md index 1ea0b6ba2..1465fd4d9 100644 --- a/docs/rust/dispatch.md +++ b/docs/rust/dispatch.md @@ -11,7 +11,16 @@ that command exists for. At mise's size that is 210 arms of pure routing, and no that an arm calls the right thing, because every arm has the same shape. `#[usage(run)]` generates it. A command implements `Run`, the enum says it dispatches, and the -match comes from the same declaration the parser and the spec come from: +match comes from the same declaration the parser and the spec come from. Four traits, differing +only in whether a command is handed a context and whether it is awaited: + +| | no context | a context | +| --------- | ---------------------------------- | ------------------------------------------------ | +| **sync** | `Run` — `#[usage(run)]` | `RunWith` — `#[usage(run_with)]` | +| **async** | `RunAsync` — `#[usage(run_async)]` | `RunAsyncWith` — `#[usage(run_async_with)]` | + +One type may implement several, and one enum may dispatch several, which is what a CLI part-way +through adopting a context — or a runtime — needs. The sync, context-free case: ```rust use usage::{Args, Cli, Run, Subcommands}; @@ -87,9 +96,14 @@ impl RunWith<&mut App> for Install { app.install(&self.tools, self.force) } } -``` -```rust +impl RunWith<&mut App> for Sponsors { + type Output = miette::Result<()>; + fn run_with(self, app: &mut App) -> Self::Output { + app.print_sponsors() + } +} + fn main() -> miette::Result<()> { let cli = Cli::parse(); let mut app = App::new(cli.verbose)?; @@ -97,6 +111,9 @@ fn main() -> miette::Result<()> { } ``` +Every variant, since the dispatch is a `match`: a command left unimplemented is a compile error +naming it. + The generated implementation is generic over the context, so `RunWith<&Config>`, `RunWith<&mut App>` and `RunWith>` are all ordinary implementations rather than shapes this crate has to anticipate. An enum may say both `run` and `run_with`, which is what a CLI @@ -107,35 +124,86 @@ wrong side: a hundred commands that need nothing shared would each carry `fn run ## Async commands -`Output` is whatever the command produces, and a future is a value like any other — so an async -command's dispatch is the same generated match, returning a future for `main` to await: +`RunAsync` and `RunAsyncWith` are the async pair, under `#[usage(run_async)]` and +`#[usage(run_async_with)]`. An implementation writes `async fn`, and the generated dispatch is an +`async fn` that awaits the selected command: ```rust -type Task = Pin + Send>>; +use usage::{RunAsync, Subcommands}; -impl Run for Install { - type Output = Task>; - fn run(self) -> Self::Output { - Box::pin(async move { install(&self.tools, self.force).await }) +#[derive(Subcommands)] +#[usage(run_async)] +enum Commands { + Install(Install), + Sponsors(Sponsors), +} + +impl RunAsync for Install { + type Output = miette::Result<()>; + async fn run_async(self) -> Self::Output { + install(&self.tools, self.force).await + } +} + +impl RunAsync for Sponsors { + type Output = miette::Result<()>; + async fn run_async(self) -> Self::Output { + fetch_sponsors().await } } #[tokio::main] async fn main() -> miette::Result<()> { - Cli::parse().command.run().await + Cli::parse().command.run_async().await } ``` -The box is because an `async` block's type cannot be named and an associated type has to be. One -allocation, on a path that is about to do I/O. A borrowed context works the same way, with the -future's lifetime tied to it: `impl<'a> RunWith<&'a App> for Install { type Output = Task<'a, …> }`. +A context works the same way, and the future borrows it for as long as it runs: + +```rust +impl RunAsyncWith<&App> for Install { + type Output = miette::Result<()>; + async fn run_async_with(self, app: &App) -> Self::Output { + app.install(&self.tools, self.force).await + } +} +``` + +### `Send` + +Neither async trait imposes it. They declare `-> impl Future` rather than +`async fn`, which is the same signature to implement against and leaves `Send` to the commands +themselves: + +- A CLI that spawns gets `Send` **by inference** — it leaks out of the concrete commands the + dispatch reaches, so `tokio::spawn(cli.command.run_async())` compiles when every command's + future is `Send`. +- A CLI on a single-threaded runtime keeps futures that are not, such as one holding an `Rc` + across an await. + +What no design can add is the third thing: _demanding_ `Send` in generic code, which +`-> impl Future + Send` in the trait would buy at the cost of the second bullet. The traits take +the side that refuses nothing. + +### The other way + +The sync traits can carry a future too, since `Output` is whatever the command produces: + +```rust +type Task<'a, T> = Pin + Send + 'a>>; + +impl Run for Install { + type Output = Task<'static, miette::Result<()>>; + fn run(self) -> Self::Output { + Box::pin(async move { install(&self.tools, self.force).await }) + } +} +``` -The traits are deliberately not `async` themselves. An `async fn` in a public trait cannot say -`+ Send` about the future it returns, so callers that need to spawn it have no way to require -one — and desugaring to `-> impl Future + Send` instead would commit every command in every CLI -to a `Send` future, which rules out the single-threaded runtimes some of them use. A CLI that -wants `async fn run(self)` without the box can say so; the shape is a third trait beside these -two, not a change to them. +That costs an allocation and names a type — the box is needed because an `async` block's type +cannot be named and an associated type has to be — and it is where the `+ Send` goes if the CLI +wants one. Worth it only when the future has to be a value: stored, selected over, or returned +across an API boundary. Otherwise use `RunAsync`. A CLI whose commands are mostly synchronous can also keep `Output = Result<()>` and hold a runtime handle in its context, which is what `RunWith` is for. @@ -181,7 +249,8 @@ type you can implement the trait for: Both are compile errors on the variant rather than a missing implementation somewhere else, which is the reason dispatch is opt-in rather than always generated. The other reason is that the generated implementation is the only one the enum can have: a CLI that wants to do -something of its own between the parse and the dispatch leaves `run` off. +something of its own between the parse and the dispatch leaves the attribute off and writes the +match. ## What it says in the spec diff --git a/docs/rust/index.md b/docs/rust/index.md index 1e39b9826..bccefe554 100644 --- a/docs/rust/index.md +++ b/docs/rust/index.md @@ -105,17 +105,39 @@ failure to stderr and exits `2` — clap's exit status, so scripts that check fo `parse_from` gives you the same machinery without the process control; see [Help, version, and errors](/rust/help) for handling its `Err` variants. -What runs afterwards can be generated too. A command implements `Run` (or `RunWith`, when -the CLI hands its commands shared state), the subcommand enum says `#[usage(run)]`, and the -`match` that routes argv to the code carrying it out is written from the same declaration: +What runs afterwards can be generated too. A command implements `Run`, its subcommand enum +says `#[usage(run)]`, and the `match` that routes argv to the code carrying it out is written +from the same declaration: ```rust +#[derive(Cli)] +#[usage(bin = "ex")] +struct Ex { + #[usage(subcommand)] + command: Commands, +} + +#[derive(Subcommands)] +#[usage(run)] +enum Commands { + Install(Install), +} + +impl Run for Install { + type Output = miette::Result<()>; + fn run(self) -> Self::Output { + install(&self.tools, self.force) + } +} + fn main() -> miette::Result<()> { - Cli::parse().command.run() + Ex::parse().command.run() } ``` -See [Dispatch](/rust/dispatch). +`RunWith` and `#[usage(run_with)]` are the same for a CLI that hands its commands shared +state, and `RunAsync` / `RunAsyncWith` with `#[usage(run_async)]` / `#[usage(run_async_with)]` +are the async pair. See [Dispatch](/rust/dispatch). ## One declaration, every artifact @@ -149,7 +171,7 @@ See [Spec output](/rust/spec) for the round-trip guarantees and what the emitted - [Args and flags](/rust/args-and-flags) — field types, attributes, env vars, defaults - [Subcommands](/rust/subcommands) — command enums, nesting, `flatten`, value enums -- [Dispatch](/rust/dispatch) — `Run`, `RunWith`, and the generated `match` +- [Dispatch](/rust/dispatch) — `Run`, `RunWith`, the async pair, and the generated `match` - [Validation](/rust/validation) — choices, groups, `exclusive`, `delimiter`, conflicts - [Help, version, and errors](/rust/help) — what the parser renders and how to hook it - [Completions](/rust/completions) — static scripts and runtime completion diff --git a/docs/rust/migrating-from-clap.md b/docs/rust/migrating-from-clap.md index a0c3432f0..812f02226 100644 --- a/docs/rust/migrating-from-clap.md +++ b/docs/rust/migrating-from-clap.md @@ -202,10 +202,11 @@ must intercept those built-ins. The `match cli.command { … }` a clap CLI writes after parsing can go too: implement `usage::Run` on each command struct, say `#[usage(run)]` on the enum, and the routing is -generated. Commands that need shared state implement `usage::RunWith` instead. A variant -holding nothing — clap's unit or inline-struct variants, and `external_subcommand` — has no type -to implement the trait for, so those keep their hand-written arms; see -[Dispatch](/rust/dispatch). +generated. Commands that need shared state implement `usage::RunWith` and the enum says +`#[usage(run_with)]`; async commands implement `usage::RunAsync` or `usage::RunAsyncWith` +under `#[usage(run_async)]` / `#[usage(run_async_with)]`. A variant holding nothing — clap's unit +or inline-struct variants, and `external_subcommand` — has no type to implement the trait for, so +those keep their hand-written arms; see [Dispatch](/rust/dispatch). ## Help, specs, and completions diff --git a/docs/rust/subcommands.md b/docs/rust/subcommands.md index ad69c2292..fd4b6f5a7 100644 --- a/docs/rust/subcommands.md +++ b/docs/rust/subcommands.md @@ -50,7 +50,8 @@ struct Install { - Nesting is unbounded in practice: an `Args` struct can carry its own `#[usage(subcommand)]` field, up to a maximum depth of 16. - `#[usage(run)]` on the enum generates the `match` that hands the selected command to the code - that carries it out; see [Dispatch](/rust/dispatch). + that carries it out — with `run_with`, `run_async` and `run_async_with` for a dispatch that + carries a context, is awaited, or both; see [Dispatch](/rust/dispatch). Variant attributes: `name`, `alias`, `alias_hidden`, `hide`, `effect`, `help`, `long_help`, `verbatim_doc_comment`, `external_subcommand`, `arg_required_else_help`. Aliases declared on the variant and on the `Args` diff --git a/usage-rs/src/lib.rs b/usage-rs/src/lib.rs index 33bc34aa8..faa235c28 100644 --- a/usage-rs/src/lib.rs +++ b/usage-rs/src/lib.rs @@ -10,10 +10,11 @@ //! usage = { package = "usage-rs", version = "5.1" } //! ``` //! -//! What happens after a parse can come from the same declaration: a command implements -//! [`Run`] — or [`RunWith`], when the CLI hands its commands shared state — the subcommand enum -//! says `#[usage(run)]`, and the `match` that routes argv to the code carrying it out is -//! generated rather than written. Nothing about it reaches the spec. +//! What happens after a parse can come from the same declaration: a command implements [`Run`], +//! the subcommand enum says `#[usage(run)]`, and the `match` that routes argv to the code +//! carrying it out is generated rather than written. [`RunWith`] under `#[usage(run_with)]` hands +//! each command shared state, and [`RunAsync`] / [`RunAsyncWith`] under `#[usage(run_async)]` / +//! `#[usage(run_async_with)]` are the async pair. Nothing about any of it reaches the spec. //! //! Enable portable expression validation only when a CLI declares `validate` rules: //!