cpp26-reflection · part 12

Clap for C++: turning structs into command-line parsers

· english · audience: working-cpp · discuss

In Rust:

#[derive(Parser)]
struct Args {
    #[arg(short, long)] verbose: bool,
    #[arg(short, long, default_value_t = 1)] count: u32,
    input: String,
}

You get --verbose/-v, --count/-c, a positional input, automatic --help, proper error messages — all from declaring the struct.

C++ today uses CLI11 or cxxopts, which require manual .add_option(...) calls per argument. With reflection + annotations from post 9, we can match Rust’s ergonomics.

Target API

struct Args {
    [[=cli::short_('v')]] bool verbose = false;
    [[=cli::short_('c'), =cli::default_(1u)]] unsigned count = 1;
    [[=cli::positional{}]] std::string input;
};

int main(int argc, char** argv) {
    auto parsed = cli::parse<Args>(argc, argv);
    if (!parsed) { std::println(stderr, "{}", parsed.error()); return 1; }
    auto const& args = *parsed;
    if (args.verbose) std::println("counting to {}...", args.count);
    std::println("processing {}", args.input);
}

Three annotations: short_, default_, positional. One entry point: cli::parse<Args>. Automatic --help. Output:

$ ./tool --help
Usage: tool [OPTIONS] <INPUT>

Arguments:
  <INPUT>

Options:
  -v, --verbose
  -c, --count <COUNT>   (default: 1)
  -h, --help           Print help

How it works

Same pattern as reflect_json. Walk the struct, dispatch on each field’s type, use annotations for customisation. The difference: we’re consuming argv, not producing a string.

The entry point

namespace cli {

template <typename Args>
std::expected<Args, std::string> parse(int argc, char const* const argv[]) {
    Args result{};
    std::vector<std::string_view> tokens(argv + 1, argv + argc);

    // First pass: handle --help
    for (auto t : tokens) if (t == "-h" || t == "--help") {
        std::print("{}", usage<Args>());
        std::exit(0);
    }

    // Second pass: parse flags and positionals
    std::size_t pos_index = 0;
    for (std::size_t i = 0; i < tokens.size(); ) {
        auto t = tokens[i];
        if (auto consumed = try_parse_option(result, tokens.subspan(i))) {
            i += *consumed;
        } else if (!t.starts_with('-')) {
            if (auto ok = assign_positional(result, pos_index, t); !ok) return std::unexpected(ok.error());
            ++pos_index; ++i;
        } else {
            return std::unexpected(std::format("unknown option: {}", t));
        }
    }
    return result;
}

}  // namespace cli

try_parse_option and assign_positional are the reflection-driven bits.

try_parse_option

For each member of Args:

  • If the token matches --<long> or -<short>, and the field type is bool, set to true, consume 1 token.
  • If it matches an option and the field needs a value (int, std::string, etc.), parse the next token as that value, consume 2 tokens.
  • If no member matches, return nullopt.
template <typename Args>
std::optional<std::size_t> try_parse_option(Args& result, std::span<std::string_view const> tokens) {
    auto const& t = tokens[0];
    constexpr auto ctx = std::meta::access_context::unchecked();
    std::optional<std::size_t> consumed;
    template for (constexpr auto m
                  : std::define_static_array(
                      std::meta::nonstatic_data_members_of(^^Args, ctx))) {
        if constexpr (!std::meta::annotation_of_type<positional>(m).has_value()) {
            constexpr auto long_name  = long_name_of<m>();    // "verbose", "count"
            constexpr auto short_name = short_name_of<m>();   // std::optional<char>{'v'}, {'c'}
            bool matches = (t == std::format("--{}", long_name))
                        || (short_name.has_value() && t.size() == 2
                             && t[0] == '-' && t[1] == *short_name);
            if (matches) {
                using M = [: std::meta::type_of(m) :];
                if constexpr (std::is_same_v<M, bool>) {
                    result.[: m :] = true;
                    consumed = 1;
                } else if (tokens.size() >= 2) {
                    result.[: m :] = parse_scalar<M>(tokens[1]);
                    consumed = 2;
                }
            }
        }
    }
    return consumed;
}

template for unrolls the dispatch. At runtime, the generated code is a flat if/else chain over field names — exactly what you’d hand-write.

Help-text generation

template <typename Args>
consteval std::string usage() {
    std::string out = "Usage: prog [OPTIONS]";
    constexpr auto ctx = std::meta::access_context::unchecked();
    template for (constexpr auto m
                  : std::define_static_array(
                      std::meta::nonstatic_data_members_of(^^Args, ctx))) {
        if constexpr (std::meta::annotation_of_type<positional>(m).has_value()) {
            out += std::format(" <{}>",
                               to_upper(std::meta::identifier_of(m)));
        }
    }
    out += "\n\nOptions:\n";
    template for (constexpr auto m : members<Args>()) {
        if constexpr (!std::meta::annotation_of_type<positional>(m).has_value()) {
            out += format_option_line<m>();
        }
    }
    return out;
}

