clap-rs / clap

A full featured, fast Command Line Argument Parser for Rust
docs.rs/clap
Apache License 2.0
13.64k stars 1.02k forks source link

`#[arg(from_global)]` with `#[command(flatten)]` #5525

Open abesto opened 3 weeks ago

abesto commented 3 weeks ago

Please complete the following tasks

Clap Version

4.5.4

Describe your use case

Consider some global options, and some logic that reads multiple fields. Implementing this with arg-level global and from_global attributes quickly becomes clumsy. To avoid a lot of duplication, it's useful to group these args into a struct and place the shared logic in its impl (and flatten it into each subcommand).

Currently (AFAICT) this requires one struct with global, and another with from_global, with the fields matched up manually, including documentation (both for --help and IDEs on the from_global side). In the worst case, the impl also needs to be duplicated. This might look like this:

use clap::{Parser, Args, Subcommand};

#[derive(Debug, Args)]
struct GlobalOptions {
    /// Some docs
    #[arg(long, global = true)]
    some_flag: bool,

    /// Some other docs
    #[arg(long, global = true)]
    some_other_flag: bool
}
impl GlobalOptions {
    pub fn some_logic(self) -> bool {
        self.some_flag && self.some_other_flag
    }
}

/////////

#[derive(Debug, Args)]
struct FromGlobalOptions {
    /// Some docs
    #[arg(from_global)]
    some_flag: bool,

    /// Some other docs
    #[arg(from_global)]
    some_other_flag: bool
}
impl FromGlobalOptions {
    pub fn some_logic(self) -> bool {
        self.some_flag && self.some_other_flag
    }
}

////////

#[derive(Debug, Parser)]
struct TopLevelCommand {
    #[command(flatten)]
    global: GlobalOptions,

    #[command(subcommand)]
    command: Command
}

#[derive(Debug, Subcommand)]
enum Command {
    SomeCommand {
        #[command(flatten)]
        global: FromGlobalOptions
    }
}

The impl can probably be deduplicated somehow, but even so: this is clumsy and prone to drift.

Describe the solution you'd like

Pipe-dream: #[command(flatten, from_global)] would magically, transparently transform the global args into from_global variants. For extra strength pipe-dream: also magically share the impl. I suspect this would require hooking into the globals passing machinery of clap such that we don't generate a new struct, but instead use GlobalOptions in both sites (instead of doing this at an external-to-clap-machinery macro level).

So the above example would become:

use clap::{Parser, Args, Subcommand};

#[derive(Debug, Args)]
struct GlobalOptions {

    /// Some docs
    #[arg(long, global = true)]
    some_flag: bool,

    /// Some other docs
    #[arg(long, global = true)]
    some_other_flag: bool
}
impl GlobalOptions {
    pub fn some_logic(self) -> bool {
        self.some_flag && self.some_other_flag
    }
}

////////

#[derive(Debug, Parser)]
struct TopLevelCommand {
    #[command(flatten)]
    global: GlobalOptions,

    #[command(subcommand)]
    command: Command
}

#[derive(Debug, Subcommand)]
enum Command {
    SomeCommand {
        #[command(flatten, from_global)]
        global: GlobalOptions
    }
}

(And then some_command.global.some_logic() would be valid.)

This is slightly preferable to manual arg-level from_globals even in the absence of an impl block, because of the automatically-synced doc comment between the global and from_global variant.

Alternatives, if applicable

This might be asking for too much magic, maybe? If so, a less magical but still-nice solution might be (possibly not part of clap?) a macro that generates both the global and the from_global variants (with doc comments and the impl copied). Maybe something like:

clap_globalize! {
#[derive(Debug, Args)]
struct GlobalOptions {
    /// Some docs
    #[arg(long)]
    some_flag: bool,

    /// Some other docs
    #[arg(long)]
    some_other_flag: bool
}
impl GlobalOptions {
    pub fn some_logic(self) -> bool {
        self.some_flag && self.some_other_flag
    }
}
}

////////

#[derive(Debug, Parser)]
struct TopLevelCommand {
    #[command(flatten)]
    global: GlobalOptions,

    #[command(subcommand)]
    command: Command
}

#[derive(Debug, Subcommand)]
enum Command {
    SomeCommand {
        #[command(flatten)]
        global: GlobalOptionsFromGlobal
    }
}

Or maybe:

#[derive(Debug, Args, FromGlobalsVariant(name = "GlobalOptionsFromGlobal"))]
struct GlobalOptions {
...
}

This feels less neat, but hey, maybe it's the best we can get.

Additional Context

If instead the preferred way is an explicitly propagated GlobalOptions instance, and from_global is just kinda... there, then knowing that would also be good!

epage commented 3 weeks ago

Pipe-dream: #[command(flatten, from_global)] would magically, transparently transform the global args into from_global variants. For extra strength pipe-dream: also magically share the impl. I suspect this would require hooking into the globals passing machinery of clap such that we don't generate a new struct, but instead use GlobalOptions in both sites (instead of doing this at an external-to-clap-machinery macro level).

The challenge with #[command(flatten, from_global)] is that proc macros can't talk to each other at compile time. We'd have to implement this at runtime through the code we generate. This requires expanding the Args trait with "just one more parameter" to change how the code is generated. Unfortunately, we have a lot of these "just one more"s and are concerned about what principle is it ok to expand this and that its either a breaking change or a messy transition because people can hand implement these traits and the new functionality wouldn't work with them.

Maybe one way of doing this is if #[command(flatten, from_global)] only invoked FromArgMatches and not Args. This would require everything in the struct to be from_global or else they get ignored.

This might be asking for too much magic, maybe? If so, a less magical but still-nice solution might be (possibly not part of clap?) a macro that generates both the global and the from_global variants (with doc comments and the impl copied). Maybe something like:

Not thrilled with having macros for such specialized cases. Also, personally I'm bothered when macros do anything besides generate a trait impl because its harder to understand what they do.

If instead the preferred way is an explicitly propagated GlobalOptions instance, and from_global is just kinda... there, then knowing that would also be good!

For myself, I find probably 75% (made up number) of times people discuss using globals, they are reaching for the wrong tool. They want to "DRY" their code when there isn't an inherent requirement that all subcommands have a shared argument. Instead, its only happenstance. We have this problem with cargo itself today, e.g. --offline was made global but it is meaningless in some commands.

Of the remaining uses for globals, I somewhat question the value of from_global. It was added in #2026 but there was no discussion on use cases. Without a good understanding of why the use case is important enough for built-in support, it makes it harder to smooth out the path in even more directions.

abesto commented 3 weeks ago

Thank you for the quick and detailed response!

I pinky-promise I do actually have a real actual use-case for global args, but that doesn't change the maths on ecosystem-wide cost/benefit.

Of the remaining uses for globals, I somewhat question the value of from_global. It was added in https://github.com/clap-rs/clap/pull/2026 but there was no discussion on use cases. Without a good understanding of why the use case is important enough for built-in support, it makes it harder to smooth out the path in even more directions.

That's good to know. As for the why: for me, it's mainly ergonomics. Slightly stronger: if all global propagation logic is handled by clap, then building some kinds of generic abstractions over clap becomes simpler. This rapidly devolves into "how do you do subcommand dispatch with zero boilerplate", which is not a can of worms I wanna open right now (and possibly not a matter clap itself should take a stand on?)

Given all of ^, it sounds like explicitly propagated GlobalOptions structs remain the way to go (until such time as someone comes around and provides a real compelling case for from_global and also solves OP).

This is an outcome I can work with!