From b85b6ebcabaafa4bb6ac5518c0dcc224f2a0b73f Mon Sep 17 00:00:00 2001 From: Aaron Aranda Date: Mon, 25 May 2026 10:08:44 -0600 Subject: [PATCH 1/9] Added option, positional, and program descriptor types. They'll be used to generate a parsing plan at compile time but unused for now. --- src/cli/CMakeLists.txt | 1 + src/cli/descriptors.hpp | 119 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 src/cli/descriptors.hpp diff --git a/src/cli/CMakeLists.txt b/src/cli/CMakeLists.txt index 298452a..57d5700 100644 --- a/src/cli/CMakeLists.txt +++ b/src/cli/CMakeLists.txt @@ -11,5 +11,6 @@ target_sources( $ $ $ + $ $ $) diff --git a/src/cli/descriptors.hpp b/src/cli/descriptors.hpp new file mode 100644 index 0000000..a092090 --- /dev/null +++ b/src/cli/descriptors.hpp @@ -0,0 +1,119 @@ +#pragma once + +#include +#include + +/** + * Descriptors are compile time built descriptions of how to parse command line options. + */ + +namespace cli { + +/** + * @enum option_kind + * @brief Discriminator for the bound `invoke` function on an `option_descriptor`. + * Determines how the parser interacts with the option. + */ +enum class option_kind { + /** @brief Option that doesn't need a value, just checked for presence. */ + bool_flag, + /** @brief Option that takes a single value. */ + scalar, + /** @brief Option where the value is appended to a range member. */ + vector, + /** @brief Option that increments with presence, like `-vvv`. */ + counter, + /** @brief Option is used to get help. */ + help, + /** @brief Option is used to get the version. */ + version, +}; + +/** + * @struct option_descriptor + * @brief Describes one command line option. + */ +struct option_descriptor { + /** @brief Long form (like `--version`). Falls back to member identifier. */ + const char *long_name; + /** @brief Canonical short form. Falls back to `long_name[0]`. */ + char short_name; + + /** @brief Placeholder shown after in the option in the help output (like `--out PATH`). */ + const char *value_name; + + /** @brief One-line description shown in help output. */ + const char *desc; + + /** @brief Environment variable to use to get the value when the option is not present. */ + const char *env_var; + + // Tags + + /** @brief Option must be provided by the user. */ + bool is_required; + + bool is_hidden; + + option_kind kind; + + /** + * @brief Bound parser / setter / action for this option. + * + * Signature is uniform across `option_kind` values so the runtime loop + * can call through a single pointer: + * + * @param opts `T*` cast to `void*` where `T` is the user's option. + * @param value argv value when `kind` is `scalar` or `vector`, otherwise unused. + * @returns true on success, false on parse / validation failure. + */ + bool (*invoke)(void *opts, std::string_view value); +}; + +/** + * @struct positional_descriptor + * @brief One row of the parse plan describing a positional argument. + */ +struct positional_descriptor { + /** @brief The member identifier used in the help output and error messages. */ + const char *name; + /** @brief One-line description shown in help output. */ + const char *desc; + + bool is_required; + /** @brief If true, this positional consumes all remaining argv slots. */ + bool is_vector; + bool is_hidden; + + /** + * @brief Parse one positional argv slot into the bound member. + * Same as `option_descriptor::invoke`. + * + * @param opts `T*` cast to `void*`. + * @param value + * @returns true on success. + */ + bool (*invoke)(void *opts, std::string_view value); +}; + +/** + * @struct program_descriptor + * @brief Compile-time description of how to parse a given option struct `T`. + */ +struct program_descriptor { + /** @brief Program name from `cli::program` or null to derive from `argv[0]`. */ + const char *name; + /** @brief Value of `cli::version(...)`. */ + const char *version; + /** @brief Value of `cli::about(...)`. */ + const char *about; + /** @brief Value of `cli::epilog(...)`. */ + const char *epilog; + /** @brief Value of `cli::usage(...)`. */ + const char *usage; + + std::span options; + std::span positionals; +}; + +} // namespace cli From 0cbf1b089f9c59b3f287ecbfbfa87ebd13cf95d3 Mon Sep 17 00:00:00 2001 From: Aaron Aranda Date: Mon, 25 May 2026 14:44:15 -0600 Subject: [PATCH 2/9] Created build method to build descriptors at compile time, and associate invoke methods. Wrote descriptor tests. Cleaned up code docs and formatting. --- src/cli/CMakeLists.txt | 1 + src/cli/annotations.hpp | 3 +- src/cli/builder.hpp | 205 ++++++++++++++++++++++++++++++++++++ src/cli/cli.hpp | 2 + src/cli/concepts.hpp | 3 - src/cli/detail.hpp | 46 ++++++-- src/cli/parse.hpp | 13 --- tests/.clang-format | 8 ++ tests/test_annotations.cpp | 8 +- tests/test_descriptors.cpp | 166 +++++++++++++++++++++++++++++ tests/test_help.cpp | 28 +++-- tests/test_parse_flags.cpp | 30 +++--- tests/test_parse_scalar.cpp | 9 +- tests/test_subcommand.cpp | 10 -- 14 files changed, 461 insertions(+), 71 deletions(-) create mode 100644 src/cli/builder.hpp create mode 100644 tests/.clang-format create mode 100644 tests/test_descriptors.cpp diff --git a/src/cli/CMakeLists.txt b/src/cli/CMakeLists.txt index 57d5700..0c728a6 100644 --- a/src/cli/CMakeLists.txt +++ b/src/cli/CMakeLists.txt @@ -6,6 +6,7 @@ target_sources( INTERFACE $ $ + $ $ $ $ diff --git a/src/cli/annotations.hpp b/src/cli/annotations.hpp index a98de8b..1a48113 100644 --- a/src/cli/annotations.hpp +++ b/src/cli/annotations.hpp @@ -188,9 +188,10 @@ struct epilog { * @brief Override the auto-synthesized `Usage:` line. */ struct usage { - const char *text; consteval usage(std::string_view str) noexcept : text{std::define_static_string(str)} {} + + const char *text; }; ///@} diff --git a/src/cli/builder.hpp b/src/cli/builder.hpp new file mode 100644 index 0000000..089c398 --- /dev/null +++ b/src/cli/builder.hpp @@ -0,0 +1,205 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Compile-time construction of the parse plan for an option struct. + +// `build()` returns a `program_descriptor` whose option and +// positional spans point at static-storage arrays. Each descriptor's +// `invoke` field is a function pointer minted by `std::meta::substitute` +// + `std::meta::extract` on one of the typed `invoke_*` templates below. +namespace cli::detail { + +template +auto invoke_bool(void *opts, std::string_view /*value*/) -> bool { + static_cast(opts)->[:M:] = true; + return true; +} + +template +auto invoke_scalar(void *opts, std::string_view value) -> bool { + return stream_parse(value, static_cast(opts)->[:M:]); +} + +template +auto invoke_vector(void *opts, std::string_view value) -> bool { + using Member = typename[:std::meta::type_of(M):]; + typename Member::value_type tmp{}; + if (!stream_parse(value, tmp)) { + return false; + } + (static_cast(opts)->[:M:]).push_back(std::move(tmp)); + return true; +} + +template +auto invoke_counter(void *opts, std::string_view /*value*/) -> bool { + ++(static_cast(opts)->[:M:]); + return true; +} + +// catches help and version which will be handled elsewhere +template +auto invoke_noop(void * /*opts*/, std::string_view /*value*/) -> bool { + return true; +} + +// classify a member by its type + tag annotations into an option_kind +struct member_classification { + option_kind kind; + bool is_vector_member; +}; + +template +consteval auto classify_member(std::meta::info memb) -> member_classification { + if (has_tag(memb)) { + return {.kind = option_kind::help, .is_vector_member = false}; + } + + if (has_tag(memb)) { + return {.kind = option_kind::version, .is_vector_member = false}; + } + + if constexpr (std::meta::is_same_type(^^Member, ^^bool)) { + return {.kind = option_kind::bool_flag, .is_vector_member = false}; + } else if constexpr (is_vector) { + return {.kind = option_kind::vector, .is_vector_member = true}; + } else if constexpr (std::meta::is_integral_type(^^Member)) { + if (has_tag(memb)) { + return {.kind = option_kind::counter, .is_vector_member = false}; + } + return {.kind = option_kind::scalar, .is_vector_member = false}; + } else { + return {.kind = option_kind::scalar, .is_vector_member = false}; + } +} + +// pick the right invoke_* template for a member's classified kind. +consteval auto invoke_template_for(option_kind kind) -> std::meta::info { + switch (kind) { + case option_kind::bool_flag: + return ^^invoke_bool; + case option_kind::scalar: + return ^^invoke_scalar; + case option_kind::vector: + return ^^invoke_vector; + case option_kind::counter: + return ^^invoke_counter; + case option_kind::help: + case option_kind::version: + return ^^invoke_noop; + } + return ^^invoke_noop; +} + +// create the typed invoke function pointer for (T, member, kind). +template +consteval auto bind_invoke(std::meta::info memb, option_kind kind) + -> bool (*)(void *, std::string_view) { + auto tmpl = invoke_template_for(kind); + auto fn = std::meta::substitute(tmpl, {^^T, std::meta::reflect_constant(memb)}); + return std::meta::extract(fn); +} + +} // namespace cli::detail + +namespace cli { + +/** + * @brief Build the compile-time parse plan for an option struct `T`. + * + * Walks `T`'s nonstatic data members, classifies each one, and associates the + * typed invoke function pointer via `std::meta::substitute`, and packages + * everything into a `program_descriptor` whose spans point at static + * storage promoted by `std::meta::define_static_array`. + */ +template +consteval auto build() -> program_descriptor { + constexpr auto ctx = std::meta::access_context::current(); + + std::vector options; + std::vector positionals; + + for (auto memb : std::meta::nonstatic_data_members_of(^^T, ctx)) { + cli::member_info mi{memb}; + auto type = std::meta::type_of(memb); + + // classify a per-member helper substituted with the member type. + auto cls_info = std::meta::substitute( + ^^detail::classify_member, + { + std::meta::dealias(type)}); + auto cls = + std::meta::extract(cls_info)(memb); + + auto invoke_fn = detail::bind_invoke(memb, cls.kind); + + if (mi.is_positional) { + positionals.emplace_back( + positional_descriptor{ + .name = mi.long_name, + .desc = mi.desc, + .is_required = mi.is_required, + .is_vector = cls.is_vector_member, + .is_hidden = mi.is_hidden, + .invoke = invoke_fn}); + } else { + options.emplace_back( + option_descriptor{ + .long_name = mi.long_name, + .short_name = mi.short_char, + .value_name = mi.value_name, + .desc = mi.desc, + .env_var = mi.env_var, + .is_required = mi.is_required, + .is_hidden = mi.is_hidden, + .kind = cls.kind, + .invoke = invoke_fn}); + } + } + + // program-level annotations + const char *prog_name = nullptr; + if (auto a = detail::get_annotation(^^T)) { + prog_name = a->value; + } + const char *prog_ver = nullptr; + if (auto a = detail::get_annotation(^^T)) { + prog_ver = a->value; + } + const char *prog_about = nullptr; + if (auto a = detail::get_annotation(^^T)) { + prog_about = a->text; + } + const char *prog_epilog = nullptr; + if (auto a = detail::get_annotation(^^T)) { + prog_epilog = a->text; + } + const char *prog_usage = nullptr; + if (auto a = detail::get_annotation(^^T)) { + prog_usage = a->text; + } + + return program_descriptor{ + .name = prog_name, + .version = prog_ver, + .about = prog_about, + .epilog = prog_epilog, + .usage = prog_usage, + .options = std::define_static_array(options), + .positionals = std::define_static_array(positionals), + }; +} + +} // namespace cli diff --git a/src/cli/cli.hpp b/src/cli/cli.hpp index 049571b..9163305 100644 --- a/src/cli/cli.hpp +++ b/src/cli/cli.hpp @@ -28,7 +28,9 @@ // For subcommands, hand a std::variant of option structs to cli::parse. #include +#include #include +#include #include #include #include diff --git a/src/cli/concepts.hpp b/src/cli/concepts.hpp index 1d9b0f1..72f50fe 100644 --- a/src/cli/concepts.hpp +++ b/src/cli/concepts.hpp @@ -2,10 +2,7 @@ #include #include -#include -#include #include -#include namespace cli { diff --git a/src/cli/detail.hpp b/src/cli/detail.hpp index ac798c1..3b57482 100644 --- a/src/cli/detail.hpp +++ b/src/cli/detail.hpp @@ -2,29 +2,55 @@ #include #include +#include +#include +#include +#include -// Reflection helpers shared by help.hpp, parse.hpp, and member_info.hpp. -// Kept in its own header so member_info.hpp can use them without pulling in -// the full help.hpp transitive graph. namespace cli::detail { -// Return the first annotation of type A on `refl`, or std::nullopt. +/** + * @brief Gets the first annotation of type A or nullopt. + * + * @tparam A The annotation @see{src/cli/annotations.hpp} + * @param info + * @returns std::optional nullopt if it doesn't exist. + */ template -consteval auto get_annotation(std::meta::info refl) -> std::optional { - auto anns = std::meta::annotations_of_with_type(refl, ^^A); +consteval auto get_annotation(std::meta::info info) -> std::optional { + auto anns = std::meta::annotations_of_with_type(info, ^^A); if (std::empty(anns)) { return std::nullopt; } if (std::size(anns) > 1) { - throw std::meta::exception{"Duplicate annotation", refl}; + throw std::meta::exception{"Duplicate annotation", info}; } return std::meta::extract(anns[0]); } -// True iff `refl` carries any annotation whose type is Tag. +/** + * @brief Checks if a type has annotation that is Tag. + * + * @tparam Tag @see{src/cli/annotations.hpp} anything that ends with _tag. + * @param info + * @returns true if Tag exists on type. + */ template -consteval auto has_tag(std::meta::info refl) -> bool { - return !std::meta::annotations_of_with_type(refl, ^^Tag).empty(); +consteval auto has_tag(std::meta::info info) -> bool { + return !std::meta::annotations_of_with_type(info, ^^Tag).empty(); +} + +// parse one stream-readable scalar into `out`. +template +auto stream_parse(std::string_view text, T &out) -> bool { + if constexpr (std::is_same_v) { + out = std::string{text}; + return true; + } else { + auto iss = std::stringstream{std::string{text}}; + iss >> out; + return !iss.fail(); + } } } // namespace cli::detail diff --git a/src/cli/parse.hpp b/src/cli/parse.hpp index e00620d..359d406 100644 --- a/src/cli/parse.hpp +++ b/src/cli/parse.hpp @@ -63,19 +63,6 @@ inline auto extract_value(std::span args, std::size_t po return {.value = {}, .extra_consumed = 0, .found = false}; } -// Parse one stream-readable scalar into `out`. -template -auto stream_parse(std::string_view text, T &out) -> bool { - auto iss = std::stringstream{std::string{text}}; - if constexpr (std::is_same_v) { - out = std::string{text}; - return true; - } else { - iss >> out; - return !iss.fail(); - } -} - // Skeleton single-struct parser. Many features are stubbed but the dispatch // shape is in place so we can fill them in incrementally. template diff --git a/tests/.clang-format b/tests/.clang-format new file mode 100644 index 0000000..f65ab02 --- /dev/null +++ b/tests/.clang-format @@ -0,0 +1,8 @@ +# Examples are showcase code where the visual layout of C++26 annotation +# blocks (`[[=cli::desc("..."), =cli::positional]]`) matters for readability. +# clang-format 22.x does not yet understand `[[=value]]` syntax — it inserts +# operator-style spaces around `=` and collapses multi-line attributes — so +# we disable it entirely in this directory. Re-enable per file with +# `// clang-format on` once clang-format learns C++26 annotations. +DisableFormat: true +SortIncludes: false diff --git a/tests/test_annotations.cpp b/tests/test_annotations.cpp index fdd3a76..263d99d 100644 --- a/tests/test_annotations.cpp +++ b/tests/test_annotations.cpp @@ -4,11 +4,15 @@ #include struct AnnotatedField { - [[ = cli::desc("hello world"), = cli::short_name('h') ]] int x; + [[=cli::desc("hello world"), + =cli::short_name('h')]] + int x; }; struct WithPositional { - [[ = cli::desc("p"), = cli::positional ]] int x; + [[=cli::desc("p"), + =cli::positional]] + int x; }; TEST(annotations, get_desc_returns_attached_text) { diff --git a/tests/test_descriptors.cpp b/tests/test_descriptors.cpp new file mode 100644 index 0000000..d178b92 --- /dev/null +++ b/tests/test_descriptors.cpp @@ -0,0 +1,166 @@ +#include +#include + +namespace { + + +struct [[=cli::program("plantest"), + =cli::version("1.2.3"), + =cli::about("Exercise the plan builder.")]] +Fixture { + + [[=cli::desc("Input path"), + =cli::positional, + =cli::required]] + std::string input; + + [[=cli::desc("Verbose mode"), + =cli::short_name('v')]] + bool verbose = false; + + [[=cli::desc("Port number"), + =cli::short_name('p'), + =cli::value_name("PORT")]] + int port = 0; + + [[=cli::desc("Include directories"), + =cli::short_name('I'), + =cli::long_name("include")]] + std::vector includes; + + [[=cli::desc("Repeat for more verbosity"), + =cli::short_name('q'), + =cli::repeatable ]] + int quiet_level = 0; +}; + +} // namespace + +TEST(descriptors, program_level_annotations_populated) { + constexpr auto plan = cli::build(); + + static_assert(std::string_view{plan.name} == "plantest"); + static_assert(std::string_view{plan.version} == "1.2.3"); + static_assert(std::string_view{plan.about} == "Exercise the plan builder."); + static_assert(plan.epilog == nullptr); + static_assert(plan.usage == nullptr); + + EXPECT_STREQ(plan.name, "plantest"); + EXPECT_STREQ(plan.version, "1.2.3"); +} + +TEST(descriptors, member_partition_between_positional_and_options) { + constexpr auto plan = cli::build(); + static_assert(plan.positionals.size() == 1); + static_assert(plan.options.size() == 4); + EXPECT_EQ(plan.positionals.size(), 1u); + EXPECT_EQ(plan.options.size(), 4u); +} + +TEST(descriptors, positional_descriptor_fields) { + constexpr auto plan = cli::build(); + constexpr auto &pos = plan.positionals[0]; + + static_assert(std::string_view{pos.name} == "input"); + static_assert(std::string_view{pos.desc} == "Input path"); + static_assert(pos.is_required); + static_assert(!pos.is_vector); + static_assert(!pos.is_hidden); + EXPECT_STREQ(pos.name, "input"); +} + +TEST(descriptors, bool_flag_descriptor) { + constexpr auto plan = cli::build(); + constexpr auto &opt = plan.options[0]; // verbose + + static_assert(std::string_view{opt.long_name} == "verbose"); + static_assert(opt.short_name == 'v'); + static_assert(opt.kind == cli::option_kind::bool_flag); + static_assert(opt.value_name == nullptr); + EXPECT_EQ(opt.kind, cli::option_kind::bool_flag); +} + +TEST(descriptors, scalar_with_value_name) { + constexpr auto plan = cli::build(); + constexpr auto &opt = plan.options[1]; // port + + static_assert(std::string_view{opt.long_name} == "port"); + static_assert(opt.short_name == 'p'); + static_assert(opt.kind == cli::option_kind::scalar); + static_assert(std::string_view{opt.value_name} == "PORT"); + EXPECT_EQ(opt.kind, cli::option_kind::scalar); +} + +TEST(descriptors, vector_option_with_long_name_override) { + constexpr auto plan = cli::build(); + constexpr auto &opt = plan.options[2]; // includes -> --include + + static_assert(std::string_view{opt.long_name} == "include"); + static_assert(opt.short_name == 'I'); + static_assert(opt.kind == cli::option_kind::vector); +} + +TEST(descriptors, repeatable_int_classified_as_counter) { + constexpr auto plan = cli::build(); + constexpr auto &opt = plan.options[3]; // quiet_level + + static_assert(opt.kind == cli::option_kind::counter); + static_assert(opt.short_name == 'q'); +} + +TEST(descriptors, invoke_pointers_are_non_null) { + constexpr auto plan = cli::build(); + for (const auto &opt : plan.options) { + EXPECT_NE(opt.invoke, nullptr); + } + for (const auto &pos : plan.positionals) { + EXPECT_NE(pos.invoke, nullptr); + } +} + +TEST(descriptors, bool_invoke_sets_member_true) { + Fixture opts{}; + constexpr auto plan = cli::build(); + // options[0] is verbose + bool ok = plan.options[0].invoke(&opts, ""); + EXPECT_TRUE(ok); + EXPECT_TRUE(opts.verbose); +} + +TEST(descriptors, scalar_invoke_parses_value) { + Fixture opts{}; + constexpr auto plan = cli::build(); + // options[1] is port + bool ok = plan.options[1].invoke(&opts, "9090"); + EXPECT_TRUE(ok); + EXPECT_EQ(opts.port, 9090); +} + +TEST(descriptors, vector_invoke_appends) { + Fixture opts{}; + constexpr auto plan = cli::build(); + // options[2] is includes + EXPECT_TRUE(plan.options[2].invoke(&opts, "foo.h")); + EXPECT_TRUE(plan.options[2].invoke(&opts, "bar.h")); + ASSERT_EQ(opts.includes.size(), 2u); + EXPECT_EQ(opts.includes[0], "foo.h"); + EXPECT_EQ(opts.includes[1], "bar.h"); +} + +TEST(descriptors, counter_invoke_increments) { + Fixture opts{}; + constexpr auto plan = cli::build(); + // options[3] is quiet_level + plan.options[3].invoke(&opts, ""); + plan.options[3].invoke(&opts, ""); + plan.options[3].invoke(&opts, ""); + EXPECT_EQ(opts.quiet_level, 3); +} + +TEST(descriptors, positional_invoke_parses_into_member) { + Fixture opts{}; + constexpr auto plan = cli::build(); + bool ok = plan.positionals[0].invoke(&opts, "/tmp/in.txt"); + EXPECT_TRUE(ok); + EXPECT_EQ(opts.input, "/tmp/in.txt"); +} diff --git a/tests/test_help.cpp b/tests/test_help.cpp index 6c99afd..29edafe 100644 --- a/tests/test_help.cpp +++ b/tests/test_help.cpp @@ -1,8 +1,5 @@ #include -#include #include -#include -#include namespace { @@ -22,18 +19,29 @@ struct stdout_silencer { stdout_silencer &operator=(const stdout_silencer &) = delete; }; -struct[[ = cli::program("plain"), = cli::about("Plain struct.") ]] Plain { - [[ = cli::desc("Input"), = cli::positional, = cli::required ]] std::string input; +struct + [[=cli::program("plain"), + =cli::about("Plain struct.")]] +Plain { + [[=cli::desc("Input"), + =cli::positional, + =cli::required ]] + std::string input; - [[ = cli::desc("Verbose"), = cli::short_name('v') ]] bool verbose = false; + [[=cli::desc("Verbose"), + =cli::short_name('v')]] + bool verbose = false; }; -struct[[= cli::program("foo")]] Foo { - [[ = cli::desc("Target"), = cli::positional, = cli::required ]] std::string target; +struct [[=cli::program("foo")]] Foo { + [[=cli::desc("Target"), + =cli::positional, + =cli::required]] + std::string target; }; -struct[[= cli::program("bar")]] Bar { - [[= cli::desc("Filter")]] std::string filter; +struct [[=cli::program("bar")]] Bar { + [[=cli::desc("Filter")]] std::string filter; }; using Command = std::variant; diff --git a/tests/test_parse_flags.cpp b/tests/test_parse_flags.cpp index e995ba2..c7a5650 100644 --- a/tests/test_parse_flags.cpp +++ b/tests/test_parse_flags.cpp @@ -1,4 +1,3 @@ -// Tests for bool flags, repeatable counters, and vector-typed options. #include #include @@ -10,22 +9,25 @@ namespace { -struct[[= cli::program("test")]] Opts { - [[ = cli::desc("Verbose mode"), = cli::short_name('v') ]] bool verbose = false; +struct [[= cli::program("test")]] Opts { + [[=cli::desc("Verbose mode"), + =cli::short_name('v') ]] + bool verbose = false; - [[ = cli::desc("Dry-run"), = cli::short_name('d') ]] bool dry_run = false; + [[=cli::desc("Dry-run"), + =cli::short_name('d')]] + bool dry_run = false; - [[ = cli::desc("Include directory"), - = cli::short_name('I'), - = cli::long_name("include") ]] std::vector - includes; + [[=cli::desc("Include directory"), + =cli::short_name('I'), + =cli::long_name("include")]] + std::vector includes; - [[ - = cli::desc("Verbosity counter"), - = cli::short_name('q'), - = cli::long_name("quiet"), - = cli::repeatable - ]] int quiet_level = 0; + [[=cli::desc("Verbosity counter"), + =cli::short_name('q'), + =cli::long_name("quiet"), + =cli::repeatable]] + int quiet_level = 0; }; template diff --git a/tests/test_parse_scalar.cpp b/tests/test_parse_scalar.cpp index 3d2f21b..72e9dd0 100644 --- a/tests/test_parse_scalar.cpp +++ b/tests/test_parse_scalar.cpp @@ -1,14 +1,7 @@ -// End-to-end tests for scalar parsing: positional, --long VALUE, -s VALUE, -// --long=VALUE, and default values when absent. - #include - #include -#include -#include -#include -#include + namespace { diff --git a/tests/test_subcommand.cpp b/tests/test_subcommand.cpp index 5d71e24..2e9bea0 100644 --- a/tests/test_subcommand.cpp +++ b/tests/test_subcommand.cpp @@ -1,16 +1,6 @@ -// Subcommand dispatch via std::variant: the right alternative is selected and -// populated, defaults for other alternatives are untouched. - #include - #include -#include -#include -#include -#include -#include - namespace { struct [[=cli::program("build")]] Build { From ea23593a54c0085d3a2d8efccc437679e1b854de Mon Sep 17 00:00:00 2001 From: Aaron Aranda Date: Mon, 25 May 2026 15:47:52 -0600 Subject: [PATCH 3/9] Use the descriptors for scalar dispatch --- src/cli/concepts.hpp | 3 ++ src/cli/parse.hpp | 112 ++++++++++++++++++++++--------------------- 2 files changed, 60 insertions(+), 55 deletions(-) diff --git a/src/cli/concepts.hpp b/src/cli/concepts.hpp index 72f50fe..8625b6b 100644 --- a/src/cli/concepts.hpp +++ b/src/cli/concepts.hpp @@ -28,6 +28,9 @@ concept is_optional = specialization_of; template concept is_vector = specialization_of; +template +concept is_flag = std::meta::is_same_type(^^T, bool); + /** * @concept stream_parsable * @brief A `T` is parsable as a single command-line value if it can be read diff --git a/src/cli/parse.hpp b/src/cli/parse.hpp index 359d406..837e52a 100644 --- a/src/cli/parse.hpp +++ b/src/cli/parse.hpp @@ -2,7 +2,9 @@ #include #include +#include #include +#include #include #include #include @@ -22,7 +24,7 @@ namespace cli::detail { -// Long-form match: --name (with optional --name=value handled by caller). +// match options in long form --name=value or --name value inline auto matches_long(std::string_view arg, std::string_view name) -> bool { if (!arg.starts_with("--")) { return false; @@ -35,20 +37,21 @@ inline auto matches_long(std::string_view arg, std::string_view name) -> bool { return rest == name; } -// Short-form match: -x (only single-letter form for now). +// match options in short form like -k or -k v // TODO: support clustered shorts (-abc), `-x=value`, `-xvalue`. inline auto matches_short(std::string_view arg, char ch) -> bool { return std::size(arg) == 2 && arg[0] == '-' && arg[1] == ch; } -// Pull the value bound to an option after a match: --name=value or next argv slot. -// Returns the value plus the number of additional args consumed (0 or 1). +// value bound to option after match or next arg +// also includes the number of extra args consumed (0 or 1) struct value_lookup { std::string_view value; std::size_t extra_consumed; bool found; }; +// extract value from arg inline auto extract_value(std::span args, std::size_t pos) -> value_lookup { auto arg = args[pos]; if (arg.starts_with("--")) { @@ -63,16 +66,12 @@ inline auto extract_value(std::span args, std::size_t po return {.value = {}, .extra_consumed = 0, .found = false}; } -// Skeleton single-struct parser. Many features are stubbed but the dispatch -// shape is in place so we can fill them in incrementally. +// single struct parser template auto parse_into(Opts &opts, std::string_view argv0, std::span args) -> void { constexpr auto ctx = std::meta::access_context::current(); - // ---- Pre-pass: universal --help / -h short-circuit. ---- - // (A member tagged `cli::help_flag` is the explicit form; this is the - // built-in fallback so help works even without an explicit flag.) for (auto arg : args) { if (arg == "--help" || arg == "-h") { cli::print_help(argv0); @@ -80,25 +79,56 @@ auto parse_into(Opts &opts, std::string_view argv0, std::span positionals; + // collect anything that doesn't start with a '-' + std::vector positionals{}; positionals.reserve(std::size(args)); for (auto arg : args) { if (!arg.starts_with('-') || arg == "-") { positionals.push_back(arg); } } - std::size_t positional_idx = 0; + + std::size_t positional_idx{0}; static constexpr auto members = std::define_static_array(std::meta::nonstatic_data_members_of(^^Opts, ctx)); - // ---- Per-member loop ---- + // member without us needing per-member specialization here. + // build the descriptors and call invoke on each option or positional + static constexpr auto plan = cli::build(); + for (const auto &opt : plan.options) { + if (opt.kind != option_kind::scalar) { + continue; + } + bool matched{false}; + for (std::size_t i = 0; i < std::size(args); ++i) { + if (matches_long(args[i], opt.long_name) || matches_short(args[i], opt.short_name)) { + auto vl = extract_value(args, i); + if (!vl.found) { + std::println(stderr, "Option {} is missing a value", args[i]); + std::exit(EXIT_FAILURE); + } + if (!opt.invoke(&opts, vl.value)) { + std::println(stderr, "Failed to parse `{}` value \"{}\"", args[i], vl.value); + std::exit(EXIT_FAILURE); + } + matched = true; + break; + } + } + if (!matched && opt.is_required) { + std::println(stderr, "Missing required option --{}", opt.long_name); + cli::print_help(argv0); + std::exit(EXIT_FAILURE); + } + } + + // member loop still handles bool, counter, vector, positional, help, and version template for (constexpr auto memb : members) { using T = typename[:std::meta::type_of(memb):]; constexpr cli::member_info mi{memb}; if constexpr (mi.is_help) { - // Explicit help flag — already handled by the pre-pass above. + // explicit help flag already handled above. continue; } else if constexpr (mi.is_version) { for (auto arg : args) { @@ -113,7 +143,7 @@ auto parse_into(Opts &opts, std::string_view argv0, std::span) { while (positional_idx < std::size(positionals)) { typename T::value_type tmp{}; @@ -146,7 +176,7 @@ auto parse_into(Opts &opts, std::string_view argv0, std::span) { + } else if constexpr (is_flag) { // Boolean flag: presence -> true. for (auto arg : args) { if (matches_long(arg, mi.long_name) || matches_short(arg, mi.short_char)) { @@ -155,8 +185,8 @@ auto parse_into(Opts &opts, std::string_view argv0, std::span) { - // -vvv style: count occurrences of the short form. + } else if constexpr (mi.is_repeat && std::meta::is_integral_type(^^T)) { + // -vvv style count occurrences of the short form for (auto arg : args) { if (matches_long(arg, mi.long_name)) { ++opts.[:memb:]; @@ -169,7 +199,7 @@ auto parse_into(Opts &opts, std::string_view argv0, std::span) { - // Repeatable value option: --name v1 --name v2. + // repeatable value option: --name v1 --name v2. for (std::size_t i = 0; i < std::size(args); ++i) { if (matches_long(args[i], mi.long_name) || matches_short(args[i], mi.short_char)) { auto vl = extract_value(args, i); @@ -187,51 +217,23 @@ auto parse_into(Opts &opts, std::string_view argv0, std::span(argv0); - std::exit(EXIT_FAILURE); - } - - // TODO: range / choices / validate_with enforcement. - // TODO: env-var fallback when not matched. - // TODO: on_match action invocation. } + + // TODO: range / choices / validate_with enforcement. + // TODO: env-var fallback when not matched. + // TODO: on_match action invocation. } // TODO: report unknown options not matched by any member. } -// Subcommand dispatch: pick the variant alternative whose program-name -// matches argv[1], parse the rest into it, emplace into the variant. +// handle subcommand +// pick the variant alt whose program name matches argv[1] template auto parse_subcommand(std::string_view argv0, std::span args) -> Variant { - // Top-level --help (only when it's the first token) prints the subcommand + // top-level --help (only when it's the first arg) prints the subcommand // list. After the subcommand name, --help is forwarded to the matched - // subcommand's parser so it can print its own per-command help. + // subcommand's parser so it can print its own help. if (!std::empty(args) && (args[0] == "--help" || args[0] == "-h")) { cli::print_help(argv0); std::exit(EXIT_SUCCESS); From 4b83706834583200c9446b3cc4956c36fcf36842 Mon Sep 17 00:00:00 2001 From: Aaron Aranda Date: Mon, 25 May 2026 15:48:55 -0600 Subject: [PATCH 4/9] Fix is_flag concept --- src/cli/concepts.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/concepts.hpp b/src/cli/concepts.hpp index 8625b6b..232e120 100644 --- a/src/cli/concepts.hpp +++ b/src/cli/concepts.hpp @@ -29,7 +29,7 @@ template concept is_vector = specialization_of; template -concept is_flag = std::meta::is_same_type(^^T, bool); +concept is_flag = std::meta::is_same_type(^^T, ^^bool); /** * @concept stream_parsable From f26baaa537b02c9b1f7190fd4802dad492079bf9 Mon Sep 17 00:00:00 2001 From: Aaron Aranda Date: Mon, 25 May 2026 15:59:14 -0600 Subject: [PATCH 5/9] Using option_kind for matching and descriptor's invoke for parsing --- src/cli/parse.hpp | 175 ++++++++++++++++++++++++---------------------- 1 file changed, 92 insertions(+), 83 deletions(-) diff --git a/src/cli/parse.hpp b/src/cli/parse.hpp index 837e52a..4c3c36e 100644 --- a/src/cli/parse.hpp +++ b/src/cli/parse.hpp @@ -92,58 +92,109 @@ auto parse_into(Opts &opts, std::string_view argv0, std::span(); for (const auto &opt : plan.options) { - if (opt.kind != option_kind::scalar) { - continue; - } - bool matched{false}; - for (std::size_t i = 0; i < std::size(args); ++i) { - if (matches_long(args[i], opt.long_name) || matches_short(args[i], opt.short_name)) { - auto vl = extract_value(args, i); - if (!vl.found) { - std::println(stderr, "Option {} is missing a value", args[i]); - std::exit(EXIT_FAILURE); + switch (opt.kind) { + case option_kind::help: + { + for (auto arg : args) { + if (matches_long(arg, opt.long_name) || matches_short(arg, opt.short_name)) { + cli::print_help(argv0); + std::exit(EXIT_SUCCESS); + } + } + break; } - if (!opt.invoke(&opts, vl.value)) { - std::println(stderr, "Failed to parse `{}` value \"{}\"", args[i], vl.value); - std::exit(EXIT_FAILURE); + case option_kind::version: + { + for (auto arg : args) { + if (matches_long(arg, opt.long_name) || matches_short(arg, opt.short_name)) { + std::println("{}", plan.version != nullptr ? plan.version : "(no version set)"); + std::exit(EXIT_SUCCESS); + } + } + break; + } + case option_kind::bool_flag: + { + for (auto arg : args) { + if (matches_long(arg, opt.long_name) || matches_short(arg, opt.short_name)) { + opt.invoke(&opts, ""); + break; + } + } + break; + } + case option_kind::counter: + { + for (auto arg : args) { + if (matches_long(arg, opt.long_name)) { + opt.invoke(&opts, ""); + } else if (arg.starts_with('-') && !arg.starts_with("--")) { + for (std::size_t k = 1; k < std::size(arg); ++k) { + if (arg[k] == opt.short_name) { + opt.invoke(&opts, ""); + } + } + } + } + break; + } + case option_kind::scalar: + { + bool matched{false}; + for (std::size_t i = 0; i < std::size(args); ++i) { + if (matches_long(args[i], opt.long_name) || matches_short(args[i], opt.short_name)) { + auto vl = extract_value(args, i); + if (!vl.found) { + std::println(stderr, "Option {} is missing a value", args[i]); + std::exit(EXIT_FAILURE); + } + if (!opt.invoke(&opts, vl.value)) { + std::println(stderr, "Failed to parse `{}` value \"{}\"", args[i], vl.value); + std::exit(EXIT_FAILURE); + } + matched = true; + break; + } + } + if (!matched && opt.is_required) { + std::println(stderr, "Missing required option --{}", opt.long_name); + cli::print_help(argv0); + std::exit(EXIT_FAILURE); + } + break; + } + case option_kind::vector: + { + for (std::size_t i = 0; i < std::size(args); ++i) { + if (matches_long(args[i], opt.long_name) || matches_short(args[i], opt.short_name)) { + auto vl = extract_value(args, i); + if (!vl.found) { + std::println(stderr, "Option {} is missing a value", args[i]); + std::exit(EXIT_FAILURE); + } + if (!opt.invoke(&opts, vl.value)) { + std::println(stderr, "Failed to parse `{}` value \"{}\"", args[i], vl.value); + std::exit(EXIT_FAILURE); + } + i += vl.extra_consumed; + } + } + break; } - matched = true; - break; - } - } - if (!matched && opt.is_required) { - std::println(stderr, "Missing required option --{}", opt.long_name); - cli::print_help(argv0); - std::exit(EXIT_FAILURE); } } - // member loop still handles bool, counter, vector, positional, help, and version + // Per-member loop now only handles positional arguments; the remaining + // template-for + if constexpr branch goes away in commit 5 when positionals + // also migrate to the plan. template for (constexpr auto memb : members) { using T = typename[:std::meta::type_of(memb):]; constexpr cli::member_info mi{memb}; - if constexpr (mi.is_help) { - // explicit help flag already handled above. - continue; - } else if constexpr (mi.is_version) { - for (auto arg : args) { - if (matches_long(arg, mi.long_name) || matches_short(arg, mi.short_char)) { - if constexpr (constexpr auto v = detail::get_annotation(^^Opts); v) { - std::println("{}", v->value); - } else { - std::println("(no version set)"); - } - std::exit(EXIT_SUCCESS); - } - } - continue; - } else if constexpr (mi.is_positional) { - // handle positional taking the remainder or args + if constexpr (mi.is_positional) { if constexpr (is_vector) { while (positional_idx < std::size(positionals)) { typename T::value_type tmp{}; @@ -175,48 +226,6 @@ auto parse_into(Opts &opts, std::string_view argv0, std::span) { - // Boolean flag: presence -> true. - for (auto arg : args) { - if (matches_long(arg, mi.long_name) || matches_short(arg, mi.short_char)) { - opts.[:memb:] = true; - break; - } - } - continue; - } else if constexpr (mi.is_repeat && std::meta::is_integral_type(^^T)) { - // -vvv style count occurrences of the short form - for (auto arg : args) { - if (matches_long(arg, mi.long_name)) { - ++opts.[:memb:]; - } else if (arg.starts_with('-') && !arg.starts_with("--")) { - for (std::size_t k = 1; k < std::size(arg); ++k) { - if (arg[k] == mi.short_char) - ++opts.[:memb:]; - } - } - } - continue; - } else if constexpr (is_vector) { - // repeatable value option: --name v1 --name v2. - for (std::size_t i = 0; i < std::size(args); ++i) { - if (matches_long(args[i], mi.long_name) || matches_short(args[i], mi.short_char)) { - auto vl = extract_value(args, i); - if (!vl.found) { - std::println(stderr, "Option {} is missing a value", args[i]); - std::exit(EXIT_FAILURE); - } - typename T::value_type tmp{}; - if (!stream_parse(vl.value, tmp)) { - std::println("Failed to parse `{}` value \"{}\"", args[i], vl.value); - std::exit(EXIT_FAILURE); - } - opts.[:memb:].push_back(std::move(tmp)); - i += vl.extra_consumed; - } - } - continue; } // TODO: range / choices / validate_with enforcement. From 91294d87f647cb21df20b4a1c5812a26ea58ea57 Mon Sep 17 00:00:00 2001 From: Aaron Aranda Date: Mon, 25 May 2026 17:33:51 -0600 Subject: [PATCH 6/9] Handling positionals with new descriptors --- src/cli/parse.hpp | 74 +++++++++++++++++++---------------------------- 1 file changed, 30 insertions(+), 44 deletions(-) diff --git a/src/cli/parse.hpp b/src/cli/parse.hpp index 4c3c36e..b1c5d4f 100644 --- a/src/cli/parse.hpp +++ b/src/cli/parse.hpp @@ -70,8 +70,6 @@ inline auto extract_value(std::span args, std::size_t po template auto parse_into(Opts &opts, std::string_view argv0, std::span args) -> void { - constexpr auto ctx = std::meta::access_context::current(); - for (auto arg : args) { if (arg == "--help" || arg == "-h") { cli::print_help(argv0); @@ -89,8 +87,6 @@ auto parse_into(Opts &opts, std::string_view argv0, std::span(); @@ -187,52 +183,42 @@ auto parse_into(Opts &opts, std::string_view argv0, std::span) { - while (positional_idx < std::size(positionals)) { - typename T::value_type tmp{}; - if (!stream_parse(positionals[positional_idx], tmp)) { - std::println( - stderr, - "Failed to parse positional `{}` from \"{}\"", - mi.long_name, - positionals[positional_idx]); - std::exit(EXIT_FAILURE); - } - opts.[:memb:].push_back(std::move(tmp)); - ++positional_idx; - } - } else { - if (positional_idx < std::size(positionals)) { - if (!stream_parse(positionals[positional_idx], opts.[:memb:])) { - std::println( - stderr, - "Failed to parse positional `{}` from \"{}\"", - mi.long_name, - positionals[positional_idx]); - std::exit(EXIT_FAILURE); - } - ++positional_idx; - } else if constexpr (mi.is_required) { - std::println(stderr, "Missing required positional argument `{}`", mi.long_name); - cli::print_help(argv0); + // Plan-driven dispatch for positionals. Vector positionals drain all + // remaining slots; scalar positionals take the next slot in declaration + // order. + for (const auto &pos : plan.positionals) { + if (pos.is_vector) { + while (positional_idx < std::size(positionals)) { + if (!pos.invoke(&opts, positionals[positional_idx])) { + std::println( + stderr, + "Failed to parse positional `{}` from \"{}\"", + pos.name, + positionals[positional_idx]); std::exit(EXIT_FAILURE); } + ++positional_idx; + } + } else if (positional_idx < std::size(positionals)) { + if (!pos.invoke(&opts, positionals[positional_idx])) { + std::println( + stderr, + "Failed to parse positional `{}` from \"{}\"", + pos.name, + positionals[positional_idx]); + std::exit(EXIT_FAILURE); } + ++positional_idx; + } else if (pos.is_required) { + std::println(stderr, "Missing required positional argument `{}`", pos.name); + cli::print_help(argv0); + std::exit(EXIT_FAILURE); } - - // TODO: range / choices / validate_with enforcement. - // TODO: env-var fallback when not matched. - // TODO: on_match action invocation. } + // TODO: range / choices / validate_with enforcement. + // TODO: env-var fallback when not matched. + // TODO: on_match action invocation. // TODO: report unknown options not matched by any member. } From 902baaee1af396c41ff79c0bca21c33ebf719c7b Mon Sep 17 00:00:00 2001 From: Aaron Aranda Date: Sat, 6 Jun 2026 10:38:20 -0600 Subject: [PATCH 7/9] Refactor help generation to also use compile time built parse plan. --- src/cli/help.hpp | 70 +++++++++++++++++++++++------------------------- 1 file changed, 33 insertions(+), 37 deletions(-) diff --git a/src/cli/help.hpp b/src/cli/help.hpp index 80a38b3..785a575 100644 --- a/src/cli/help.hpp +++ b/src/cli/help.hpp @@ -2,13 +2,15 @@ #include #include +#include #include +#include #include -#include #include #include #include #include +#include #include #include #include @@ -38,11 +40,10 @@ namespace cli { // Usage: | synthesized // Positional arguments: // Options: -// Subcommands: +// Commands: // template auto print_help(std::string_view argv0) -> void { - constexpr auto ctx = std::meta::access_context::current(); auto prog = detail::resolve_program_name(argv0); std::println("\n{}", prog); @@ -55,22 +56,17 @@ auto print_help(std::string_view argv0) -> void { if constexpr (constexpr auto us = detail::get_annotation(^^T); us) { std::println("Usage: {}", us->text); } else if constexpr (is_variant) { - std::println("Usage: {} [options]", prog); + std::println("Usage: {} [options]", prog); } else { std::println("Usage: {} [options]", prog); } if constexpr (is_variant) { - std::println("\nSubcommands:"); - template for (constexpr std::size_t I : [] { - std::array> arr{}; - for (std::size_t i = 0; i < arr.size(); ++i) { - arr[i] = i; - } - return arr; - }()) { - using Alt = std::variant_alternative_t; - constexpr auto alt_refl = std::meta::dealias(^^Alt); + // each variant alternative is its own subcommand + std::println("\nCommands:"); + template for (constexpr std::size_t I : std::views::iota(0uz, std::meta::variant_size(^^T))) { + constexpr auto Alt = std::meta::variant_alternative(I, ^^T); + constexpr auto alt_refl = std::meta::dealias(Alt); constexpr auto alt_program = detail::get_annotation(alt_refl); constexpr auto alt_about = detail::get_annotation(alt_refl); @@ -80,35 +76,35 @@ auto print_help(std::string_view argv0) -> void { std::println(" {:<20} {}", alt_name, alt_text); } } else { + static constexpr auto plan = cli::build(); + bool any_pos = false; - template for (constexpr auto memb : - std::define_static_array(std::meta::nonstatic_data_members_of(^^T, ctx))) { - constexpr cli::member_info mi{memb}; - if constexpr (mi.is_positional && !mi.is_hidden) { - if (!any_pos) { - std::println("\nPositional arguments:"); - any_pos = true; - } - std::println(" {:<20} {}", mi.long_name, mi.desc ? mi.desc : ""); + for (const auto &pos : plan.positionals) { + if (pos.is_hidden) { + continue; + } + if (!any_pos) { + std::println("\nPositional arguments:"); + any_pos = true; } + std::println(" {:<20} {}", pos.name, pos.desc != nullptr ? pos.desc : ""); } std::println("\nOptions:"); - template for (constexpr auto memb : - std::define_static_array(std::meta::nonstatic_data_members_of(^^T, ctx))) { - constexpr cli::member_info mi{memb}; - if constexpr (!mi.is_positional && !mi.is_hidden) { - std::string label; - label += '-'; - label += mi.short_char; - label += ", --"; - label += mi.long_name; - if (mi.value_name) { - label += ' '; - label += mi.value_name; - } - std::println(" {:<28} {}", label, mi.desc ? mi.desc : ""); + for (const auto &opt : plan.options) { + if (opt.is_hidden) { + continue; + } + std::string label; + label += '-'; + label += opt.short_name; + label += ", --"; + label += opt.long_name; + if (opt.value_name != nullptr) { + label += ' '; + label += opt.value_name; } + std::println(" {:<28} {}", label, opt.desc != nullptr ? opt.desc : ""); } } From 157b13c16ceb19064332487dee798938bf6a4028 Mon Sep 17 00:00:00 2001 From: Aaron Aranda Date: Sun, 7 Jun 2026 13:07:42 -0600 Subject: [PATCH 8/9] Simplify annotation value extraction with fallback value --- src/cli/builder.hpp | 64 +++++++++++++++++---------------------------- src/cli/detail.hpp | 41 +++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 40 deletions(-) diff --git a/src/cli/builder.hpp b/src/cli/builder.hpp index 089c398..eecc0ad 100644 --- a/src/cli/builder.hpp +++ b/src/cli/builder.hpp @@ -4,7 +4,6 @@ #include #include #include -#include #include #include #include @@ -112,6 +111,12 @@ consteval auto bind_invoke(std::meta::info memb, option_kind kind) return std::meta::extract(fn); } +// long_name annotation, falling back to the member identifier. +consteval auto resolve_long_name(std::meta::info info) -> const char * { + return get_value(info).value_or( + std::define_static_string(std::meta::identifier_of(info))); +} + } // namespace cli::detail namespace cli { @@ -132,7 +137,6 @@ consteval auto build() -> program_descriptor { std::vector positionals; for (auto memb : std::meta::nonstatic_data_members_of(^^T, ctx)) { - cli::member_info mi{memb}; auto type = std::meta::type_of(memb); // classify a per-member helper substituted with the member type. @@ -145,58 +149,38 @@ consteval auto build() -> program_descriptor { auto invoke_fn = detail::bind_invoke(memb, cls.kind); - if (mi.is_positional) { + if (detail::has_tag(memb)) { positionals.emplace_back( positional_descriptor{ - .name = mi.long_name, - .desc = mi.desc, - .is_required = mi.is_required, + .name = detail::resolve_long_name(memb), + .desc = detail::get_value(memb).value_or(nullptr), + .is_required = detail::has_tag(memb), .is_vector = cls.is_vector_member, - .is_hidden = mi.is_hidden, + .is_hidden = detail::has_tag(memb), .invoke = invoke_fn}); } else { options.emplace_back( option_descriptor{ - .long_name = mi.long_name, - .short_name = mi.short_char, - .value_name = mi.value_name, - .desc = mi.desc, - .env_var = mi.env_var, - .is_required = mi.is_required, - .is_hidden = mi.is_hidden, + .long_name = detail::resolve_long_name(memb), + .short_name = + detail::get_value(memb).value_or(std::meta::identifier_of(memb)[0]), + .value_name = detail::get_value(memb).value_or(nullptr), + .desc = detail::get_value(memb).value_or(nullptr), + .env_var = detail::get_value(memb).value_or(nullptr), + .is_required = detail::has_tag(memb), + .is_hidden = detail::has_tag(memb), .kind = cls.kind, .invoke = invoke_fn}); } } // program-level annotations - const char *prog_name = nullptr; - if (auto a = detail::get_annotation(^^T)) { - prog_name = a->value; - } - const char *prog_ver = nullptr; - if (auto a = detail::get_annotation(^^T)) { - prog_ver = a->value; - } - const char *prog_about = nullptr; - if (auto a = detail::get_annotation(^^T)) { - prog_about = a->text; - } - const char *prog_epilog = nullptr; - if (auto a = detail::get_annotation(^^T)) { - prog_epilog = a->text; - } - const char *prog_usage = nullptr; - if (auto a = detail::get_annotation(^^T)) { - prog_usage = a->text; - } - return program_descriptor{ - .name = prog_name, - .version = prog_ver, - .about = prog_about, - .epilog = prog_epilog, - .usage = prog_usage, + .name = detail::get_value(^^T).value_or(nullptr), + .version = detail::get_value(^^T).value_or(nullptr), + .about = detail::get_value(^^T).value_or(nullptr), + .epilog = detail::get_value(^^T).value_or(nullptr), + .usage = detail::get_value(^^T).value_or(nullptr), .options = std::define_static_array(options), .positionals = std::define_static_array(positionals), }; diff --git a/src/cli/detail.hpp b/src/cli/detail.hpp index 3b57482..925df31 100644 --- a/src/cli/detail.hpp +++ b/src/cli/detail.hpp @@ -28,6 +28,47 @@ consteval auto get_annotation(std::meta::info info) -> std::optional { return std::meta::extract(anns[0]); } +/** + * @brief Reflection of an annotation's single value field. + * + * Every value annotation (desc, long_name, program, ...) wraps exactly one + * data member. This returns the reflection of that member, and rejects + * annotations that don't have exactly one field at compile time. + * + * @tparam A The annotation @see{src/cli/annotations.hpp} + */ +template +consteval auto value_field() -> std::meta::info { + constexpr auto ctx = std::meta::access_context::current(); + auto members = std::meta::nonstatic_data_members_of(^^A, ctx); + if (std::size(members) != 1) { + throw std::meta::exception{"annotation must have exactly one value field", ^^A}; + } + return members[0]; +} + +/// The deduced type of annotation A's single value field. +template +using value_t = typename[:std::meta::type_of(value_field()):]; + +/** + * @brief Reads the value of annotation A on `info`, or nullopt. + * + * The return type is deduced from the annotation's field, so callers never + * name it; the fallback is chosen at the call site via `.value_or(...)`. + * + * @tparam A The annotation @see{src/cli/annotations.hpp} + * @param info + * @returns std::optional> + */ +template +consteval auto get_value(std::meta::info info) -> std::optional> { + if (auto ann = get_annotation(info)) { + return ann->[:value_field():]; + } + return std::nullopt; +} + /** * @brief Checks if a type has annotation that is Tag. * From 71c884817e7c079363bd29a3f69fe78fe93c1782 Mon Sep 17 00:00:00 2001 From: Aaron Aranda Date: Sun, 7 Jun 2026 13:13:07 -0600 Subject: [PATCH 9/9] Replace member_info with descriptor types to build a compile time parse plan. --- CHANGELOG.md | 12 ++++++ docs/guide.md | 18 +++++++++ src/cli/CMakeLists.txt | 1 - src/cli/builder.hpp | 5 +-- src/cli/cli.hpp | 1 - src/cli/help.hpp | 1 - src/cli/member_info.hpp | 82 ----------------------------------------- src/cli/parse.hpp | 5 +-- 8 files changed, 32 insertions(+), 93 deletions(-) delete mode 100644 src/cli/member_info.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d1a403..06c8548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed +- Parser rearchitected around a compile-time **parse plan**. `cli::build()` + produces a `program_descriptor` (flat `option_descriptor` / `positional_descriptor` + spans) via `std::meta::substitute` + `std::meta::extract`; `parse_into` and + `print_help` are now ordinary runtime loops over those spans instead of + `template for` + per-member `if constexpr`. User-facing API is unchanged. +- Annotation value extraction unified behind a single generic + `detail::get_value` (deduced return type, single-field checked) — replaces + the per-annotation `get_*` helper functions. - Annotation internals simplified: string-bearing annotations now hold a plain `const char*` (promoted via `std::define_static_string` in a consteval ctor) instead of the previous `cstr { std::meta::info }` wrapper. The `view_of<>` @@ -15,6 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `const char*` / `std::string_view`. User-facing syntax (`cli::desc("foo")`) is unchanged. +### Removed +- `cli/member_info.hpp` — superseded by the descriptor types built in + `cli/builder.hpp`. + ### Added - Annotation-driven option struct API: `cli::desc`, `cli::short_name`, `cli::long_name`, `cli::value_name`, `cli::env`, `cli::positional`, `cli::required`, `cli::hidden`, diff --git a/docs/guide.md b/docs/guide.md index d33de53..9b85117 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -15,6 +15,24 @@ struct [[=cli::program("greet"), =cli::about("Say hello.")]] Opts { Nothing about the parser is registered at runtime — every name, type, default, and validator is read out of the struct at compile time using reflection. +## Architecture: the compile-time parse plan + +libcli does not parse `argv` by walking your struct's members at runtime. Instead, `cli::build()` runs at compile time and produces a `program_descriptor` — a flat, reflection-free description of how to parse `T`: + +``` +build() ──► program_descriptor { + options: span // one per flag + positionals: span // one per positional + name / version / about / epilog / usage // program-level strings + } +``` + +Each `option_descriptor` carries the resolved `long_name`, `short_name`, `desc`, `kind` (bool / scalar / vector / counter / help / version), and an `invoke` function pointer. That pointer is the key trick: `cli::build` uses `std::meta::substitute` to instantiate a typed handler (e.g. `invoke_scalar`) and `std::meta::extract` to recover it as a plain `bool(*)(void*, std::string_view)`. The member type is baked into the pointer, so the runtime parser can dispatch through it without itself being a template. + +The result: `parse_into` and `print_help` are ordinary runtime functions that loop over the descriptor spans — no `template for`, no per-member `if constexpr`. The reflection all happens once, in `build()`, when the plan is constructed. (See [Brevzin's "The Power of `substitute`"](https://brevzin.github.io/c++/2026/03/02/power-of-substitute/) for the underlying technique.) + +This is what makes compile-time validation natural: because the plan is a `constexpr` value, a malformed CLI definition (duplicate short name, conflicting annotation) can be rejected with a `static_assert` or a thrown `std::meta::exception` *while building the plan*, before the program ever runs. + ## Annotation reference ### Member-level diff --git a/src/cli/CMakeLists.txt b/src/cli/CMakeLists.txt index 0c728a6..a895184 100644 --- a/src/cli/CMakeLists.txt +++ b/src/cli/CMakeLists.txt @@ -11,7 +11,6 @@ target_sources( $ $ $ - $ $ $ $) diff --git a/src/cli/builder.hpp b/src/cli/builder.hpp index eecc0ad..d07cf06 100644 --- a/src/cli/builder.hpp +++ b/src/cli/builder.hpp @@ -4,12 +4,9 @@ #include #include #include -#include -#include -#include #include #include -#include +#include #include // Compile-time construction of the parse plan for an option struct. diff --git a/src/cli/cli.hpp b/src/cli/cli.hpp index 9163305..f1e45d8 100644 --- a/src/cli/cli.hpp +++ b/src/cli/cli.hpp @@ -34,6 +34,5 @@ #include #include #include -#include #include #include diff --git a/src/cli/help.hpp b/src/cli/help.hpp index 785a575..28ed200 100644 --- a/src/cli/help.hpp +++ b/src/cli/help.hpp @@ -1,6 +1,5 @@ #pragma once -#include #include #include #include diff --git a/src/cli/member_info.hpp b/src/cli/member_info.hpp deleted file mode 100644 index 5486648..0000000 --- a/src/cli/member_info.hpp +++ /dev/null @@ -1,82 +0,0 @@ -#pragma once - -#include -#include -#include - -namespace cli { - -/** - * @struct member_info - * @brief All annotation-derived facts about one option-struct member. - * - * Computed once at compile time in the consteval constructor so the parser - * and help printer don't repeatedly call `has_tag` / `get_annotation` on the - * same member reflection. - * - * Construct from the member's reflection inside a constexpr/consteval - * context: - * - * @code{.cpp} - * template for (constexpr auto memb : nonstatic_data_members_of(^^Opts, ctx)) { - * constexpr cli::member_info mi{memb}; - * if constexpr (mi.is_required) { ... } - * std::println("--{}", mi.long_name); - * } - * @endcode - * - * Every field is a constant expression on a constexpr-constructed instance, - * so `if constexpr (mi.is_required)` works at compile time. - */ -struct member_info final { - consteval member_info(std::meta::info info) - : is_positional{detail::has_tag(info)}, - is_required{detail::has_tag(info)}, - is_hidden{detail::has_tag(info)}, - is_repeat{detail::has_tag(info)}, - is_help{detail::has_tag(info)}, - is_version{detail::has_tag(info)}, - long_name{[&] consteval -> const char * { - if (auto ln = detail::get_annotation(info)) { - return ln->name; - } - return std::define_static_string(std::meta::identifier_of(info)); - }()}, - short_char{[&] consteval -> char { - if (auto sn = detail::get_annotation(info)) { - return sn->c; - } - return std::meta::identifier_of(info)[0]; - }()}, - value_name{[&] consteval -> const char * { - auto vn = detail::get_annotation(info); - return vn ? vn->name : nullptr; - }()}, - desc{[&] consteval -> const char * { - auto d = detail::get_annotation(info); - return d ? d->text : nullptr; - }()}, - env_var{[&] consteval -> const char * { - auto e = detail::get_annotation(info); - return e ? e->name : nullptr; - }()} {} - - bool is_positional = false; - bool is_required = false; - bool is_hidden = false; - bool is_repeat = false; - bool is_help = false; - bool is_version = false; - - // Always resolved: falls back to the member identifier / its first - // character when the corresponding annotation is absent. - const char *long_name = nullptr; - char short_char = '\0'; - - // Nullable: `nullptr` means the user did not attach the annotation. - const char *value_name = nullptr; - const char *desc = nullptr; - const char *env_var = nullptr; -}; - -} // namespace cli diff --git a/src/cli/parse.hpp b/src/cli/parse.hpp index b1c5d4f..12503d1 100644 --- a/src/cli/parse.hpp +++ b/src/cli/parse.hpp @@ -1,6 +1,5 @@ #pragma once -#include #include #include #include @@ -8,7 +7,6 @@ #include #include #include -#include #include #include #include @@ -16,10 +14,9 @@ #include #include #include -#include #include #include -#include +#include #include namespace cli::detail {