Skip to content

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:

  1. Parses the function signature
  2. Inspects each parameter type (Pos<String>, bool, etc.)
  3. Generates argument parsing code based on the types
  4. Creates a CommandEntry with metadata and a parsing closure
  5. Submits the CommandEntry to the inventory registry

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 styles
  • CommandEntry -- static metadata for each registered command
  • ParamEntry -- static metadata for each parameter
  • ParsedArgs -- the result of parsing raw CLI tokens
  • ArgSchema -- the lookup table that tells the parser what options exist
  • ErrorKind -- 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 value or --name=value
  • flags: boolean flags from --flag presence

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_required for required positionals
  • parse_optional for optional positionals
  • parse_variadic for variadic positionals
  • parse_named for named options
  • parse_named_many for repeatable named options
  • parse_flag for 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:

  1. Parsing errors (UnknownOption, MissingValue) are returned as Err(ErrorKind) from ParsedArgs::parse
  2. Validation errors (Conflict, Requires) are returned from validate_params
  3. Type parsing errors (InvalidValue, InvalidChoice) are returned during individual parameter extraction
  4. All errors are converted to String via From<ErrorKind> for String
  5. The App::run() method parses the error string back into an ErrorKind for styled output
  6. print_error renders 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_name computes <HOST>, [<HOST>], or <HOST>... based on kind
  • extra_hints collects env, default, and choices into (env: X, default: Y, choices: Z)
  • max_width is 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.