Architecture
This page explains how clish works under the hood. Understanding the architecture helps you debug issues, extend the framework, or just appreciate the design.
Three-crate design
clish is split into three crates in a Cargo workspace:
clish/ # Public API crate (what you depend on)
clish-core/ # Runtime types and logic
clish-macros/ # Procedural macro crate
Why three crates?
Rust requires procedural macros to live in their own crate with crate-type = ["proc-macro"]. The runtime logic cannot live in the macro crate because macros run at compile time and cannot contain runtime code. The public API crate (clish) re-exports everything so you only need one dependency.
clish-macros
The proc-macro crate provides the #[command] attribute. When you annotate a function:
#[command]
fn deploy(target: Pos<String>, force: bool) { ... }
The macro:
- Parses the function signature
- Inspects each parameter type (
Pos<String>,bool, etc.) - Generates argument parsing code based on the types
- Creates a
CommandEntrywith metadata and a parsing closure - Submits the
CommandEntryto theinventoryregistry
The macro rewrites your function signature to use the parsed types directly. Your original Pos<String> becomes String, Pos<Vec<T>> becomes Vec<T>, and bool stays bool.
clish-core
The runtime crate contains:
App-- the top-level application struct with metadata and stylesCommandEntry-- static metadata for each registered commandParamEntry-- static metadata for each parameterParsedArgs-- the result of parsing raw CLI tokensArgSchema-- the lookup table that tells the parser what options existErrorKind-- all possible error types- Help rendering -- functions that print styled help and error output
The inventory crate provides zero-cost static registration. Commands are collected at startup without any runtime overhead.
clish
The public API crate re-exports everything and provides the app!() macro. The app!() macro constructs an App with metadata from Cargo.toml and optionally sets up oneshot mode.
The parsing pipeline
When App::run() is called, this happens:
1. Collect commands
let commands: Vec<&CommandEntry> = inventory::iter::<CommandEntry>().collect();
All CommandEntry instances submitted by #[command] macros are gathered from the binary.
2. Match the subcommand
In multi-command mode, argv[1] is matched against command names and aliases. If no match is found, an UnknownCommand error is printed.
3. Parse arguments
The command's run closure is called with argv[2..]. Inside the closure:
let schema = ArgSchema { named, flags, short_named_chars, ... };
let parsed = ParsedArgs::parse(args, &schema)?;
validate_params(&parsed, PARAMS)?;
// parse individual parameters...
ParsedArgs::parse tokenizes the arguments into three buckets:
positional: values that do not start with-named: key-value pairs from--name valueor--name=valueflags: boolean flags from--flagpresence
4. Validate
validate_params checks conflicts_with and requires rules. Choices are checked during individual parameter parsing.
5. Parse individual parameters
Each parameter is extracted from ParsedArgs using helper functions:
parse_requiredfor required positionalsparse_optionalfor optional positionalsparse_variadicfor variadic positionalsparse_namedfor named optionsparse_named_manyfor repeatable named optionsparse_flagfor boolean flags
Environment variables and defaults are applied during this step. Type parsing (FromStr) happens here too.
6. Execute
The user function is called with the parsed values. If the function returns Result<(), String> and returns Err, the error string is printed using the standard error formatting pipeline.
The inventory system
clish uses the inventory crate for zero-cost static registration. This is how multiple #[command] functions across different modules all end up in the same registry without any manual wiring.
Each #[command] macro generates a const _: () = { ... } block that calls inventory::submit!():
const _: () = {
static PARAMS: &[ParamEntry] = &[...];
inventory::submit!(CommandEntry::new(
"deploy", &[], false, false, "", "Deploy...", "", PARAMS, |args| { ... }
));
};
These submissions are collected at runtime via inventory::iter::<CommandEntry>(). The collection happens once at startup when App::run() is called.
Error handling flow
Errors flow through the system like this:
- Parsing errors (
UnknownOption,MissingValue) are returned asErr(ErrorKind)fromParsedArgs::parse - Validation errors (
Conflict,Requires) are returned fromvalidate_params - Type parsing errors (
InvalidValue,InvalidChoice) are returned during individual parameter extraction - All errors are converted to
StringviaFrom<ErrorKind> for String - The
App::run()method parses the error string back into anErrorKindfor styled output print_errorrenders the error with source-line annotations and hints
The string round-trip exists because the parsing closure returns Result<(), String>, not Result<(), ErrorKind>. This keeps the closure signature simple and allows custom errors from user functions.
Help generation
Help text is generated from ParamEntry and CommandEntry metadata at runtime. There is no static help text. Every label, placeholder, and hint is computed from the parameter configuration:
param_display_namecomputes<HOST>,[<HOST>], or<HOST>...based onkindextra_hintscollects env, default, and choices into(env: X, default: Y, choices: Z)max_widthis computed from all parameter display strings for column alignment
This means help output always matches the actual parameter configuration. You cannot have stale help text.
Next step
That covers the user guide. Head over to the Cookbook for comprehensive reference tables on every attribute, type, and style option.