Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,25 @@ 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<T>()`
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<A>` (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<>`
variable template is no longer needed — consumers read annotations as plain
`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`,
Expand Down
18 changes: 18 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>()` runs at compile time and produces a `program_descriptor` — a flat, reflection-free description of how to parse `T`:

```
build<T>() ──► program_descriptor {
options: span<option_descriptor> // one per flag
positionals: span<positional_descriptor> // 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<T, member>`) 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<T>()`, 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
Expand Down
3 changes: 2 additions & 1 deletion src/cli/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ target_sources(
INTERFACE
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/cli.hpp>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/annotations.hpp>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/builder.hpp>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/concepts.hpp>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/detail.hpp>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/error.hpp>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/help.hpp>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/member_info.hpp>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/descriptors.hpp>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/parse.hpp>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/validators.hpp>)
3 changes: 2 additions & 1 deletion src/cli/annotations.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

///@}
Expand Down
186 changes: 186 additions & 0 deletions src/cli/builder.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
#pragma once

#include <cli/annotations.hpp>
#include <cli/concepts.hpp>
#include <cli/descriptors.hpp>
#include <cli/detail.hpp>
#include <meta>
#include <string_view>
#include <utility>
#include <vector>

// Compile-time construction of the parse plan for an option struct.

// `build<T>()` 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 <class T, std::meta::info M>
auto invoke_bool(void *opts, std::string_view /*value*/) -> bool {
static_cast<T *>(opts)->[:M:] = true;
return true;
}

template <class T, std::meta::info M>
auto invoke_scalar(void *opts, std::string_view value) -> bool {
return stream_parse(value, static_cast<T *>(opts)->[:M:]);
}

template <class T, std::meta::info M>
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<T *>(opts)->[:M:]).push_back(std::move(tmp));
return true;
}

template <class T, std::meta::info M>
auto invoke_counter(void *opts, std::string_view /*value*/) -> bool {
++(static_cast<T *>(opts)->[:M:]);
return true;
}

// catches help and version which will be handled elsewhere
template <class T, std::meta::info M>
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 <class Member>
consteval auto classify_member(std::meta::info memb) -> member_classification {
if (has_tag<help_flag_tag>(memb)) {
return {.kind = option_kind::help, .is_vector_member = false};
}

if (has_tag<version_flag_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<Member>) {
return {.kind = option_kind::vector, .is_vector_member = true};
} else if constexpr (std::meta::is_integral_type(^^Member)) {
if (has_tag<repeatable_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 <class T>
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<bool (*)(void *, std::string_view)>(fn);
}

// long_name annotation, falling back to the member identifier.
consteval auto resolve_long_name(std::meta::info info) -> const char * {
return get_value<long_name>(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 <option_struct T>
consteval auto build() -> program_descriptor {
constexpr auto ctx = std::meta::access_context::current();

std::vector<option_descriptor> options;
std::vector<positional_descriptor> 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<detail::member_classification (*)(std::meta::info)>(cls_info)(memb);

auto invoke_fn = detail::bind_invoke<T>(memb, cls.kind);

if (detail::has_tag<positional_tag>(memb)) {
positionals.emplace_back(
positional_descriptor{
.name = detail::resolve_long_name(memb),
.desc = detail::get_value<desc>(memb).value_or(nullptr),
.is_required = detail::has_tag<required_tag>(memb),
.is_vector = cls.is_vector_member,
.is_hidden = detail::has_tag<hidden_tag>(memb),
.invoke = invoke_fn});
} else {
options.emplace_back(
option_descriptor{
.long_name = detail::resolve_long_name(memb),
.short_name =
detail::get_value<short_name>(memb).value_or(std::meta::identifier_of(memb)[0]),
.value_name = detail::get_value<value_name>(memb).value_or(nullptr),
.desc = detail::get_value<desc>(memb).value_or(nullptr),
.env_var = detail::get_value<env>(memb).value_or(nullptr),
.is_required = detail::has_tag<required_tag>(memb),
.is_hidden = detail::has_tag<hidden_tag>(memb),
.kind = cls.kind,
.invoke = invoke_fn});
}
}

// program-level annotations
return program_descriptor{
.name = detail::get_value<program>(^^T).value_or(nullptr),
.version = detail::get_value<version>(^^T).value_or(nullptr),
.about = detail::get_value<about>(^^T).value_or(nullptr),
.epilog = detail::get_value<epilog>(^^T).value_or(nullptr),
.usage = detail::get_value<usage>(^^T).value_or(nullptr),
.options = std::define_static_array(options),
.positionals = std::define_static_array(positionals),
};
}

} // namespace cli
3 changes: 2 additions & 1 deletion src/cli/cli.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@
// For subcommands, hand a std::variant of option structs to cli::parse.

#include <cli/annotations.hpp>
#include <cli/builder.hpp>
#include <cli/concepts.hpp>
#include <cli/descriptors.hpp>
#include <cli/detail.hpp>
#include <cli/error.hpp>
#include <cli/help.hpp>
#include <cli/member_info.hpp>
#include <cli/parse.hpp>
#include <cli/validators.hpp>
6 changes: 3 additions & 3 deletions src/cli/concepts.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@

#include <istream>
#include <meta>
#include <optional>
#include <type_traits>
#include <variant>
#include <vector>

namespace cli {

Expand All @@ -31,6 +28,9 @@ concept is_optional = specialization_of<T, std::optional>;
template <class T>
concept is_vector = specialization_of<T, std::vector>;

template <class T>
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
Expand Down
Loading
Loading