Skip to content

Macro Internals: From Function to CLI Command

At the heart of clish's developer experience is the #[command] attribute. With just a single attribute on a standard Rust function, it configures command metadata, parameters, parsing rules, and validation checks.

This page explains how clish-macros parses your function signature, performs compile-time validations, and rewrites your code to generate the underlying command registration.


Procedural Macros 101

In Rust, a procedural macro acts as a compiler plugin. It receives a stream of code tokens from the compiler, parses them, manipulates or analyzes them, and returns a new stream of code tokens that the compiler compiles in place of the original code.

clish-macros uses two industry-standard crates to do this: 1. syn: Parses raw Rust tokens into an Abstract Syntax Tree (AST)—a structured data representation of Rust syntax. 2. quote: Converts Rust-like templates back into code tokens.


The Macro Execution Pipeline

When the Rust compiler encounters #[command], it passes the attribute tokens (e.g., help = "...", param(...)) and the function tokens to the command function inside clish-macros:

graph TD
    Attr[Attribute Tokens] -->|Parsed by syn| CmdAttrs[CommandAttrs AST]
    Fn[Function Tokens] -->|Parsed by syn| ItemFn[ItemFn AST]

    ItemFn -->|Analyze parameters| Params[Extract ParamInfo]
    Params -->|Type Rewriting| NewSig[Generate Rewritten Signature]
    Params -->|Checks| Validation{Compile-time Checks}

    Validation -->|Fail| CompileError[Compile Error]
    Validation -->|Pass| Quote[Generate Code via quote!]

    NewSig --> Quote
    CmdAttrs --> Quote
    Quote -->|Return Tokens| Compiler[Rust Compiler compiles generated code]

1. Parsing Attributes (CommandAttrs)

The macro parses custom command properties such as name, help, details, aliases, hidden, deprecated, and parameter overrides defined inside param(...) blocks. If no help or details are provided in the attribute, the macro reads the function's doc comments as a fallback, splitting them on the first empty line.

2. Parameter Analysis & Type Rewriting

The macro inspects each argument in your function signature. clish relies on Rust's type system to distinguish parameter kinds: * Pos<T> represents a positional parameter. * Named<T> represents a named option (--name val). * bool (or Flag) represents a boolean flag (--flag).

To make writing your function logic clean, the macro rewrites your function signature. It strips away the wrapping Pos<T> and Named<T> types so that your function receives the unwrapped types directly.

Here is how the types are transformed:

Original Type Rewritten Type Parameter Kind
Pos<String> String Required Positional
Pos<Option<i32>> Option<i32> Optional Positional
Pos<Vec<String>> Vec<String> Variadic Positional
Named<f64> f64 Required Named Option
Named<Option<u16>> Option<u16> Optional Named Option
Named<Vec<String>> Vec<String> Repeatable Named Option
bool bool Flag (presence is true, absence is false)

To extract the inner types, the macro searches the AST for generic angle brackets. For example, given Named<Option<u16>>, it extracts Option as the outer type and u16 as the inner type. It also marks the parameter as needs_parse = true if the inner type is not a String, signifying that the runtime parser must convert the string input into the target type using Rust's FromStr trait.

3. Compile-Time Validation

Before generating any code, the macro validates your function signature to catch errors immediately during compilation: * Redundant Types: Option<Vec<T>> is rejected because a Vec can already represent zero items. Option<bool> is rejected because flags should be simple bool values (present or absent). * Variadic Placement: Only one variadic parameter (Pos<Vec<T>>) is allowed, and it must be the very last positional parameter in the function signature. * Unsupported Types: Any type other than Pos, Named, or bool/Flag triggers a compile error.


What the Macro Generates

Once validation passes, the macro generates a block of code. Let's see what is emitted when you write:

#[command(help = "Deploy application")]
fn deploy(target: Pos<String>, port: Named<u16>, force: bool) {
    println!("Deploying to {target} on port {port} (force={force})");
}

The macro expands this into two main parts: the rewritten function and an anonymous constant block containing static metadata and registration logic.

1. The Rewritten Function

The macro outputs your original function but replaces its parameter types:

fn deploy(target: String, port: u16, force: bool) {
    println!("Deploying to {target} on port {port} (force={force})");
}

2. The Anonymous Constant Block

To register the command without polluting the namespace or requiring global initialization functions, the macro outputs a const _: () = { ... }; block:

const _: () = {
    // 1. Declare static parameter descriptors
    static PARAMS: &[::clish::parse::ParamEntry] = &[
        ::clish::parse::ParamEntry::new(
            "target", '\0', "Target destination", "", "positional", ...
        ),
        ::clish::parse::ParamEntry::new(
            "port", 'p', "Port number", "", "named", ...
        ),
        ::clish::parse::ParamEntry::new(
            "force", 'f', "Force deployment", "", "flag", ...
        ),
    ];

    // 2. Submit the command registry entry via inventory
    ::clish::inventory::submit!(::clish::parse::CommandEntry::new(
        "deploy",          // CLI Command name
        &[],               // Aliases
        false,             // Hidden flag
        false,             // Deprecated flag
        "",                // Deprecation note
        "Deploy application", // Short help
        "",                // Detailed details
        PARAMS,            // Static parameter reference
        |args| {           // Execution & Parsing closure
            // Setup Schema mapping for argument parsing
            let schema = ::clish::parse::ArgSchema {
                named: &["port"],
                flags: &["force"],
                short_named_chars: &['p'],
                short_named_targets: &["port"],
                short_flag_chars: &['f'],
                short_flag_targets: &["force"],
            };

            // Step A: Tokenize raw CLI strings
            let parsed = ::clish::parse::ParsedArgs::parse(args, &schema)?;

            // Step B: Validate conflicts and prerequisites
            ::clish::parse::validate_params(&parsed, PARAMS)?;

            // Step C: Parse and resolve individual parameters
            let target = ::clish::parse::parse_required(&parsed, 0)?;

            let __val = ::clish::parse::parse_named(&parsed, "port")
                .ok_or_else(|| ::clish::ErrorKind::missing_value("port"))?;
            let port = __val.parse::<u16>().map_err(|_| {
                ::clish::ErrorKind::invalid_value(__val, "u16")
            })?;

            let force = ::clish::parse::parse_flag(&parsed, "force");

            // Step D: Invoke original function
            deploy(target, port, force);
            Ok(())
        }
    ));
};

Static Registration with inventory

You might wonder: How does the application know about this command if we never manually register it in main?

This magic is powered by the inventory crate. 1. The macro uses inventory::submit! inside the anonymous constant block to register the static CommandEntry structure. 2. Under the hood, inventory uses linker-based registry mechanisms (like .init_array on Linux ELF binaries or __DATA,__mod_init_func on macOS Mach-O binaries). 3. When the program binary is loaded into memory, these registration structures are automatically linked into a continuous global list. 4. When App::run() executes, it calls inventory::iter::<CommandEntry>() to query this list dynamically at startup with zero performance cost.


Next, let's explore the Parsing Engine to see how the generated closure tokenizes and resolves arguments at runtime.