diff --git a/PLAN.md b/PLAN.md index 83093148a..7f03b16ba 100644 --- a/PLAN.md +++ b/PLAN.md @@ -354,6 +354,54 @@ 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`, `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 + 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: + **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. + **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..4624f7257 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, RunAsync, RunAsyncWith, 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..0cc546cbc --- /dev/null +++ b/argv/src/run.rs @@ -0,0 +1,177 @@ +//! 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. +//! +//! 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 +//! +//! | | no context | a context | +//! | -------------- | ---------- | -------------------- | +//! | **sync** | [`Run`] | [`RunWith`] | +//! | **async** | [`RunAsync`] | [`RunAsyncWith`] | +//! +//! 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 +//! +//! [`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. +//! +//! 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 +//! +//! ``` +//! 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; +} + +/// 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/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..45d5cafb8 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, the way every other command declares its own. -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/conformance/tests/dispatch_async.rs b/conformance/tests/dispatch_async.rs new file mode 100644 index 000000000..004520b5c --- /dev/null +++ b/conformance/tests/dispatch_async.rs @@ -0,0 +1,320 @@ +//! Async commands, dispatched. +//! +//! 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, RunAsync, RunAsyncWith, RunWith}; +use usage_derive::{Args, Cli, Subcommands}; + +/// Install a tool +#[derive(Args)] +struct Install { + #[usage(long)] + force: bool, +} + +/// Show who pays for this +#[derive(Args)] +struct Sponsors; + +/// List the configuration +#[derive(Args)] +struct ConfigLs { + #[usage(long)] + no_header: bool, +} + +#[derive(Subcommands)] +#[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 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 { + #[usage(subcommand)] + command: Command, +} + +impl RunAsync for Install { + type Output = Result; + async fn run_async(self) -> Self::Output { + yield_once().await; + Ok(format!("install force={}", self.force)) + } +} + +impl RunAsync for Sponsors { + type Output = Result; + async fn run_async(self) -> Self::Output { + yield_once().await; + Ok("sponsors".to_string()) + } +} + +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 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 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)) + } +} + +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_awaited() { + let ex = parse(&["install", "--force"]); + assert_eq!( + 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 a_context_reaches_an_async_command() { + let app = App { jobs: 4 }; + let ex = parse(&["install"]); + assert_eq!( + block_on(ex.command.run_async_with(&app)), + Ok("install force=false jobs=4".to_string()) + ); + 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 +/// 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 mut cx = Context::from_waker(Waker::noop()); + 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/derive/src/codegen.rs b/derive/src/codegen.rs index 12a84de54..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. @@ -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,272 @@ 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. +/// 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.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_ty = &subs.variants[0].ty; + let first = quote!(#first_ty); + + 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) + } + }); + // `*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; + + #signature { + match self { + #(#arms)* + } + } + } + } + }); + + quote! { + #[doc(hidden)] + const _: () = { + use #runtime as usage_argv; + + #(#impls)* + }; + } +} + +/// 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.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, held_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 held_ty = quote!(#held_ty); + + 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; + + #signature { + #call + } + } + } + }); + + quote! { + #[doc(hidden)] + const _: () = { + use #runtime as usage_argv; + + #(#impls)* + }; + } +} + 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 +6304,8 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { } } }; + + #dispatch } } diff --git a/derive/src/lib.rs b/derive/src/lib.rs index a794e29eb..aacff00e2 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -119,6 +119,46 @@ //! 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) } +//! } +//! ``` +//! +//! 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 +//! 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 +262,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`, `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 //! @@ -411,6 +453,10 @@ 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)]`, `#[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 149acbb8b..e529e9f9b 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -62,6 +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, + /// 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 + /// 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 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 @@ -658,6 +665,7 @@ impl Cli { runtime_bin: None, completion: false, settings: false, + dispatch: Dispatch::default(), min_usage_version: None, usage: None, effect: None, @@ -783,6 +791,10 @@ impl Cli { // decorative after it. "completion" => cli.completion = flag_value(&meta)?, "settings" => cli.settings = 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 => { @@ -972,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` 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" ), @@ -1482,6 +1494,54 @@ 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.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, + Kind::Subcommand { + optional: false, + .. + } + ) + }) { + Some(field) => { + return Err(syn::Error::new( + field.span, + 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( + 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 => {} + } + } + // 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 +4540,51 @@ 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 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, + 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. @@ -4591,17 +4696,23 @@ impl Subcommands { } let mut rename_all = None; + 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" => 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` here" + usage::Subcommands takes `rename_all`, `run`, \ + `run_with`, `run_async` and `run_async_with` here" ), )); } @@ -4659,9 +4770,58 @@ impl Subcommands { } } + 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 + // 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, + 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, + 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 + // 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, + 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" + ), + )); + } + } + Ok(Subcommands { ident: input.ident.clone(), variants, + dispatch, }) } } @@ -7675,4 +7835,189 @@ 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.dispatch.run); + assert!(cli.dispatch.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.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] + 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..1465fd4d9 --- /dev/null +++ b/docs/rust/dispatch.md @@ -0,0 +1,261 @@ +# 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. 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}; + +#[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) + } +} + +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)?; + cli.command.run_with(&mut app) +} +``` + +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 +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 + +`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 +use usage::{RunAsync, Subcommands}; + +#[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_async().await +} +``` + +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 }) + } +} +``` + +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. + +## 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 the attribute off and writes the +match. + +## 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..bccefe554 100644 --- a/docs/rust/index.md +++ b/docs/rust/index.md @@ -105,6 +105,40 @@ 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`, 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<()> { + Ex::parse().command.run() +} +``` + +`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 Because the derive also emits a usage spec, everything on this site that consumes a spec works @@ -137,6 +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`, 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 e4246a8b2..812f02226 100644 --- a/docs/rust/migrating-from-clap.md +++ b/docs/rust/migrating-from-clap.md @@ -200,6 +200,14 @@ 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` 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 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..fd4b6f5a7 100644 --- a/docs/rust/subcommands.md +++ b/docs/rust/subcommands.md @@ -49,6 +49,9 @@ 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 — 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 da8ff8f98..faa235c28 100644 --- a/usage-rs/src/lib.rs +++ b/usage-rs/src/lib.rs @@ -10,6 +10,12 @@ //! usage = { package = "usage-rs", version = "5.1" } //! ``` //! +//! 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: //! //! ```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); +}