Skip to content

feat(derive): Add #[command(defer)] for lazy subcommand initialization#6256

Open
r-near wants to merge 5 commits into
clap-rs:masterfrom
r-near:feat/defer-derived-subcommands
Open

feat(derive): Add #[command(defer)] for lazy subcommand initialization#6256
r-near wants to merge 5 commits into
clap-rs:masterfrom
r-near:feat/defer-derived-subcommands

Conversation

@r-near
Copy link
Copy Markdown

@r-near r-near commented Feb 9, 2026

Summary

Adds #[command(defer = true)] to the derive API, enabling lazy initialization of subcommand arguments via Command::defer(). This closes the gap between the builder API (which has had Command::defer since #4792) and the derive API.

  • Subcommand metadata (.about(), .version(), etc.) is applied eagerly, so --help displays subcommand descriptions without needing build()
  • Subcommand args and nested subcommands are wrapped in Command::defer() and only materialized when needed
  • Defaults to false in v4, true under unstable-v5

Motivation

CLIs with many deeply nested subcommands pay a steep startup cost because clap eagerly initializes every subcommand and all their args — even when only one is invoked. For a CLI with ~330 subcommands, this was measured at 4–6 seconds of startup time, reduced to ~12ms with deferred initialization.

See #4959 for the original feature request.

Prior art

This picks up the work from #6225 by @frol, restructured to follow the project's contribution guidelines. Key differences from the original PR:

  • gen_augment accepts a skip_top_level_methods parameter so metadata and args are each generated exactly once (no duplication)
  • Commit history follows CONTRIBUTING.md conventions (refactor → baseline tests → feature)

Example

