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 298452a..a895184 100644 --- a/src/cli/CMakeLists.txt +++ b/src/cli/CMakeLists.txt @@ -6,10 +6,11 @@ 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..d07cf06 --- /dev/null +++ b/src/cli/builder.hpp @@ -0,0 +1,186 @@ +#pragma once + +#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); +} + +// 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 { + +/** + * @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)) { + 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 (detail::has_tag(memb)) { + positionals.emplace_back( + positional_descriptor{ + .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 = detail::has_tag(memb), + .invoke = invoke_fn}); + } else { + options.emplace_back( + option_descriptor{ + .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 + return program_descriptor{ + .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), + }; +} + +} // namespace cli diff --git a/src/cli/cli.hpp b/src/cli/cli.hpp index 049571b..f1e45d8 100644 --- a/src/cli/cli.hpp +++ b/src/cli/cli.hpp @@ -28,10 +28,11 @@ // For subcommands, hand a std::variant of option structs to cli::parse. #include +#include #include +#include #include #include #include -#include #include #include diff --git a/src/cli/concepts.hpp b/src/cli/concepts.hpp index 1d9b0f1..232e120 100644 --- a/src/cli/concepts.hpp +++ b/src/cli/concepts.hpp @@ -2,10 +2,7 @@ #include #include -#include -#include #include -#include namespace cli { @@ -31,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/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 diff --git a/src/cli/detail.hpp b/src/cli/detail.hpp index ac798c1..925df31 100644 --- a/src/cli/detail.hpp +++ b/src/cli/detail.hpp @@ -2,29 +2,96 @@ #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 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. + * + * @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/help.hpp b/src/cli/help.hpp index 80a38b3..28ed200 100644 --- a/src/cli/help.hpp +++ b/src/cli/help.hpp @@ -1,14 +1,15 @@ #pragma once -#include #include +#include #include +#include #include -#include #include #include #include #include +#include #include #include #include @@ -38,11 +39,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 +55,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 +75,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 : ""); } } 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 e00620d..12503d1 100644 --- a/src/cli/parse.hpp +++ b/src/cli/parse.hpp @@ -1,12 +1,12 @@ #pragma once -#include #include +#include #include +#include #include #include #include -#include #include #include #include @@ -14,15 +14,14 @@ #include #include #include -#include #include #include -#include +#include #include 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 +34,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,29 +63,10 @@ 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. +// 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); @@ -93,158 +74,158 @@ 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; - static constexpr auto members = - std::define_static_array(std::meta::nonstatic_data_members_of(^^Opts, ctx)); - // ---- Per-member loop ---- - template for (constexpr auto memb : members) { - using T = typename[:std::meta::type_of(memb):]; - constexpr cli::member_info mi{memb}; + std::size_t positional_idx{0}; - if constexpr (mi.is_help) { - // Explicit help flag — already handled by the pre-pass 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)"); + // switch over option_kind for matching and use the descriptor's invoke for parsing + static constexpr auto plan = cli::build(); + for (const auto &opt : plan.options) { + 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); + } } - std::exit(EXIT_SUCCESS); + break; } - } - continue; - } else if constexpr (mi.is_positional) { - // Positional. Vector positional captures all remaining; scalar takes one. - if constexpr (is_vector) { - 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); + 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); + } } - opts.[:memb:].push_back(std::move(tmp)); - ++positional_idx; + break; } - } 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); + 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; + } } - ++positional_idx; - } else if constexpr (mi.is_required) { - std::println(stderr, "Missing required positional argument `{}`", mi.long_name); - cli::print_help(argv0); - std::exit(EXIT_FAILURE); - } - } - continue; - } else if constexpr (std::is_same_v) { - // 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::is_integral_v) { - // -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:]; + 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; } - } - 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); + 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; + } } - typename T::value_type tmp{}; - if (!stream_parse(vl.value, tmp)) { - std::println("Failed to parse `{}` value \"{}\"", args[i], vl.value); + if (!matched && opt.is_required) { + std::println(stderr, "Missing required option --{}", opt.long_name); + cli::print_help(argv0); std::exit(EXIT_FAILURE); } - opts.[:memb:].push_back(std::move(tmp)); - i += vl.extra_consumed; + break; } - } - continue; - } else { - // Generic single-value option. - bool matched = false; - 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); - } - if (!stream_parse(vl.value, opts.[:memb:])) { - std::println( - stderr, - "Failed to parse `{}` value \"{}\" as {}", - args[i], - vl.value, - std::meta::display_string_of(^^T)); - std::exit(EXIT_FAILURE); + 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; + } } - matched = true; break; } + } + } + + // 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; } - if (!matched && mi.is_required) { - std::println(stderr, "Missing required option --{}", mi.long_name); - cli::print_help(argv0); + } 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); } - - // TODO: range / choices / validate_with enforcement. - // TODO: env-var fallback when not matched. - // TODO: on_match action invocation. + ++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: 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); 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 {