Help text is computed at compile time and baked into the binary as a static string. No format string overhead at runtime — typing --help prints a char const*.

Annotations we add

namespace cli {

struct short_ {
    char value;
    bool operator==(short_ const&) const = default;
};

struct positional {
    bool operator==(positional const&) const = default;
};

template <typename T>
struct default_ {
    T value;
    bool operator==(default_ const&) const = default;
};

struct help_ {
    char const* text;
    bool operator==(help_ const&) const = default;
};
consteval auto help(std::string_view s) { return help_{std::define_static_string(s)}; }

struct name_ {
    char const* value;
    bool operator==(name_ const&) const = default;
};
consteval auto name(std::string_view s) { return name_{std::define_static_string(s)}; }

}  // namespace cli

Seven total — same pattern as the serde family in post 9.

Subcommands

Clap’s killer feature: subcommands. Represented as a std::variant of command structs:

struct AddCmd    { std::string path; [[=cli::short_('f')]] bool force = false; };
struct RemoveCmd { std::string path; };

struct Cli {
    bool verbose = false;
    std::variant<AddCmd, RemoveCmd> command;
};

int main(int argc, char** argv) {
    auto args = cli::parse<Cli>(argc, argv);
    std::visit([](auto const& c) { run(c); }, args->command);
}

// ./tool add path/to/thing --force
// ./tool remove path/to/thing

The variant branch is chosen by the first non-flag token. reflect_cli walks the variant alternatives (via template_arguments_of(^^std::variant<...>)) and routes accordingly.

The [[=cli::positional{0}]] / [[=cli::option{"-n", "--count"}]] pattern uses value-carrying structs per axis — the shape Barry Revzin’s reference example ships and that the P3394R4 examples canonise (empty tag struct for boolean markers, value-carrying struct for parameters). It’s the same split serde-rs settled on after years of evolution.

An equally valid alternative is enum class : unsigned bitflags for orthogonal boolean modifiers:

enum class cli_flags : unsigned {
    none       = 0,
    required   = 1 << 0,
    hidden     = 1 << 1,
    env        = 1 << 2,
    repeatable = 1 << 3,
};
constexpr cli_flags operator|(cli_flags a, cli_flags b) {
    return cli_flags(unsigned(a) | unsigned(b));
}

// Pre-named combinations stay short at call sites:
inline constexpr cli_flags required_env = cli_flags::required | cli_flags::env;

struct Args {
    [[=cli::option{"--token"}, =required_env]]            std::string api_token;
    [[=cli::option{"--include", "-i"}, =cli_flags::repeatable]]
                                                          std::vector<std::string> includes;
};

The bitflag pattern actually works as advertised — the demo below walks the struct and prints which modifier bits each field carries:

Bitflags win when the modifier set is closed, the combinations recur, and the consumer wants the whole mask as a value — the canonical examples are std::ios::fmtflags and std::filesystem::perms. They lose on:

  • Documentation surface — each combination is implicit; clap-rs’s most-cited derive pain (discussion #4090) is exactly this open-endedness.
  • Per-bit lookup(get_annotation<cli_flags>(m) & required) != 0 reads worse than has_annotation<required>(m).
  • Type-distinct queriesannotations_of_with_type(M, ^^required) is purpose-built for tag types; bitfields force a single combined value through the same channel.
  • Invalid-combination rejection — overload resolution + static_assert can reject impossible tag pairs at compile time; every bit combination is syntactically valid.

And there’s a third orthogonal axis you should use first: the type signature. std::optional<int> count says “optional” without any annotation; std::vector<std::string> includes says “repeatable”. Clap-rs’s Option<T> / Vec<T> convention encodes those modifiers in the type system, not the attribute system, leaving the annotation surface for the genuinely declarative parts (flag names, defaults, env-var bindings). The C++ version follows the same lesson — your annotation taxonomy should cover only what the type system can’t say.

The demo below runs a shape_of<T>() consteval helper across the fields of an Args struct and prints the per-field shape derived from the type alone — no annotations involved:

What about CLI11 / cxxopts?

They’re fine libraries. The reflection version gives you:

  • No per-argument call. Struct declaration is the spec.
  • Type-safe assignment. args.count is already a uint32_t, not parsed later from std::string.
  • Automatic help. Generated from annotations + identifiers.
  • Compile-time validation. Forgetting to handle a field is a compile error — you’ll know before shipping, not at runtime when a user passes --missing-flag.

What you lose: less magical-looking .add_option(...) chains that may feel more discoverable to readers unfamiliar with reflection. Trade-off.

Limitations of the naive version

  • No mutually-exclusive groups (yet). serde-analog: #[arg(group = ...)]. Implementable via annotations, left as exercise.
  • No variable-length positionals. std::vector<std::string> files could collect trailing args, but the lexer needs a small addition.
  • No env-var fallback (CLI_ENV_<NAME>). An annotation handles it.

These are three days of iteration each. The foundation is solid.

What’s next