#[derive(Subcommand)]
#[command(defer = true)]
enum Commands {
    /// Add a file
    Add { #[arg(long)] file: String },
    /// Remove a file  
    Remove { #[arg(long)] force: bool },
}

Partially addresses #4959

Copilot AI review requested due to automatic review settings February 9, 2026 23:59
@r-near r-near force-pushed the feat/defer-derived-subcommands branch from 6477449 to 63f512f Compare February 10, 2026 00:01
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds support in clap_derive for a new #[command(defer = ...)] attribute to enable lazy initialization of subcommand arguments (via Command::defer), aligning derive behavior with the existing builder API and improving startup time for CLIs with large subcommand trees.

Changes:

  • Add defer tracking to derive Item and parse #[command(defer = true/false)].
  • Generate deferred subcommand augmentation for derive-generated subcommands, splitting eager metadata from deferred arg/subcommand materialization.
  • Add/adjust derive tests to validate deferred behavior and ensure help-related introspection calls Command::build() when needed.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/derive/help.rs Updates tests to call cmd.build() before subcommand introspection and reformats an assertion.
tests/derive/defer.rs Adds new integration tests covering deferred subcommand initialization (including nested subcommands and version-dependent defaults).
clap_derive/src/item.rs Introduces Item::defer state with a default tied to unstable-v5, parses the new magic attribute, and exposes a getter.
clap_derive/src/derives/subcommand.rs Wraps subcommand augmentation in Command::defer(...) when enabled and avoids duplicating top-level method generation in the deferred path.
clap_derive/src/derives/args.rs Adds skip_top_level_methods plumbing to support eager metadata + deferred args generation without duplication.
clap_derive/src/attr.rs Adds lit_bool_or_abort and registers defer as a magic attribute name.
Comments suppressed due to low confidence (1)

clap_derive/src/derives/subcommand.rs:343

  • For named variants, args::gen_augment(...) already prepends parent_item.deprecations() (see derives/args.rs), but this code also injects item.deprecations() into the surrounding subcommand({ ... }) block. This can lead to duplicated deprecation warnings/work for the same item. Consider suppressing one of these (e.g., add a flag to args::gen_augment to skip deprecations when the caller handles them, or only emit #deprecations here for the non-Named branches).
                let deprecations = if !override_required {
                    item.deprecations()
                } else {
                    quote!()
                };

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread clap_derive/src/derives/subcommand.rs
Comment thread clap_derive/src/attr.rs
@epage
Copy link
Copy Markdown
Member

epage commented Feb 10, 2026

This squashed the refactor commit from #6225: 38bbeb6

Comment thread tests/derive/defer.rs Outdated
Comment thread clap_derive/src/derives/args.rs Outdated
@r-near r-near force-pushed the feat/defer-derived-subcommands branch 2 times, most recently from 1a5e579 to c16a824 Compare February 10, 2026 19:19
@r-near
Copy link
Copy Markdown
Author

r-near commented Feb 10, 2026

This squashed the refactor commit from #6225: 38bbeb6

Split it back out here b98fc9e and unified the Named/Unit/Unnamed branches into the same tuple pattern.

@epage thanks for the quick review! Would you mind taking another look when you have a chance?

Comment on lines +80 to +81
let initial_app_methods = item.initial_top_level_methods();
let final_app_methods = item.final_top_level_methods();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intention would be that we would do this consistently across all derives

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok both callers now apply initial/final uniformly. Added here 560fef5 (this PR)

Comment thread clap_derive/src/derives/subcommand.rs
@epage
Copy link
Copy Markdown
Member

epage commented Feb 10, 2026

Thanks! This is almost there, just some clean up.

@r-near r-near force-pushed the feat/defer-derived-subcommands branch from c16a824 to 43a6e00 Compare February 10, 2026 22:08
Comment on lines +326 to +332
let sub_augment = if parent_item.defer() {
quote! {
#subcommand_var.defer(|#subcommand_var| { #sub_augment })
}
} else {
sub_augment
};
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't seem to do this for Args. We're missing a test case for this.

Looking back at #6225, I see deferring of arguments discussed at #6225 (comment)

This is causing me to look back at #4959 where new trait methods are suggested and I'm wondering why I suggested those originally and if there is some missing functionality, e.g. around flatten.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a test case for this.

So for the current scope, I think flatten is covered under deferred subcommands, right?

The extra trait-method ideas from #4959 seem like broader defer semantics. IDK if you wanna add that into this PR?

Move the application of initial_top_level_methods and
final_top_level_methods out of gen_augment and into its callers
in both args.rs and subcommand.rs. This gives callers control
over when and how metadata (about, version, etc.) is applied,
separately from args and groups.

Part of clap-rs#4959
Restructure Kind::Command branches in subcommand.rs so that all
three field variants (Named, Unit, Unnamed) return a
(sub_augment, initial_app_methods, final_from_attrs) tuple, with
the surrounding code applying them uniformly.

Part of clap-rs#4959
Add tests showing the current eager initialization behavior. The
subsequent feature commit will modify these to use #[command(defer)]
and update assertions to reflect deferred behavior.

Tests cover:
- Subcommand with named fields
- Nested subcommands with Args structs
- Flattened Args in subcommands
- Enum variants with inline args
- Default behavior is eager

Part of clap-rs#4959
find_subcommand inspects nested subcommands that may be deferred
once unstable-v5 defaults defer to true. Calling build() first
ensures the subcommands are materialized, making this test
forward-compatible.

Part of clap-rs#4959
Add support for deferred subcommand initialization in the derive API
via #[command(defer = true)]. When enabled, the derive macro generates
code that uses Command::defer() to lazily initialize subcommand args,
while keeping metadata (about, version, etc.) eagerly applied so that
--help displays subcommand descriptions without needing to build.

The attribute defaults to false in clap v4 and true when the
unstable-v5 feature is enabled.

This can dramatically improve startup time for CLIs with many deeply
nested subcommands (e.g. 4-6 seconds down to ~12ms for a CLI with 330
subcommands).

Part of clap-rs#4959
@r-near r-near force-pushed the feat/defer-derived-subcommands branch 4 times, most recently from 4349dee to 531fdd5 Compare February 13, 2026 07:48
@r-near r-near requested a review from epage February 15, 2026 17:43
@r-near
Copy link
Copy Markdown
Author

r-near commented Mar 2, 2026

@epage would you mind taking a look when you get a chance?

@r-near
Copy link
Copy Markdown
Author

r-near commented Apr 15, 2026

Please sir, may I have a review? @epage

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants