From 98f2ab141f5115c78cec2a4eb12709aa4a4cddc0 Mon Sep 17 00:00:00 2001 From: gen740 <54583542+gen740@users.noreply.github.com> Date: Tue, 12 May 2026 13:05:24 +0900 Subject: [PATCH 1/6] Add relation metadata and hidden help fields --- include/cli/argument.hh | 362 ++++------------------------- include/cli/argument_base.hh | 69 ++++++ include/cli/argument_validation.hh | 315 +++++++++++++++++++++++++ include/cli/help.hh | 182 ++++++++++++--- include/cli/parser.hh | 112 +++++++++ include/cli/relation.hh | 87 +++++++ tests/test_help.cc | 60 +++++ tests/test_parse.cc | 85 +++++++ 8 files changed, 920 insertions(+), 352 deletions(-) create mode 100644 include/cli/argument_base.hh create mode 100644 include/cli/argument_validation.hh create mode 100644 include/cli/relation.hh diff --git a/include/cli/argument.hh b/include/cli/argument.hh index 19d1990..be8dee3 100644 --- a/include/cli/argument.hh +++ b/include/cli/argument.hh @@ -9,332 +9,14 @@ #include #include "cli/action.hh" +#include "cli/argument_base.hh" +#include "cli/argument_validation.hh" #include "cli/meta.hh" +#include "cli/relation.hh" #include "cli/string_literal.hh" namespace cli { -/** @brief Base tag for all members that may appear in an argument specification - * struct. */ -struct SpecMemberTag {}; - -/** @brief Tag base class for command-line option/flag fields. */ -struct OptionTag : SpecMemberTag {}; - -/** @brief Tag base class for positional argument fields. */ -struct PositionalTag : SpecMemberTag {}; - -/** @brief Tag base class for subcommand fields. */ -struct CommandTag : SpecMemberTag {}; - -/** @brief Tag base class for the `Description` field that provides the CLI's - * help text. */ -struct DescriptionTag : SpecMemberTag {}; - -/** - * @brief Specifies the minimum and maximum number of values an option or - * positional accepts. - * - * The default `{0, -1}` is equivalent to `nargs::zero_or_more`. - * - * **Valid ranges:** - * - `min >= 0` - * - `max >= 1`, or `max == -1` which means *unlimited* (no upper bound) - * - `min <= max` (when `max != -1`) - * - * Prefer the predefined constants in the `cli::nargs` namespace over - * constructing `Nargs` directly. - */ -struct Nargs { - int min = 0; ///< Minimum number of values (>= 0). - int max = -1; ///< Maximum number of values; -1 means unlimited. -}; - -/** - * @brief Controls whether an option or positional argument is mandatory. - * - * When `required`, the parser returns `ErrorCode::missing_required` if the - * argument is absent. When `optional` (the default), absence is allowed. - */ -enum class Presence { required, optional }; - -/** @brief Convenience constant for `Presence::required`. */ -inline constexpr auto required = Presence::required; - -/** @brief Convenience constant for `Presence::optional`. */ -inline constexpr auto optional = Presence::optional; - -/** - * @brief Predefined `Nargs` constants for common value-count patterns. - */ -namespace nargs { - -/** @brief Accepts no values; used for boolean flags. `{0, 0}` */ -inline constexpr Nargs none{.min = 0, .max = 0}; - -/** @brief Accepts exactly one value. `{1, 1}` */ -inline constexpr Nargs one{.min = 1, .max = 1}; - -/** @brief Accepts zero or one value. `{0, 1}` */ -inline constexpr Nargs zero_or_one{.min = 0, .max = 1}; - -/** @brief Accepts zero or more values. `{0, -1}` */ -inline constexpr Nargs zero_or_more{.min = 0, .max = -1}; - -/** @brief Accepts one or more values. `{1, -1}` */ -inline constexpr Nargs one_or_more{.min = 1, .max = -1}; - -/** - * @brief Accepts exactly `N` values. - * @tparam N The required number of values. - */ -template -inline constexpr Nargs exactly{.min = N, .max = N}; - -/** - * @brief Accepts between `Min` and `Max` values (inclusive). - * @tparam Min Minimum number of values. - * @tparam Max Maximum number of values. - */ -template -inline constexpr Nargs between{.min = Min, .max = Max}; - -} // namespace nargs - -namespace detail { - -/** - * @brief Checks that every member of aggregate type `T` derives from - * `SpecMemberTag`. - * - * @tparam T The argument specification type to validate. - * @return `true` if all members inherit from `SpecMemberTag`. - */ -template -consteval auto members_are_derived_from_valid_cli_class() -> bool { - return [](std::type_identity>) consteval - -> auto { - return (std::derived_from, SpecMemberTag> && ...); - }(std::type_identity< - std::remove_cvref_t()))>>{}); -} - -/** - * @brief Checks that all `OptionTag` members of `T` have distinct long names. - * - * @tparam T The argument specification type to validate. - * @return `true` if no two options share the same long name. - */ -template -consteval auto options_have_unique_long_name() -> bool { - std::vector names; - [&](std::type_identity>) consteval - -> auto { - ( - [&]() consteval -> auto { - if constexpr (std::derived_from, - OptionTag>) { - names.push_back(std::remove_cvref_t::name.view()); - } - }(), - ...); - }(std::type_identity< - std::remove_cvref_t()))>>{}); - std::ranges::sort(names); - return std::ranges::adjacent_find(names) == names.end(); -} - -/** - * @brief Checks that all `OptionTag` members of `T` have distinct short names. - * - * Options with no short name (`'\0'`) are excluded from the uniqueness check. - * - * @tparam T The argument specification type to validate. - * @return `true` if no two options share the same non-null short name. - */ -template -consteval auto options_have_unique_short_name() -> bool { - std::vector names; - [&](std::type_identity>) consteval - -> auto { - ( - [&]() consteval -> auto { - if constexpr (std::derived_from, - OptionTag>) { - if constexpr (std::remove_cvref_t::short_name != '\0') { - names.push_back(std::remove_cvref_t::short_name); - } - } - }(), - ...); - }(std::type_identity< - std::remove_cvref_t()))>>{}); - std::ranges::sort(names); - return std::ranges::adjacent_find(names) == names.end(); -} - -/** - * @brief Checks that all `CommandTag` members of `T` have distinct names. - * - * @tparam T The argument specification type to validate. - * @return `true` if no two subcommands share the same name. - */ -template -consteval auto commands_have_unique_long_name() -> bool { - std::vector names; - [&](std::type_identity>) consteval - -> auto { - ( - [&]() consteval -> auto { - if constexpr (std::derived_from, - CommandTag>) { - names.push_back(std::remove_cvref_t::name.view()); - } - }(), - ...); - }(std::type_identity< - std::remove_cvref_t()))>>{}); - std::ranges::sort(names); - return std::ranges::adjacent_find(names) == names.end(); -} - -/** - * @brief Checks that any variadic positional appears after all fixed - * positionals. - * - * A variadic positional is one where `nargs.min != nargs.max`. The constraint - * ensures that token dispatch is unambiguous. - * - * @tparam T The argument specification type to validate. - * @return `true` if no fixed positional follows a variadic positional. - */ -template -consteval auto positionals_have_variadic_at_end() { - bool found_variadic = false; - bool found_positional_after_variadic = false; - [&](std::type_identity>) consteval - -> auto { - ( - [&]() consteval -> auto { - if constexpr (std::derived_from, - PositionalTag>) { - if (found_variadic) { - found_positional_after_variadic = true; - } - if (std::remove_cvref_t::nargs.max != - std::remove_cvref_t::nargs.min) { - found_variadic = true; - } - } - }(), - ...); - }(std::type_identity< - std::remove_cvref_t()))>>{}); - return !found_positional_after_variadic; -} - -/** - * @brief Validates the syntax of a long option name at compile time. - * - * Rules: - * - Must be non-empty. - * - Must start with a lowercase ASCII letter. - * - May contain lowercase letters, digits, and single hyphens. - * - Must not contain consecutive hyphens or end with a hyphen. - * - * @tparam Name The option name to validate (without the `--` prefix). - * @return `true` if `Name` is a valid long option name. - */ -template -[[nodiscard]] -constexpr auto is_valid_long_option_name() noexcept -> bool { - constexpr auto size = Name.size(); - - if constexpr (size == 0) { - return false; - } - auto is_alpha = [](char c) constexpr -> bool { - return ('a' <= c && c <= 'z'); - }; - auto is_digit = [](char c) constexpr -> bool { return '0' <= c && c <= '9'; }; - auto is_alnum = [&](char c) constexpr -> bool { - return is_alpha(c) || is_digit(c); - }; - - if (!is_alpha(Name[0])) { - return false; - } - bool previous_is_hyphen = false; - for (std::size_t i = 1; i < size; ++i) { - const char c = Name[i]; - if (c == '-') { - if (previous_is_hyphen) { - return false; - } - previous_is_hyphen = true; - continue; - } - if (!is_alnum(c)) { - return false; - } - previous_is_hyphen = false; - } - - return !previous_is_hyphen; -} - -/** - * @brief Validates a short option name character at compile time. - * - * Accepts ASCII letters (upper or lower case) or `'\0'` (meaning no short name). - * - * @param Name The short option character to validate. - * @return `true` if `Name` is a valid short option name. - */ -constexpr auto is_valid_short_option_name(char Name) noexcept -> bool { - return ('a' <= Name && Name <= 'z') || ('A' <= Name && Name <= 'Z') || - Name == '\0'; -} - -/** - * @brief Validates the syntax of a subcommand name at compile time. - * - * Applies the same rules as `is_valid_long_option_name`. - * - * @tparam Name The command name to validate. - * @return `true` if `Name` is a valid command name. - */ -template -[[nodiscard]] -consteval auto is_valid_command_name() noexcept { - return is_valid_long_option_name(); -} - -/** - * @brief Validates that a `Nargs` value satisfies the basic range constraints. - * - * Checks that `min >= 0` and that `max` is either -1 (unlimited) or - * satisfies `max >= min`. The `ArgImpl` and `PositionalImpl` template - * `requires` clauses enforce these same constraints at the point of - * instantiation. - * - * @tparam NargsValue The `Nargs` value to validate. - * @return `true` if `NargsValue` is a well-formed nargs specification. - */ -template -[[nodiscard]] -consteval auto is_valid_nargs() noexcept -> bool { - if (NargsValue.min < 0) { - return false; - } - if (NargsValue.max != -1 && NargsValue.max < NargsValue.min) { - return false; - } - return true; -} - -} // namespace detail - /** * @brief Concept that validates an aggregate type as a well-formed CLI argument * specification. @@ -358,6 +40,7 @@ concept ArgumentSpec = requires { requires detail::options_have_unique_short_name(); requires detail::commands_have_unique_long_name(); requires detail::positionals_have_variadic_at_end(); + requires detail::relations_are_well_formed(); }; template @@ -388,6 +71,9 @@ struct ArgParameter { on_parse{}; ///< Callback invoked once after parsing completes. std::string_view env{}; ///< Environment variable name to fall back to when ///< the option is absent from the command line. + bool hidden{}; ///< Whether this argument is omitted from generated help. + std::string_view + deprecated{}; ///< Optional deprecation message shown in generated help. }; namespace detail { @@ -461,6 +147,15 @@ struct ArgImpl : public OptionTag { [[nodiscard]] auto help_text() const -> const std::string_view { return param_.help; } + [[nodiscard]] auto hidden() const -> bool { return param_.hidden; } + [[nodiscard]] auto deprecated() const -> std::string_view { + return param_.deprecated; + } + [[nodiscard]] auto presence() const -> Presence { return param_.presence; } + [[nodiscard]] auto env() const -> std::string_view { return param_.env; } + [[nodiscard]] auto default_value() const -> const std::optional& { + return param_.default_value; + } protected: static constexpr bool entry_is_optional = @@ -613,6 +308,15 @@ struct PositionalImpl : public PositionalTag { [[nodiscard]] auto help_text() const -> const std::string_view { return param_.help; } + [[nodiscard]] auto hidden() const -> bool { return param_.hidden; } + [[nodiscard]] auto deprecated() const -> std::string_view { + return param_.deprecated; + } + [[nodiscard]] auto presence() const -> Presence { return param_.presence; } + [[nodiscard]] auto env() const -> std::string_view { return param_.env; } + [[nodiscard]] auto default_value() const -> const std::optional& { + return param_.default_value; + } protected: [[nodiscard]] auto get_param() const -> const ArgParameter& { @@ -678,7 +382,10 @@ struct Description : public std::string, DescriptionTag {}; */ struct CommandParameter { std::string_view - help{}; ///< Brief description shown in the parent command's help. + help{}; ///< Brief description shown in the parent command's help. + bool hidden{}; ///< Whether this command is omitted from generated help. + std::string_view + deprecated{}; ///< Optional deprecation message shown in generated help. }; /** @@ -699,17 +406,26 @@ struct Command : public T, public CommandTag { using argument_type = T; Command() = default; - Command(CommandParameter param) : help_(param.help) {} + Command(CommandParameter param) + : help_(param.help), + hidden_(param.hidden), + deprecated_(param.deprecated) {} static constexpr auto name = Name; static constexpr auto command_name() -> std::string_view { return Name.view(); } [[nodiscard]] auto provided() const -> bool { return provided_; } [[nodiscard]] auto help_text() const -> std::string_view { return help_; } + [[nodiscard]] auto hidden() const -> bool { return hidden_; } + [[nodiscard]] auto deprecated() const -> std::string_view { + return deprecated_; + } auto mark_provided() -> void { provided_ = true; } private: std::string_view help_{}; + bool hidden_{}; + std::string_view deprecated_{}; bool provided_{}; }; diff --git a/include/cli/argument_base.hh b/include/cli/argument_base.hh new file mode 100644 index 0000000..06d5484 --- /dev/null +++ b/include/cli/argument_base.hh @@ -0,0 +1,69 @@ +#pragma once + +namespace cli { + +/** @brief Base tag for all members that may appear in an argument specification + * struct. */ +struct SpecMemberTag {}; + +/** @brief Tag base class for command-line option/flag fields. */ +struct OptionTag : SpecMemberTag {}; + +/** @brief Tag base class for positional argument fields. */ +struct PositionalTag : SpecMemberTag {}; + +/** @brief Tag base class for subcommand fields. */ +struct CommandTag : SpecMemberTag {}; + +/** @brief Tag base class for the `Description` field that provides the CLI's + * help text. */ +struct DescriptionTag : SpecMemberTag {}; + +/** + * @brief Specifies the minimum and maximum number of values an option or + * positional accepts. + * + * The default `{0, -1}` is equivalent to `nargs::zero_or_more`. + */ +struct Nargs { + int min = 0; ///< Minimum number of values (>= 0). + int max = -1; ///< Maximum number of values; -1 means unlimited. +}; + +/** + * @brief Controls whether an option or positional argument is mandatory. + */ +enum class Presence { required, optional }; + +/** @brief Convenience constant for `Presence::required`. */ +inline constexpr auto required = Presence::required; + +/** @brief Convenience constant for `Presence::optional`. */ +inline constexpr auto optional = Presence::optional; + +namespace nargs { + +/** @brief Accepts no values; used for boolean flags. `{0, 0}` */ +inline constexpr Nargs none{.min = 0, .max = 0}; + +/** @brief Accepts exactly one value. `{1, 1}` */ +inline constexpr Nargs one{.min = 1, .max = 1}; + +/** @brief Accepts zero or one value. `{0, 1}` */ +inline constexpr Nargs zero_or_one{.min = 0, .max = 1}; + +/** @brief Accepts zero or more values. `{0, -1}` */ +inline constexpr Nargs zero_or_more{.min = 0, .max = -1}; + +/** @brief Accepts one or more values. `{1, -1}` */ +inline constexpr Nargs one_or_more{.min = 1, .max = -1}; + +template +inline constexpr Nargs exactly{.min = N, .max = N}; + +template +inline constexpr Nargs between{.min = Min, .max = Max}; + +} // namespace nargs + +} // namespace cli diff --git a/include/cli/argument_validation.hh b/include/cli/argument_validation.hh new file mode 100644 index 0000000..58a0424 --- /dev/null +++ b/include/cli/argument_validation.hh @@ -0,0 +1,315 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "cli/argument_base.hh" +#include "cli/meta.hh" +#include "cli/relation.hh" +#include "cli/string_literal.hh" + +namespace cli::detail { + +template +consteval auto members_are_derived_from_valid_cli_class() -> bool { + return [](std::type_identity>) consteval + -> auto { + return (std::derived_from, SpecMemberTag> && ...); + }(std::type_identity< + std::remove_cvref_t()))>>{}); +} + +template +consteval auto options_have_unique_long_name() -> bool { + std::vector names; + [&](std::type_identity>) consteval + -> auto { + ( + [&]() consteval -> auto { + if constexpr (std::derived_from, + OptionTag>) { + names.push_back(std::remove_cvref_t::name.view()); + } + }(), + ...); + }(std::type_identity< + std::remove_cvref_t()))>>{}); + std::ranges::sort(names); + return std::ranges::adjacent_find(names) == names.end(); +} + +template +consteval auto options_have_unique_short_name() -> bool { + std::vector names; + [&](std::type_identity>) consteval + -> auto { + ( + [&]() consteval -> auto { + if constexpr (std::derived_from, + OptionTag>) { + if constexpr (std::remove_cvref_t::short_name != '\0') { + names.push_back(std::remove_cvref_t::short_name); + } + } + }(), + ...); + }(std::type_identity< + std::remove_cvref_t()))>>{}); + std::ranges::sort(names); + return std::ranges::adjacent_find(names) == names.end(); +} + +template +consteval auto commands_have_unique_long_name() -> bool { + std::vector names; + [&](std::type_identity>) consteval + -> auto { + ( + [&]() consteval -> auto { + if constexpr (std::derived_from, + CommandTag>) { + names.push_back(std::remove_cvref_t::name.view()); + } + }(), + ...); + }(std::type_identity< + std::remove_cvref_t()))>>{}); + std::ranges::sort(names); + return std::ranges::adjacent_find(names) == names.end(); +} + +template +consteval auto positionals_have_variadic_at_end() { + bool found_variadic = false; + bool found_positional_after_variadic = false; + [&](std::type_identity>) consteval + -> auto { + ( + [&]() consteval -> auto { + if constexpr (std::derived_from, + PositionalTag>) { + if (found_variadic) { + found_positional_after_variadic = true; + } + if (std::remove_cvref_t::nargs.max != + std::remove_cvref_t::nargs.min) { + found_variadic = true; + } + } + }(), + ...); + }(std::type_identity< + std::remove_cvref_t()))>>{}); + return !found_positional_after_variadic; +} + +template +concept HasRelations = requires { + { T::relations } -> RelationSetLike; +}; + +template +consteval auto relation_target_name() -> std::string_view { + using F = std::remove_cvref_t; + if constexpr (std::derived_from || + std::derived_from) { + return F::name.view(); + } else { + return {}; + } +} + +template +consteval auto named_relation_targets() { + return [&](std::type_identity>) consteval + -> auto { + return std::array{ + relation_target_name()...}; + }(std::type_identity< + std::remove_cvref_t()))>>{}); +} + +template +consteval auto contains_name(const Array& names, std::string_view name) -> bool { + for (auto candidate : names) { + if (!candidate.empty() && candidate == name) return true; + } + return false; +} + +template +consteval auto group_name_exists_before(std::string_view name) -> bool { + if constexpr (I >= Limit) { + return false; + } else { + constexpr auto rel = std::get(T::relations.items); + using R = std::remove_cvref_t; + if constexpr (std::same_as) { + if (rel.name == name) return true; + } + return group_name_exists_before(name); + } +} + +template +consteval auto group_name_exists(std::string_view name) -> bool { + constexpr auto size = + std::tuple_size_v>; + return group_name_exists_before(name); +} + +template +consteval auto operand_exists(std::string_view name) -> bool { + constexpr auto field_names = named_relation_targets(); + return contains_name(field_names, name) || group_name_exists(name); +} + +template +consteval auto relation_membership_count(std::string_view name) -> std::size_t { + constexpr auto size = + std::tuple_size_v>; + if constexpr (I >= size) { + return 0; + } else { + constexpr auto rel = std::get(T::relations.items); + using R = std::remove_cvref_t; + std::size_t current = 0; + if constexpr (std::same_as) { + for (auto member : rel.group) { + if (member == name) ++current; + } + } else if constexpr (std::same_as) { + if (rel.left == name) ++current; + if (rel.right == name) ++current; + } else if constexpr (std::same_as) { + if (rel.source == name) ++current; + if (rel.target == name) ++current; + } + return current + relation_membership_count(name); + } +} + +template +consteval auto field_relation_memberships_are_unique() -> bool { + constexpr auto field_names = named_relation_targets(); + for (auto name : field_names) { + if (!name.empty() && relation_membership_count(name) > 1) { + return false; + } + } + return true; +} + +template +consteval auto relation_items_are_well_formed() -> bool { + constexpr auto size = + std::tuple_size_v>; + if constexpr (I >= size) { + return true; + } else { + constexpr auto rel = std::get(T::relations.items); + using R = std::remove_cvref_t; + constexpr auto field_names = named_relation_targets(); + if constexpr (std::same_as) { + if (rel.name.empty() || rel.group.size < 2 || + contains_name(field_names, rel.name) || + group_name_exists_before(rel.name)) { + return false; + } + for (auto member : rel.group) { + if (!contains_name(field_names, member)) return false; + } + } else if constexpr (std::same_as) { + if (rel.left.empty() || rel.right.empty() || rel.left == rel.right || + !operand_exists(rel.left) || !operand_exists(rel.right)) { + return false; + } + } else if constexpr (std::same_as) { + if (rel.source.empty() || rel.target.empty() || rel.source == rel.target || + !operand_exists(rel.source) || !operand_exists(rel.target)) { + return false; + } + } else { + return false; + } + return relation_items_are_well_formed(); + } +} + +template +consteval auto relations_are_well_formed() -> bool { + if constexpr (!HasRelations) { + return true; + } else { + return relation_items_are_well_formed() && + field_relation_memberships_are_unique(); + } +} + +template +[[nodiscard]] +constexpr auto is_valid_long_option_name() noexcept -> bool { + constexpr auto size = Name.size(); + + if constexpr (size == 0) { + return false; + } + auto is_alpha = [](char c) constexpr -> bool { + return ('a' <= c && c <= 'z'); + }; + auto is_digit = [](char c) constexpr -> bool { return '0' <= c && c <= '9'; }; + auto is_alnum = [&](char c) constexpr -> bool { + return is_alpha(c) || is_digit(c); + }; + + if (!is_alpha(Name[0])) { + return false; + } + bool previous_is_hyphen = false; + for (std::size_t i = 1; i < size; ++i) { + const char c = Name[i]; + if (c == '-') { + if (previous_is_hyphen) { + return false; + } + previous_is_hyphen = true; + continue; + } + if (!is_alnum(c)) { + return false; + } + previous_is_hyphen = false; + } + + return !previous_is_hyphen; +} + +constexpr auto is_valid_short_option_name(char Name) noexcept -> bool { + return ('a' <= Name && Name <= 'z') || ('A' <= Name && Name <= 'Z') || + Name == '\0'; +} + +template +[[nodiscard]] +consteval auto is_valid_command_name() noexcept { + return is_valid_long_option_name(); +} + +template +[[nodiscard]] +consteval auto is_valid_nargs() noexcept -> bool { + if (NargsValue.min < 0) { + return false; + } + if (NargsValue.max != -1 && NargsValue.max < NargsValue.min) { + return false; + } + return true; +} + +} // namespace cli::detail diff --git a/include/cli/help.hh b/include/cli/help.hh index cac02e2..28e438a 100644 --- a/include/cli/help.hh +++ b/include/cli/help.hh @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include #include @@ -152,11 +154,11 @@ auto command_usage_suffix(T& value) -> std::string { [&]() -> auto { using F = std::remove_cvref_t; if constexpr (std::derived_from) { - has_options = true; + if (!fields.hidden()) has_options = true; } else if constexpr (std::derived_from) { - has_positionals = true; + if (!fields.hidden()) has_positionals = true; } else if constexpr (std::derived_from) { - has_commands = true; + if (!fields.hidden()) has_commands = true; } }(), ...); @@ -339,13 +341,75 @@ auto append_section(std::string& out, std::string_view title, * @return The formatted row string. */ template -auto render_help_row(Field& field, std::string_view label, std::size_t width) +auto help_metadata(Field& field) -> std::vector { + std::vector metadata; + if constexpr (std::derived_from, OptionTag> || + std::derived_from, PositionalTag>) { + if (field.presence() == Presence::required) { + metadata.emplace_back("required"); + } + if (!field.env().empty()) { + metadata.emplace_back(std::format("env: {}", field.env())); + } + if (const auto& default_value = field.default_value()) { + using Value = std::remove_cvref_t; + if constexpr (requires(std::ostream& os, const Value& value) { + os << value; + }) { + std::ostringstream out; + out << *default_value; + metadata.emplace_back(std::format("default: {}", out.str())); + } + } + } + if (!field.deprecated().empty()) { + metadata.emplace_back(std::format("deprecated: {}", field.deprecated())); + } + return metadata; +} + +inline auto append_help_metadata(std::string& out, + const std::vector& metadata) + -> void { + if (metadata.empty()) { + return; + } + out += " ["; + for (std::size_t i = 0; i < metadata.size(); ++i) { + if (i != 0) { + out += "] ["; + } + out += metadata[i]; + } + out += "]"; +} + +inline auto relation_name_list(NameList<> names) -> std::string { + std::string out; + for (std::size_t i = 0; i < names.size; ++i) { + if (i != 0) out += ", "; + out += "--"; + out += names.values[i]; + } + return out; +} + +template +auto render_help_row(Field& field, std::string_view label, std::size_t width, + std::string_view option_color, std::string_view reset_color) -> std::string { std::string out = " "; + out += option_color; out += label; - if (!field.help_text().empty()) { + out += reset_color; + const auto metadata = help_metadata(field); + if (!field.help_text().empty() || !metadata.empty()) { out.append(width - label.size() + 2, ' '); - append_wrapped_description(out, field.help_text(), width + 4); + if (!field.help_text().empty()) { + out += field.help_text(); + } + append_help_metadata(out, metadata); + out += '\n'; } else { out += '\n'; } @@ -430,9 +494,9 @@ auto format_help_impl(T& value, std::string_view program_name, out += '\n'; } - std::vector> option_rows; - std::vector> positional_rows; - std::vector> command_rows; + std::vector> option_rows; + std::vector> positional_rows; + std::vector> command_rows; std::size_t option_width = 0; std::size_t positional_width = 0; std::size_t command_width = 0; @@ -443,18 +507,20 @@ auto format_help_impl(T& value, std::string_view program_name, [&]() -> auto { using F = std::remove_cvref_t; if constexpr (std::derived_from) { + if (fields.hidden()) return; auto label = field_usage_label(); option_width = std::max(option_width, label.size()); - option_rows.emplace_back(std::move(label), fields.help_text()); + option_rows.emplace_back(std::move(label), std::string{}); } else if constexpr (std::derived_from) { + if (fields.hidden()) return; auto label = field_usage_label(); positional_width = std::max(positional_width, label.size()); - positional_rows.emplace_back(std::move(label), - fields.help_text()); + positional_rows.emplace_back(std::move(label), std::string{}); } else if constexpr (std::derived_from) { + if (fields.hidden()) return; auto label = field_usage_label(); command_width = std::max(command_width, label.size()); - command_rows.emplace_back(std::move(label), fields.help_text()); + command_rows.emplace_back(std::move(label), std::string{}); } }(), ...); @@ -463,8 +529,7 @@ auto format_help_impl(T& value, std::string_view program_name, auto append_rows = [&](std::string_view title, - const std::vector>& rows, - std::size_t width) -> auto { + const std::vector>& rows) -> auto { if (rows.empty()) { return; } @@ -473,23 +538,81 @@ auto format_help_impl(T& value, std::string_view program_name, out += title; out += reset; out += '\n'; - for (const auto& [label, description] : rows) { - out += " "; - out += option_color; - out += label; - out += reset; - if (!description.empty()) { - out.append(width - label.size() + 2, ' '); - append_wrapped_description(out, description, width + 4); - } else { - out += '\n'; - } + for (const auto& [label, row] : rows) { + (void)label; + out += row; } }; - append_rows("Options:", option_rows, option_width); - append_rows("Positional arguments:", positional_rows, positional_width); - append_rows("Commands:", command_rows, command_width); + option_rows.clear(); + positional_rows.clear(); + command_rows.clear(); + std::apply( + [&](auto&... fields) -> auto { + ( + [&]() -> auto { + using F = std::remove_cvref_t; + if constexpr (std::derived_from) { + if (fields.hidden()) return; + auto label = field_usage_label(); + option_rows.emplace_back( + label, render_help_row(fields, label, option_width, + option_color, reset)); + } else if constexpr (std::derived_from) { + if (fields.hidden()) return; + auto label = field_usage_label(); + positional_rows.emplace_back( + label, render_help_row(fields, label, positional_width, + option_color, reset)); + } else if constexpr (std::derived_from) { + if (fields.hidden()) return; + auto label = field_usage_label(); + command_rows.emplace_back( + label, render_help_row(fields, label, command_width, + option_color, reset)); + } + }(), + ...); + }, + as_tuple(value)); + + append_rows("Options:", option_rows); + append_rows("Positional arguments:", positional_rows); + append_rows("Commands:", command_rows); + + if constexpr (detail::HasRelations) { + std::vector relation_rows; + std::apply( + [&](auto... rels) -> auto { + ( + [&]() -> auto { + using R = std::remove_cvref_t; + if constexpr (std::same_as) { + relation_rows.push_back( + std::format(" {}: {} must be provided together\n", + rels.name, relation_name_list(rels.group))); + } else if constexpr (std::same_as) { + relation_rows.push_back(std::format( + " --{} conflicts with --{}\n", rels.left, rels.right)); + } else if constexpr (std::same_as) { + relation_rows.push_back(std::format(" --{} depends on --{}\n", + rels.source, rels.target)); + } + }(), + ...); + }, + T::relations.items); + if (!relation_rows.empty()) { + out += '\n'; + out += heading; + out += "Relations:"; + out += reset; + out += '\n'; + for (const auto& row : relation_rows) { + out += row; + } + } + } if (recurse && !command_rows.empty()) { std::apply( @@ -498,6 +621,7 @@ auto format_help_impl(T& value, std::string_view program_name, [&]() -> auto { using F = std::remove_cvref_t; if constexpr (std::derived_from) { + if (fields.hidden()) return; out += '\n'; out += format_help_impl( static_cast(fields), diff --git a/include/cli/parser.hh b/include/cli/parser.hh index 3c8896c..3bfa7f0 100644 --- a/include/cli/parser.hh +++ b/include/cli/parser.hh @@ -751,6 +751,11 @@ struct Parser { return; } + if (auto err = validate_relations(result.value); err.has_error()) { + result.error = std::move(err.error); + return; + } + if constexpr (requires(T& t) { { t.constraints() } -> std::same_as; }) { @@ -984,6 +989,113 @@ struct Parser { return res; } + static auto relation_name_list(NameList<> names) -> std::string { + std::string out; + for (std::size_t i = 0; i < names.size; ++i) { + if (i != 0) out += ", "; + out += "--"; + out += names.values[i]; + } + return out; + } + + static auto relation_operand_present(T& val, std::string_view name) -> bool { + bool found_field = false; + bool present = false; + for_each_field(val, [&](auto& f) -> auto { + using F = std::remove_cvref_t; + if constexpr (std::derived_from || + std::derived_from) { + if (!found_field && F::name.view() == name) { + found_field = true; + present = f.provided(); + } + } + }); + if (found_field) return present; + + if constexpr (detail::HasRelations) { + bool found_group = false; + bool group_present = false; + std::apply( + [&](auto... rels) -> auto { + ( + [&]() -> auto { + using R = std::remove_cvref_t; + if constexpr (std::same_as) { + if (!found_group && rels.name == name) { + found_group = true; + for (auto member : rels.group) { + group_present = group_present || + relation_operand_present(val, member); + } + } + } + }(), + ...); + }, + T::relations.items); + return group_present; + } + return false; + } + + static auto validate_relations(T& val) -> ActionResult { + if constexpr (!detail::HasRelations) { + return ActionResult::ok(); + } else { + ActionResult res = ActionResult::ok(); + std::apply( + [&](auto... rels) -> auto { + ( + [&]() -> auto { + if (res.has_error()) return; + using R = std::remove_cvref_t; + if constexpr (std::same_as) { + std::size_t count = 0; + for (auto member : rels.group) { + if (relation_operand_present(val, member)) ++count; + } + if (count != 0 && count != rels.group.size) { + res = ActionResult::fail(ParseError{ + .code = ErrorCode::dependency_missing, + .kind = ErrorKind::validation, + .subject = std::string(rels.name), + .detail = "arguments must be provided together: " + + relation_name_list(rels.group), + }); + } + } else if constexpr (std::same_as) { + if (relation_operand_present(val, rels.left) && + relation_operand_present(val, rels.right)) { + res = ActionResult::fail(ParseError{ + .code = ErrorCode::mutually_exclusive, + .kind = ErrorKind::validation, + .subject = "--" + std::string(rels.left) + ", --" + + std::string(rels.right), + .detail = + "these arguments cannot be provided together", + }); + } + } else if constexpr (std::same_as) { + if (relation_operand_present(val, rels.source) && + !relation_operand_present(val, rels.target)) { + res = ActionResult::fail(ParseError{ + .code = ErrorCode::dependency_missing, + .kind = ErrorKind::validation, + .subject = "--" + std::string(rels.source), + .detail = "requires --" + std::string(rels.target), + }); + } + } + }(), + ...); + }, + T::relations.items); + return res; + } + } + auto apply_env_fallback(T& val) -> ActionResult { ActionResult res = ActionResult::ok(); for_each_field(val, [&](auto& f) -> auto { diff --git a/include/cli/relation.hh b/include/cli/relation.hh new file mode 100644 index 0000000..c40b00e --- /dev/null +++ b/include/cli/relation.hh @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace cli { + +template +struct NameList { + std::array values{}; + std::size_t size{}; + + constexpr NameList() = default; + constexpr NameList(std::initializer_list names) { + size = names.size() < Max ? names.size() : Max; + auto it = names.begin(); + for (std::size_t i = 0; i < size; ++i, ++it) { + values[i] = *it; + } + } + + [[nodiscard]] constexpr auto begin() const { return values.begin(); } + [[nodiscard]] constexpr auto end() const { return values.begin() + size; } + [[nodiscard]] constexpr auto empty() const -> bool { return size == 0; } +}; + +struct TogetherParams { + std::string_view name; + NameList<> group; +}; + +struct TogetherRelation { + std::string_view name; + NameList<> group; +}; + +struct ConflictsRelation { + std::string_view left; + std::string_view right; +}; + +struct DependsOnRelation { + std::string_view source; + std::string_view target; +}; + +template +struct RelationSet { + std::tuple items; +}; + +template +struct IsRelationSet : std::false_type {}; + +template +struct IsRelationSet> : std::true_type {}; + +template +concept RelationSetLike = IsRelationSet>::value; + +[[nodiscard]] constexpr auto together(TogetherParams params) + -> TogetherRelation { + return {.name = params.name, .group = params.group}; +} + +[[nodiscard]] constexpr auto conflicts(std::string_view left, + std::string_view right) + -> ConflictsRelation { + return {.left = left, .right = right}; +} + +[[nodiscard]] constexpr auto depends_on(std::string_view source, + std::string_view target) + -> DependsOnRelation { + return {.source = source, .target = target}; +} + +template +[[nodiscard]] constexpr auto relations(Relations... rels) + -> RelationSet { + return {.items = std::tuple{rels...}}; +} + +} // namespace cli diff --git a/tests/test_help.cc b/tests/test_help.cc index 3a574b8..62ef574 100644 --- a/tests/test_help.cc +++ b/tests/test_help.cc @@ -56,6 +56,39 @@ struct ExitSuccessArgs { {.help = "Print version and exit", .presence = cli::optional}}; }; +struct MetadataHelpArgs { + cli::Flag<"visible"> visible{{.help = "Visible flag"}}; + cli::Flag<"hidden"> hidden{{.help = "Hidden flag", .hidden = true}}; + cli::Flag<"old"> old{{ + .help = "Old flag", + .deprecated = "use --visible instead", + }}; + cli::StringOption<"output", 'o'> output{{ + .help = "Output file", + .presence = cli::required, + .default_value = std::string{"out.txt"}, + .env = "APP_OUTPUT", + }}; + cli::StringOption<"user"> user{{.help = "User name"}}; + cli::StringOption<"password"> password{{.help = "Password"}}; + cli::Flag<"json"> json{{.help = "JSON output"}}; + cli::Flag<"markdown"> markdown{{.help = "Markdown output"}}; + cli::Flag<"deploy"> deploy{{.help = "Deploy"}}; + cli::StringOption<"profile"> profile{{.help = "Deploy profile"}}; + cli::Command<"legacy", BuildArgs> legacy{{ + .help = "Legacy command", + .deprecated = "use build", + }}; + cli::Command<"internal", BuildArgs> internal{{ + .help = "Internal command", + .hidden = true, + }}; + + static constexpr auto relations = cli::relations( + cli::together({.name = "legacy_auth", .group = {"user", "password"}}), + cli::conflicts("json", "markdown"), cli::depends_on("deploy", "profile")); +}; + } // namespace TEST(Help, UsageContainsProgramAndMajorGroups) { @@ -226,3 +259,30 @@ TEST(Help, ExitSuccessActionTriggersDedicatedCode) { EXPECT_TRUE(result.error.use_stdout()); EXPECT_EQ(result.error.exit_code(), 0); } + +TEST(Help, MetadataIsRenderedAndHiddenFieldsAreOmitted) { + cli::Parser parser; + + const auto help = parser.format_help(cli::ColorMode::never); + EXPECT_NE(help.find("--visible"), std::string::npos); + EXPECT_EQ(help.find("--hidden"), std::string::npos); + EXPECT_NE(help.find("[deprecated: use --visible instead]"), std::string::npos); + EXPECT_NE(help.find("[required]"), std::string::npos); + EXPECT_NE(help.find("[env: APP_OUTPUT]"), std::string::npos); + EXPECT_NE(help.find("[default: out.txt]"), std::string::npos); + EXPECT_NE(help.find("legacy"), std::string::npos); + EXPECT_NE(help.find("[deprecated: use build]"), std::string::npos); + EXPECT_EQ(help.find("internal"), std::string::npos); +} + +TEST(Help, RelationsAreRendered) { + cli::Parser parser; + + const auto help = parser.format_help(cli::ColorMode::never); + EXPECT_NE(help.find("Relations:"), std::string::npos); + EXPECT_NE( + help.find("legacy_auth: --user, --password must be provided together"), + std::string::npos); + EXPECT_NE(help.find("--json conflicts with --markdown"), std::string::npos); + EXPECT_NE(help.find("--deploy depends on --profile"), std::string::npos); +} diff --git a/tests/test_parse.cc b/tests/test_parse.cc index 6cada92..a3173db 100644 --- a/tests/test_parse.cc +++ b/tests/test_parse.cc @@ -81,6 +81,53 @@ struct TotalValuesArgs { mode; }; +struct RelationArgs { + cli::StringOption<"user"> user; + cli::StringOption<"password"> password; + cli::Flag<"json"> json; + cli::Flag<"markdown"> markdown; + cli::Flag<"deploy"> deploy; + cli::StringOption<"profile"> profile; + + static constexpr auto relations = cli::relations( + cli::together({.name = "legacy_auth", .group = {"user", "password"}}), + cli::conflicts("json", "markdown"), cli::depends_on("deploy", "profile")); +}; + +struct UnknownRelationTargetArgs { + cli::Flag<"json"> json; + + static constexpr auto relations = + cli::relations(cli::conflicts("json", "xml")); +}; + +struct DuplicateRelationMembershipArgs { + cli::Flag<"user"> user; + cli::Flag<"password"> password; + cli::Flag<"json"> json; + + static constexpr auto relations = cli::relations( + cli::together({.name = "legacy_auth", .group = {"user", "password"}}), + cli::conflicts("user", "json")); +}; + +struct RelationGroupNameCollisionArgs { + cli::Flag<"legacy_auth"> legacy_auth; + cli::Flag<"user"> user; + cli::Flag<"password"> password; + + static constexpr auto relations = cli::relations( + cli::together({.name = "legacy_auth", .group = {"user", "password"}})); +}; + +static_assert(cli::detail::relations_are_well_formed()); +static_assert( + !cli::detail::relations_are_well_formed()); +static_assert( + !cli::detail::relations_are_well_formed()); +static_assert( + !cli::detail::relations_are_well_formed()); + auto argv(std::initializer_list values) -> std::vector { return {values}; @@ -221,6 +268,44 @@ TEST(Parse, MissingOptionalEntryDoesNotIncrementTotalValues) { EXPECT_EQ(seen, (std::vector{0, 0})); } +TEST(Parse, RelationTogetherRequiresAllMembers) { + auto args = argv({"prog", "--user", "alice"}); + auto result = cli::Parser{}.parse( + std::span(args), 1); + + ASSERT_TRUE(result.has_error()); + EXPECT_EQ(result.error.code, cli::ErrorCode::dependency_missing); + EXPECT_EQ(result.error.subject, "legacy_auth"); +} + +TEST(Parse, RelationConflictsRejectsBothArguments) { + auto args = argv({"prog", "--json", "--markdown"}); + auto result = cli::Parser{}.parse( + std::span(args), 1); + + ASSERT_TRUE(result.has_error()); + EXPECT_EQ(result.error.code, cli::ErrorCode::mutually_exclusive); +} + +TEST(Parse, RelationDependsOnRequiresTarget) { + auto args = argv({"prog", "--deploy"}); + auto result = cli::Parser{}.parse( + std::span(args), 1); + + ASSERT_TRUE(result.has_error()); + EXPECT_EQ(result.error.code, cli::ErrorCode::dependency_missing); + EXPECT_EQ(result.error.subject, "--deploy"); +} + +TEST(Parse, RelationsPassWhenSatisfied) { + auto args = argv({"prog", "--user", "alice", "--password", "secret", + "--deploy", "--profile", "prod", "--json"}); + auto result = cli::Parser{}.parse( + std::span(args), 1); + + ASSERT_TRUE(result.has_value()); +} + #ifdef _WIN32 namespace { From 0c1df3cf72f45e72049de67b80f055537f98d614 Mon Sep 17 00:00:00 2001 From: gen740 <54583542+gen740@users.noreply.github.com> Date: Tue, 12 May 2026 14:14:04 +0900 Subject: [PATCH 2/6] Add relation docs and group example --- docs/docs/examples/advanced.mdx | 4 +- docs/docs/examples/bind.mdx | 4 +- docs/docs/examples/callback-entry-point.mdx | 4 +- docs/docs/examples/compiler.mdx | 4 +- docs/docs/examples/custom-action.mdx | 4 +- .../examples/distribution-and-platform.mdx | 4 +- docs/docs/examples/env-fallback.mdx | 4 +- docs/docs/examples/git.mdx | 4 +- docs/docs/examples/index.mdx | 18 +- docs/docs/examples/relations.mdx | 53 +++++ docs/docs/relations.mdx | 103 ++++++++ docs/sidebars.ts | 1 + examples/relations.cc | 36 +++ include/cli/argument_validation.hh | 12 +- include/cli/help.hh | 221 ++++++++++++++---- include/cli/parser.hh | 12 +- include/cli/relation.hh | 13 +- tests/test_help.cc | 18 +- tests/test_parse.cc | 8 +- 19 files changed, 427 insertions(+), 100 deletions(-) create mode 100644 docs/docs/examples/relations.mdx create mode 100644 docs/docs/relations.mdx create mode 100644 examples/relations.cc diff --git a/docs/docs/examples/advanced.mdx b/docs/docs/examples/advanced.mdx index d704e5f..7db1be6 100644 --- a/docs/docs/examples/advanced.mdx +++ b/docs/docs/examples/advanced.mdx @@ -1,6 +1,6 @@ --- -sidebar_position: 7 -title: 6. Advanced +sidebar_position: 8 +title: 7. Advanced --- import {ExampleSource} from '@site/src/components/ExampleDoc'; diff --git a/docs/docs/examples/bind.mdx b/docs/docs/examples/bind.mdx index d3c19fa..9e26586 100644 --- a/docs/docs/examples/bind.mdx +++ b/docs/docs/examples/bind.mdx @@ -1,6 +1,6 @@ --- -sidebar_position: 5 -title: 4. Bind +sidebar_position: 6 +title: 5. Bind --- import {ExampleSource} from '@site/src/components/ExampleDoc'; diff --git a/docs/docs/examples/callback-entry-point.mdx b/docs/docs/examples/callback-entry-point.mdx index d320d4d..2d22e90 100644 --- a/docs/docs/examples/callback-entry-point.mdx +++ b/docs/docs/examples/callback-entry-point.mdx @@ -1,6 +1,6 @@ --- -sidebar_position: 6 -title: 5. Callback Entry Point +sidebar_position: 7 +title: 6. Callback Entry Point --- import {ExampleSource} from '@site/src/components/ExampleDoc'; diff --git a/docs/docs/examples/compiler.mdx b/docs/docs/examples/compiler.mdx index f3b8273..a06aeae 100644 --- a/docs/docs/examples/compiler.mdx +++ b/docs/docs/examples/compiler.mdx @@ -1,6 +1,6 @@ --- -sidebar_position: 9 -title: 9. Compiler Wrapper +sidebar_position: 11 +title: 10. Compiler Wrapper --- import {ExampleSource} from '@site/src/components/ExampleDoc'; diff --git a/docs/docs/examples/custom-action.mdx b/docs/docs/examples/custom-action.mdx index 705941c..568b9df 100644 --- a/docs/docs/examples/custom-action.mdx +++ b/docs/docs/examples/custom-action.mdx @@ -1,6 +1,6 @@ --- -sidebar_position: 7 -title: 7. Custom Action +sidebar_position: 9 +title: 8. Custom Action --- import {ExampleSource} from '@site/src/components/ExampleDoc'; diff --git a/docs/docs/examples/distribution-and-platform.mdx b/docs/docs/examples/distribution-and-platform.mdx index aac0f6b..b3aba3c 100644 --- a/docs/docs/examples/distribution-and-platform.mdx +++ b/docs/docs/examples/distribution-and-platform.mdx @@ -1,6 +1,6 @@ --- -sidebar_position: 10 -title: 10. Distribution and Platform Examples +sidebar_position: 12 +title: 11. Distribution and Platform Examples --- import {ExampleSource} from '@site/src/components/ExampleDoc'; diff --git a/docs/docs/examples/env-fallback.mdx b/docs/docs/examples/env-fallback.mdx index d849a20..24baeda 100644 --- a/docs/docs/examples/env-fallback.mdx +++ b/docs/docs/examples/env-fallback.mdx @@ -1,6 +1,6 @@ --- -sidebar_position: 11 -title: 11. Env Fallback +sidebar_position: 13 +title: 12. Env Fallback --- import {ExampleSource} from '@site/src/components/ExampleDoc'; diff --git a/docs/docs/examples/git.mdx b/docs/docs/examples/git.mdx index 2ee0bac..2469e29 100644 --- a/docs/docs/examples/git.mdx +++ b/docs/docs/examples/git.mdx @@ -1,6 +1,6 @@ --- -sidebar_position: 8 -title: 8. Git +sidebar_position: 10 +title: 9. Git --- import {ExampleSource} from '@site/src/components/ExampleDoc'; diff --git a/docs/docs/examples/index.mdx b/docs/docs/examples/index.mdx index cd0c31a..ad97cfc 100644 --- a/docs/docs/examples/index.mdx +++ b/docs/docs/examples/index.mdx @@ -18,21 +18,23 @@ Each one is small enough to copy into a project, but specific enough to answer Nested commands with their own help output and behavior. 3. [Validation](./validation) Parse-time checks for numbers, names, and filesystem arguments. -4. [Bind](./bind) +4. [Relations](./relations) + Schema-level relationships such as groups, conflicts, and depends_on. +5. [Bind](./bind) Write values directly into existing variables. -5. [Callback Entry Point](./callback-entry-point) +6. [Callback Entry Point](./callback-entry-point) Fill application state during parsing, then call the real entry point. -6. [Advanced](./advanced) +7. [Advanced](./advanced) Lower-level composition with explicit conversion, validation, and packing steps. -7. [Custom Action](./custom-action) +8. [Custom Action](./custom-action) Full struct-based action definition with `accepts_input`, `after_type`, and `storage_type`. -8. [Git](./git) +9. [Git](./git) A command-heavy CLI inspired by clap's git example, including nested subcommands. -9. [Compiler Wrapper](./compiler) +10. [Compiler Wrapper](./compiler) Short-cluster flags, negatable key-value options, and `std::map` storage with `insert_or_assign`. -10. [Distribution and Platform Examples](./distribution-and-platform) +11. [Distribution and Platform Examples](./distribution-and-platform) Single-header usage, C++20 modules, and the Windows Unicode sample used by CI. -11. [Env Fallback](./env-fallback) +12. [Env Fallback](./env-fallback) Populate options from environment variables with the same conversion and validation pipeline. ## Building diff --git a/docs/docs/examples/relations.mdx b/docs/docs/examples/relations.mdx new file mode 100644 index 0000000..a4c98f4 --- /dev/null +++ b/docs/docs/examples/relations.mdx @@ -0,0 +1,53 @@ +--- +sidebar_position: 5 +title: 4. Relations +--- + +import {ExampleSource} from '@site/src/components/ExampleDoc'; + +# Relations + +This example shows schema-level relationships between options. Use this pattern +when the rule can be described from occurrence alone and should also appear in +generated help. Relation metadata is rendered inline on affected options, while +named groups get their own `Option groups:` entry. + +## Source + + + +## What This Example Shows + +The example defines three static relations: + +- `user` and `password` are a named `legacy_auth` group and must be provided together. +- `json` conflicts with `markdown`. +- the named `legacy_auth` group depends on `profile`. + +The parser enforces these rules after normal option parsing and before the +program uses the parsed values. + +## Try It + +```bash +./build/examples/relations --json +./build/examples/relations --user alice --password secret --profile prod +./build/examples/relations --markdown --profile prod +``` + +Failure cases: + +- `--user alice` fails because `password` is missing from the `legacy_auth` group. +- `--json --markdown` fails because the two output formats conflict. +- `--user alice --password secret` fails because `legacy_auth` depends on `profile`. +- `--help` shows relation metadata next to `--user`, `--password`, `--json`, + `--markdown`, and `--profile`. + +## Related Examples + +- [Validation](/docs/examples/validation) +- [Advanced](/docs/examples/advanced) diff --git a/docs/docs/relations.mdx b/docs/docs/relations.mdx new file mode 100644 index 0000000..0ca0535 --- /dev/null +++ b/docs/docs/relations.mdx @@ -0,0 +1,103 @@ +--- +sidebar_position: 4 +title: Relations +--- + +# Relations + +Use `static constexpr auto relations` when a relationship between fields should +be part of the typed schema, generated help, and parse-time validation. The +help generator renders short relation metadata inline on affected options, and +named groups in an `Option groups:` section. + +This is intentionally metadata, not a replacement for `constraints()`. +Relations cover simple occurrence-based rules that are independent of runtime +values. Keep `constraints()` for value-dependent logic such as "`--type foo` +requires `--foo-config`". + +## API + +```cpp +struct Args { + cli::StringOption<"user"> user; + cli::StringOption<"password"> password; + cli::Flag<"json"> json; + cli::Flag<"markdown"> markdown; + cli::StringOption<"profile"> profile; + + static constexpr auto relations = cli::relations( + cli::group({.name = "legacy_auth", .members = {"user", "password"}}), + cli::conflicts("json", "markdown"), + cli::depends_on("legacy_auth", "profile")); +}; +``` + +Names are written without the command-line prefix: use `"json"`, not +`"--json"`. + +## Relation Types + +- `cli::group({.name = "...", .members = {...}})` requires all members in the + named group to be provided together, or none of them. +- `cli::conflicts("a", "b")` rejects input when both operands are provided. +- `cli::depends_on("a", "b")` rejects input when `a` is provided but `b` is not. + +All checks are based on `provided()`: defaults do not count as CLI occurrence. + +## Named Groups + +`group` creates a named group that can be reused as an operand in another +relation: + +```cpp +static constexpr auto relations = cli::relations( + cli::group({.name = "legacy_auth", .members = {"user", "password"}}), + cli::depends_on("legacy_auth", "profile")); +``` + +This means `--user` and `--password` must appear together, and that legacy auth +requires `--profile`. + +The generated help shows the rule where users look for it: + +```text +--user User name [requires: --profile] +--password Password [requires: --profile] +--profile Profile [required by: legacy_auth] + +Option groups: + legacy_auth: + --user, --password must be used together + requires: --profile +``` + +## Well-Formed Schema Rules + +The relation schema is checked at compile time as part of `ArgumentSpec`. + +- Every field or group name used by a relation must exist. +- A `group` needs a non-empty unique name and at least two members. +- A group name must not collide with an option or command name. +- Each field may participate in only one direct relation. +- `conflicts("a", "a")` and `depends_on("a", "a")` are invalid. + +These rules are deliberately strict. They keep help text and parse-time +validation unambiguous. + +## Relation Metadata vs `constraints()` + +Use relations when the rule is static and occurrence-based: + +- `--json` conflicts with `--markdown` +- `legacy_auth` depends on `--profile` +- `--user` and `--password` must be provided together + +Use `constraints()` when the rule depends on parsed values: + +- `--type s3` requires `--bucket` +- `--mode fast` rejects `--exact` +- one numeric option must be greater than another + +## Example + +See [Relations Example](./examples/relations) for a complete program. diff --git a/docs/sidebars.ts b/docs/sidebars.ts index ba892d3..2880a57 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -5,6 +5,7 @@ const sidebars: SidebarsConfig = { 'intro', 'tutorial', 'actions', + 'relations', { type: 'category', label: 'Examples', diff --git a/examples/relations.cc b/examples/relations.cc new file mode 100644 index 0000000..e660600 --- /dev/null +++ b/examples/relations.cc @@ -0,0 +1,36 @@ +#include + +#include "cli/argument.hh" +#include "cli/parser.hh" + +struct Args { + cli::Description description{ + "Relation metadata example covering groups, conflicts, and depends_on."}; + cli::Help<> help; + + cli::StringOption<"user"> user{{.help = "Legacy auth user name"}}; + cli::StringOption<"password"> password{{.help = "Legacy auth password"}}; + + cli::Flag<"json"> json{{.help = "Write JSON output"}}; + cli::Flag<"markdown"> markdown{{.help = "Write Markdown output"}}; + + cli::StringOption<"profile"> profile{{.help = "Deployment profile"}}; + + static constexpr auto relations = cli::relations( + cli::group({.name = "legacy_auth", .members = {"user", "password"}}), + cli::conflicts("json", "markdown"), + cli::depends_on("legacy_auth", "profile")); +}; + +auto main(int argc, char* argv[]) -> int { + const auto args = cli::parse_or_exit(argc, argv); + + std::cout << "format: " << (args.json ? "json" : "markdown") << '\n'; + if (args.user) { + std::cout << "legacy auth: " << *args.user << '\n'; + } + if (args.profile) { + std::cout << "profile: " << *args.profile << '\n'; + } + return 0; +} diff --git a/include/cli/argument_validation.hh b/include/cli/argument_validation.hh index 58a0424..3cadc73 100644 --- a/include/cli/argument_validation.hh +++ b/include/cli/argument_validation.hh @@ -149,7 +149,7 @@ consteval auto group_name_exists_before(std::string_view name) -> bool { } else { constexpr auto rel = std::get(T::relations.items); using R = std::remove_cvref_t; - if constexpr (std::same_as) { + if constexpr (std::same_as) { if (rel.name == name) return true; } return group_name_exists_before(name); @@ -179,8 +179,8 @@ consteval auto relation_membership_count(std::string_view name) -> std::size_t { constexpr auto rel = std::get(T::relations.items); using R = std::remove_cvref_t; std::size_t current = 0; - if constexpr (std::same_as) { - for (auto member : rel.group) { + if constexpr (std::same_as) { + for (auto member : rel.members) { if (member == name) ++current; } } else if constexpr (std::same_as) { @@ -215,13 +215,13 @@ consteval auto relation_items_are_well_formed() -> bool { constexpr auto rel = std::get(T::relations.items); using R = std::remove_cvref_t; constexpr auto field_names = named_relation_targets(); - if constexpr (std::same_as) { - if (rel.name.empty() || rel.group.size < 2 || + if constexpr (std::same_as) { + if (rel.name.empty() || rel.members.size < 2 || contains_name(field_names, rel.name) || group_name_exists_before(rel.name)) { return false; } - for (auto member : rel.group) { + for (auto member : rel.members) { if (!contains_name(field_names, member)) return false; } } else if constexpr (std::same_as) { diff --git a/include/cli/help.hh b/include/cli/help.hh index 28e438a..a3b303a 100644 --- a/include/cli/help.hh +++ b/include/cli/help.hh @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -341,6 +342,112 @@ auto append_section(std::string& out, std::string_view title, * @return The formatted row string. */ template +consteval auto relation_field_name() -> std::string_view { + using F = std::remove_cvref_t; + if constexpr (std::derived_from || + std::derived_from) { + return F::name.view(); + } else { + return {}; + } +} + +inline auto relation_name_list(NameList<> names) -> std::string { + std::string out; + for (std::size_t i = 0; i < names.size; ++i) { + if (i != 0) out += ", "; + out += "--"; + out += names.values[i]; + } + return out; +} + +template +constexpr auto relation_group(std::string_view name) -> NameList<> { + NameList<> result; + if constexpr (detail::HasRelations) { + std::apply( + [&](auto... rels) -> auto { + ( + [&]() -> auto { + using R = std::remove_cvref_t; + if constexpr (std::same_as) { + if (rels.name == name) result = rels.members; + } + }(), + ...); + }, + T::relations.items); + } + return result; +} + +template +constexpr auto relation_operand_contains(std::string_view operand, + std::string_view field_name) -> bool { + if (operand == field_name) return true; + const auto group = relation_group(operand); + for (auto member : group) { + if (member == field_name) return true; + } + return false; +} + +template +auto relation_operand_label(std::string_view operand) -> std::string { + if constexpr (detail::HasRelations) { + if (!relation_group(operand).empty()) { + return std::string(operand); + } + } + return std::format("--{}", operand); +} + +template +auto relation_help_metadata() -> std::vector { + std::vector metadata; + constexpr auto field_name = relation_field_name(); + if constexpr (field_name.empty()) { + return metadata; + } else if constexpr (detail::HasRelations) { + std::apply( + [&](auto... rels) -> auto { + ( + [&]() -> auto { + using R = std::remove_cvref_t; + if constexpr (std::same_as) { + (void)field_name; + } else if constexpr (std::same_as) { + if (relation_operand_contains(rels.left, field_name)) { + metadata.emplace_back(std::format( + "conflicts: {}", relation_operand_label(rels.right))); + } + if (relation_operand_contains(rels.right, field_name)) { + metadata.emplace_back(std::format( + "conflicts: {}", relation_operand_label(rels.left))); + } + } else if constexpr (std::same_as) { + if (relation_operand_contains(rels.source, field_name)) { + metadata.emplace_back(std::format( + "requires: {}", relation_operand_label(rels.target))); + } + if (relation_operand_contains(rels.target, field_name)) { + metadata.emplace_back( + std::format("required by: {}", + relation_operand_label(rels.source))); + } + } + }(), + ...); + }, + T::relations.items); + return metadata; + } else { + return metadata; + } +} + +template auto help_metadata(Field& field) -> std::vector { std::vector metadata; if constexpr (std::derived_from, OptionTag> || @@ -365,6 +472,10 @@ auto help_metadata(Field& field) -> std::vector { if (!field.deprecated().empty()) { metadata.emplace_back(std::format("deprecated: {}", field.deprecated())); } + auto relation_metadata = relation_help_metadata(); + metadata.insert(metadata.end(), + std::make_move_iterator(relation_metadata.begin()), + std::make_move_iterator(relation_metadata.end())); return metadata; } @@ -384,17 +495,57 @@ inline auto append_help_metadata(std::string& out, out += "]"; } -inline auto relation_name_list(NameList<> names) -> std::string { - std::string out; - for (std::size_t i = 0; i < names.size; ++i) { - if (i != 0) out += ", "; - out += "--"; - out += names.values[i]; +template +auto append_group_requirements(std::string& row, std::string_view group_name) + -> void { + if constexpr (detail::HasRelations) { + std::apply( + [&](auto... rels) -> auto { + ( + [&]() -> auto { + using R = std::remove_cvref_t; + if constexpr (std::same_as) { + if (rels.source == group_name) { + row += std::format(" requires: {}\n", + relation_operand_label(rels.target)); + } + } + }(), + ...); + }, + T::relations.items); } - return out; } -template +template +auto relation_option_group_row(const GroupRelation& group) -> std::string { + std::string row = std::format(" {}:\n {} must be used together\n", + group.name, relation_name_list(group.members)); + append_group_requirements(row, group.name); + return row; +} + +template +auto relation_option_group_rows() -> std::vector { + std::vector rows; + if constexpr (detail::HasRelations) { + std::apply( + [&](auto... rels) -> auto { + ( + [&]() -> auto { + using R = std::remove_cvref_t; + if constexpr (std::same_as) { + rows.push_back(relation_option_group_row(rels)); + } + }(), + ...); + }, + T::relations.items); + } + return rows; +} + +template auto render_help_row(Field& field, std::string_view label, std::size_t width, std::string_view option_color, std::string_view reset_color) -> std::string { @@ -402,7 +553,7 @@ auto render_help_row(Field& field, std::string_view label, std::size_t width, out += option_color; out += label; out += reset_color; - const auto metadata = help_metadata(field); + const auto metadata = help_metadata(field); if (!field.help_text().empty() || !metadata.empty()) { out.append(width - label.size() + 2, ' '); if (!field.help_text().empty()) { @@ -556,20 +707,20 @@ auto format_help_impl(T& value, std::string_view program_name, if (fields.hidden()) return; auto label = field_usage_label(); option_rows.emplace_back( - label, render_help_row(fields, label, option_width, - option_color, reset)); + label, render_help_row(fields, label, option_width, + option_color, reset)); } else if constexpr (std::derived_from) { if (fields.hidden()) return; auto label = field_usage_label(); positional_rows.emplace_back( - label, render_help_row(fields, label, positional_width, - option_color, reset)); + label, render_help_row(fields, label, positional_width, + option_color, reset)); } else if constexpr (std::derived_from) { if (fields.hidden()) return; auto label = field_usage_label(); command_rows.emplace_back( - label, render_help_row(fields, label, command_width, - option_color, reset)); + label, render_help_row(fields, label, command_width, + option_color, reset)); } }(), ...); @@ -580,37 +731,15 @@ auto format_help_impl(T& value, std::string_view program_name, append_rows("Positional arguments:", positional_rows); append_rows("Commands:", command_rows); - if constexpr (detail::HasRelations) { - std::vector relation_rows; - std::apply( - [&](auto... rels) -> auto { - ( - [&]() -> auto { - using R = std::remove_cvref_t; - if constexpr (std::same_as) { - relation_rows.push_back( - std::format(" {}: {} must be provided together\n", - rels.name, relation_name_list(rels.group))); - } else if constexpr (std::same_as) { - relation_rows.push_back(std::format( - " --{} conflicts with --{}\n", rels.left, rels.right)); - } else if constexpr (std::same_as) { - relation_rows.push_back(std::format(" --{} depends on --{}\n", - rels.source, rels.target)); - } - }(), - ...); - }, - T::relations.items); - if (!relation_rows.empty()) { - out += '\n'; - out += heading; - out += "Relations:"; - out += reset; - out += '\n'; - for (const auto& row : relation_rows) { - out += row; - } + const auto option_group_rows = relation_option_group_rows(); + if (!option_group_rows.empty()) { + out += '\n'; + out += heading; + out += "Option groups:"; + out += reset; + out += '\n'; + for (const auto& row : option_group_rows) { + out += row; } } diff --git a/include/cli/parser.hh b/include/cli/parser.hh index 3bfa7f0..a337235 100644 --- a/include/cli/parser.hh +++ b/include/cli/parser.hh @@ -1022,10 +1022,10 @@ struct Parser { ( [&]() -> auto { using R = std::remove_cvref_t; - if constexpr (std::same_as) { + if constexpr (std::same_as) { if (!found_group && rels.name == name) { found_group = true; - for (auto member : rels.group) { + for (auto member : rels.members) { group_present = group_present || relation_operand_present(val, member); } @@ -1051,18 +1051,18 @@ struct Parser { [&]() -> auto { if (res.has_error()) return; using R = std::remove_cvref_t; - if constexpr (std::same_as) { + if constexpr (std::same_as) { std::size_t count = 0; - for (auto member : rels.group) { + for (auto member : rels.members) { if (relation_operand_present(val, member)) ++count; } - if (count != 0 && count != rels.group.size) { + if (count != 0 && count != rels.members.size) { res = ActionResult::fail(ParseError{ .code = ErrorCode::dependency_missing, .kind = ErrorKind::validation, .subject = std::string(rels.name), .detail = "arguments must be provided together: " + - relation_name_list(rels.group), + relation_name_list(rels.members), }); } } else if constexpr (std::same_as) { diff --git a/include/cli/relation.hh b/include/cli/relation.hh index c40b00e..5e8495d 100644 --- a/include/cli/relation.hh +++ b/include/cli/relation.hh @@ -27,14 +27,14 @@ struct NameList { [[nodiscard]] constexpr auto empty() const -> bool { return size == 0; } }; -struct TogetherParams { +struct GroupParams { std::string_view name; - NameList<> group; + NameList<> members; }; -struct TogetherRelation { +struct GroupRelation { std::string_view name; - NameList<> group; + NameList<> members; }; struct ConflictsRelation { @@ -61,9 +61,8 @@ struct IsRelationSet> : std::true_type {}; template concept RelationSetLike = IsRelationSet>::value; -[[nodiscard]] constexpr auto together(TogetherParams params) - -> TogetherRelation { - return {.name = params.name, .group = params.group}; +[[nodiscard]] constexpr auto group(GroupParams params) -> GroupRelation { + return {.name = params.name, .members = params.members}; } [[nodiscard]] constexpr auto conflicts(std::string_view left, diff --git a/tests/test_help.cc b/tests/test_help.cc index 62ef574..c622c5f 100644 --- a/tests/test_help.cc +++ b/tests/test_help.cc @@ -85,7 +85,7 @@ struct MetadataHelpArgs { }}; static constexpr auto relations = cli::relations( - cli::together({.name = "legacy_auth", .group = {"user", "password"}}), + cli::group({.name = "legacy_auth", .members = {"user", "password"}}), cli::conflicts("json", "markdown"), cli::depends_on("deploy", "profile")); }; @@ -279,10 +279,14 @@ TEST(Help, RelationsAreRendered) { cli::Parser parser; const auto help = parser.format_help(cli::ColorMode::never); - EXPECT_NE(help.find("Relations:"), std::string::npos); - EXPECT_NE( - help.find("legacy_auth: --user, --password must be provided together"), - std::string::npos); - EXPECT_NE(help.find("--json conflicts with --markdown"), std::string::npos); - EXPECT_NE(help.find("--deploy depends on --profile"), std::string::npos); + EXPECT_EQ(help.find("Relations:"), std::string::npos); + EXPECT_NE(help.find("Option groups:"), std::string::npos); + EXPECT_NE(help.find("--user"), std::string::npos); + EXPECT_NE(help.find("legacy_auth:"), std::string::npos); + EXPECT_NE(help.find("--user, --password must be used together"), + std::string::npos); + EXPECT_NE(help.find("[conflicts: --markdown]"), std::string::npos); + EXPECT_NE(help.find("[conflicts: --json]"), std::string::npos); + EXPECT_NE(help.find("[requires: --profile]"), std::string::npos); + EXPECT_NE(help.find("[required by: --deploy]"), std::string::npos); } diff --git a/tests/test_parse.cc b/tests/test_parse.cc index a3173db..9ff7b7b 100644 --- a/tests/test_parse.cc +++ b/tests/test_parse.cc @@ -90,7 +90,7 @@ struct RelationArgs { cli::StringOption<"profile"> profile; static constexpr auto relations = cli::relations( - cli::together({.name = "legacy_auth", .group = {"user", "password"}}), + cli::group({.name = "legacy_auth", .members = {"user", "password"}}), cli::conflicts("json", "markdown"), cli::depends_on("deploy", "profile")); }; @@ -107,7 +107,7 @@ struct DuplicateRelationMembershipArgs { cli::Flag<"json"> json; static constexpr auto relations = cli::relations( - cli::together({.name = "legacy_auth", .group = {"user", "password"}}), + cli::group({.name = "legacy_auth", .members = {"user", "password"}}), cli::conflicts("user", "json")); }; @@ -117,7 +117,7 @@ struct RelationGroupNameCollisionArgs { cli::Flag<"password"> password; static constexpr auto relations = cli::relations( - cli::together({.name = "legacy_auth", .group = {"user", "password"}})); + cli::group({.name = "legacy_auth", .members = {"user", "password"}})); }; static_assert(cli::detail::relations_are_well_formed()); @@ -268,7 +268,7 @@ TEST(Parse, MissingOptionalEntryDoesNotIncrementTotalValues) { EXPECT_EQ(seen, (std::vector{0, 0})); } -TEST(Parse, RelationTogetherRequiresAllMembers) { +TEST(Parse, RelationGroupRequiresAllMembers) { auto args = argv({"prog", "--user", "alice"}); auto result = cli::Parser{}.parse( std::span(args), 1); From 4a2d4a35e97aeb8d9b95bdc117c7c6fa9d5629a2 Mon Sep 17 00:00:00 2001 From: gen740 <54583542+gen740@users.noreply.github.com> Date: Tue, 12 May 2026 14:21:44 +0900 Subject: [PATCH 3/6] Improve help colors and palette customization --- include/cli/color.hh | 56 ++++++++++++++++++++-- include/cli/help.hh | 108 +++++++++++++++++++++++++++++------------- include/cli/parser.hh | 16 ++++++- tests/test_help.cc | 28 ++++++++++- 4 files changed, 169 insertions(+), 39 deletions(-) diff --git a/include/cli/color.hh b/include/cli/color.hh index 779b2b7..695b88a 100644 --- a/include/cli/color.hh +++ b/include/cli/color.hh @@ -19,6 +19,25 @@ namespace cli { */ enum class ColorMode { auto_, never, always }; +/** + * @brief ANSI palette used by generated help output. + * + * Each field is an ANSI SGR sequence. `format_help(ColorMode::never, ...)` + * suppresses these sequences regardless of the palette contents. + */ +struct HelpPalette { + std::string_view usage{"\033[1m"}; + std::string_view heading{"\033[1;4;36m"}; + std::string_view option{"\033[1;32m"}; + std::string_view metavar{"\033[36m"}; + std::string_view command{"\033[1;33m"}; + std::string_view metadata{"\033[2m"}; + std::string_view group{"\033[1;33m"}; + std::string_view reset{"\033[0m"}; +}; + +inline constexpr HelpPalette default_help_palette{}; + /** * @brief Tag type passed to `format_help()` to request recursive sub-command * output. @@ -46,6 +65,7 @@ namespace detail { struct AnsiStyle { /** @brief Whether ANSI codes should be emitted. */ bool enabled; + HelpPalette palette{}; /** * @brief Returns the ANSI bold sequence, or an empty string when disabled. @@ -72,7 +92,35 @@ struct AnsiStyle { * @return `"\033[0m"` if enabled, `""` otherwise. */ [[nodiscard]] constexpr auto reset() const noexcept -> std::string_view { - return enabled ? "\033[0m" : ""; + return enabled ? palette.reset : ""; + } + + [[nodiscard]] constexpr auto usage() const noexcept -> std::string_view { + return enabled ? palette.usage : ""; + } + + [[nodiscard]] constexpr auto heading() const noexcept -> std::string_view { + return enabled ? palette.heading : ""; + } + + [[nodiscard]] constexpr auto option() const noexcept -> std::string_view { + return enabled ? palette.option : ""; + } + + [[nodiscard]] constexpr auto metavar() const noexcept -> std::string_view { + return enabled ? palette.metavar : ""; + } + + [[nodiscard]] constexpr auto command() const noexcept -> std::string_view { + return enabled ? palette.command : ""; + } + + [[nodiscard]] constexpr auto metadata() const noexcept -> std::string_view { + return enabled ? palette.metadata : ""; + } + + [[nodiscard]] constexpr auto group() const noexcept -> std::string_view { + return enabled ? palette.group : ""; } }; @@ -102,10 +150,12 @@ inline auto is_tty() noexcept -> bool { * @param mode The requested color mode. * @return An `AnsiStyle` with `enabled` set appropriately. */ -inline auto resolve_color(ColorMode mode) noexcept -> AnsiStyle { +inline auto resolve_color(ColorMode mode, + HelpPalette palette = default_help_palette) noexcept + -> AnsiStyle { const bool on = (mode == ColorMode::always) || (mode == ColorMode::auto_ && is_tty()); - return AnsiStyle{on}; + return AnsiStyle{.enabled = on, .palette = palette}; } } // namespace detail diff --git a/include/cli/help.hh b/include/cli/help.hh index a3b303a..2214e7c 100644 --- a/include/cli/help.hh +++ b/include/cli/help.hh @@ -214,6 +214,39 @@ auto field_usage_label() -> std::string { } } +template +[[nodiscard]] +auto styled_field_usage_label(const AnsiStyle& style) -> std::string { + using F = std::remove_cvref_t; + const auto reset = style.reset(); + if constexpr (std::derived_from) { + std::string out; + if constexpr (F::short_name != '\0') { + out += style.option(); + out += std::format("-{}", F::short_name); + out += reset; + out += ", "; + } + out += style.option(); + out += std::format("--{}", F::name.view()); + out += reset; + if constexpr (!(F::nargs.min == 0 && F::nargs.max == 0)) { + out += " "; + out += style.metavar(); + out += metavar_for(); + out += reset; + } + return out; + } else if constexpr (std::derived_from) { + return std::format("{}{}{}", style.metavar(), + metavar_for(), reset); + } else if constexpr (std::derived_from) { + return std::format("{}{}{}", style.command(), F::command_name(), reset); + } else { + return std::string{}; + } +} + /** * @brief Splits a string on newline characters. * @@ -480,11 +513,12 @@ auto help_metadata(Field& field) -> std::vector { } inline auto append_help_metadata(std::string& out, - const std::vector& metadata) - -> void { + const std::vector& metadata, + const AnsiStyle& style) -> void { if (metadata.empty()) { return; } + out += style.metadata(); out += " ["; for (std::size_t i = 0; i < metadata.size(); ++i) { if (i != 0) { @@ -493,6 +527,7 @@ inline auto append_help_metadata(std::string& out, out += metadata[i]; } out += "]"; + out += style.reset(); } template @@ -518,15 +553,22 @@ auto append_group_requirements(std::string& row, std::string_view group_name) } template -auto relation_option_group_row(const GroupRelation& group) -> std::string { - std::string row = std::format(" {}:\n {} must be used together\n", - group.name, relation_name_list(group.members)); +auto relation_option_group_row(const GroupRelation& group, + const AnsiStyle& style) -> std::string { + std::string row = " "; + row += style.group(); + row += group.name; + row += style.reset(); + row += ":\n "; + row += relation_name_list(group.members); + row += " must be used together\n"; append_group_requirements(row, group.name); return row; } template -auto relation_option_group_rows() -> std::vector { +auto relation_option_group_rows(const AnsiStyle& style) + -> std::vector { std::vector rows; if constexpr (detail::HasRelations) { std::apply( @@ -535,7 +577,7 @@ auto relation_option_group_rows() -> std::vector { [&]() -> auto { using R = std::remove_cvref_t; if constexpr (std::same_as) { - rows.push_back(relation_option_group_row(rels)); + rows.push_back(relation_option_group_row(rels, style)); } }(), ...); @@ -546,20 +588,18 @@ auto relation_option_group_rows() -> std::vector { } template -auto render_help_row(Field& field, std::string_view label, std::size_t width, - std::string_view option_color, std::string_view reset_color) - -> std::string { +auto render_help_row(Field& field, std::string_view label, + std::string_view styled_label, std::size_t width, + const AnsiStyle& style) -> std::string { std::string out = " "; - out += option_color; - out += label; - out += reset_color; + out += styled_label; const auto metadata = help_metadata(field); if (!field.help_text().empty() || !metadata.empty()) { out.append(width - label.size() + 2, ' '); if (!field.help_text().empty()) { out += field.help_text(); } - append_help_metadata(out, metadata); + append_help_metadata(out, metadata, style); out += '\n'; } else { out += '\n'; @@ -623,11 +663,11 @@ auto find_description(T& value) -> std::string_view { template auto format_help_impl(T& value, std::string_view program_name, ColorMode color_mode, bool recurse, - std::string_view command_path) -> std::string { - const auto style = resolve_color(color_mode); - const std::string heading = - std::string(style.bold()) + std::string(style.underline()); - const std::string option_color = std::string(style.bold()); + std::string_view command_path, + HelpPalette palette = default_help_palette) + -> std::string { + const auto style = resolve_color(color_mode, palette); + const std::string heading = std::string(style.heading()); const std::string reset = std::string(style.reset()); std::string full_program = std::string(program_name); @@ -636,8 +676,8 @@ auto format_help_impl(T& value, std::string_view program_name, full_program += command_path; } - std::string out = - std::format("Usage: {}{}\n", full_program, command_usage_suffix(value)); + std::string out = std::format("{}Usage:{} {}{}\n", style.usage(), reset, + full_program, command_usage_suffix(value)); if (const auto description = find_description(value); !description.empty()) { out += '\n'; @@ -706,21 +746,24 @@ auto format_help_impl(T& value, std::string_view program_name, if constexpr (std::derived_from) { if (fields.hidden()) return; auto label = field_usage_label(); + auto styled_label = styled_field_usage_label(style); option_rows.emplace_back( - label, render_help_row(fields, label, option_width, - option_color, reset)); + label, render_help_row(fields, label, styled_label, + option_width, style)); } else if constexpr (std::derived_from) { if (fields.hidden()) return; auto label = field_usage_label(); + auto styled_label = styled_field_usage_label(style); positional_rows.emplace_back( - label, render_help_row(fields, label, positional_width, - option_color, reset)); + label, render_help_row(fields, label, styled_label, + positional_width, style)); } else if constexpr (std::derived_from) { if (fields.hidden()) return; auto label = field_usage_label(); + auto styled_label = styled_field_usage_label(style); command_rows.emplace_back( - label, render_help_row(fields, label, command_width, - option_color, reset)); + label, render_help_row(fields, label, styled_label, + command_width, style)); } }(), ...); @@ -731,7 +774,7 @@ auto format_help_impl(T& value, std::string_view program_name, append_rows("Positional arguments:", positional_rows); append_rows("Commands:", command_rows); - const auto option_group_rows = relation_option_group_rows(); + const auto option_group_rows = relation_option_group_rows(style); if (!option_group_rows.empty()) { out += '\n'; out += heading; @@ -754,7 +797,8 @@ auto format_help_impl(T& value, std::string_view program_name, out += '\n'; out += format_help_impl( static_cast(fields), - program_name, color_mode, true, F::command_name()); + program_name, color_mode, true, F::command_name(), + palette); } }(), ...); @@ -784,10 +828,10 @@ auto format_help_impl(T& value, std::string_view program_name, */ template auto format_help(T& value, std::string_view program_name = "program", - ColorMode color_mode = ColorMode::auto_, bool recurse = false) - -> std::string { + ColorMode color_mode = ColorMode::auto_, bool recurse = false, + HelpPalette palette = default_help_palette) -> std::string { return detail::format_help_impl(value, program_name, color_mode, recurse, - {}); + {}, palette); } } // namespace cli diff --git a/include/cli/parser.hh b/include/cli/parser.hh index a337235..207171e 100644 --- a/include/cli/parser.hh +++ b/include/cli/parser.hh @@ -772,8 +772,10 @@ struct Parser { * @param color_mode Controls ANSI color output. Default: `ColorMode::auto_`. * @return The formatted help string. */ - auto format_help(ColorMode color_mode = ColorMode::auto_) -> std::string { - return cli::format_help(scratch_, program_name_, color_mode, false); + auto format_help(ColorMode color_mode = ColorMode::auto_, + HelpPalette palette = default_help_palette) -> std::string { + return cli::format_help(scratch_, program_name_, color_mode, false, + palette); } /** @@ -785,6 +787,10 @@ struct Parser { return format_help(ColorMode::auto_, recurse_help); } + auto format_help(HelpPalette palette) -> std::string { + return format_help(ColorMode::auto_, palette); + } + /** * @brief Generates a help string with explicit color mode and recursive * subcommand help. @@ -796,6 +802,12 @@ struct Parser { return cli::format_help(scratch_, program_name_, color_mode, true); } + auto format_help(ColorMode color_mode, HelpPalette palette, RecurseHelpTag) + -> std::string { + return cli::format_help(scratch_, program_name_, color_mode, true, + palette); + } + private: T scratch_{}; diff --git a/tests/test_help.cc b/tests/test_help.cc index c622c5f..6cb0cf7 100644 --- a/tests/test_help.cc +++ b/tests/test_help.cc @@ -172,9 +172,31 @@ TEST(Help, ColorAlwaysEmitsAnsiHeadingsAndLabels) { const auto help = parser.format_help(cli::ColorMode::always); EXPECT_NE(help.find("\033[1m"), std::string::npos); - EXPECT_NE(help.find("\033[4m"), std::string::npos); + EXPECT_NE(help.find("\033[1;4;36mOptions:"), std::string::npos); EXPECT_NE(help.find("\033[0m"), std::string::npos); - EXPECT_NE(help.find("\033[1m-v, --verbose"), std::string::npos); + EXPECT_NE(help.find("\033[1;32m-v\033[0m, \033[1;32m--verbose"), + std::string::npos); + EXPECT_NE(help.find("\033[36m\033[0m"), std::string::npos); +} + +TEST(Help, CustomPaletteIsUsed) { + cli::Parser parser; + constexpr cli::HelpPalette palette{ + .usage = "\033[95m", + .heading = "\033[94m", + .option = "\033[93m", + .metavar = "\033[92m", + .command = "\033[91m", + .metadata = "\033[90m", + .group = "\033[96m", + .reset = "\033[39m", + }; + + const auto help = parser.format_help(cli::ColorMode::always, palette); + EXPECT_NE(help.find("\033[95mUsage:\033[39m"), std::string::npos); + EXPECT_NE(help.find("\033[94mOptions:\033[39m"), std::string::npos); + EXPECT_NE(help.find("\033[93m-v\033[39m, \033[93m--verbose"), + std::string::npos); } TEST(Help, ResolveColorRespectsMode) { @@ -188,6 +210,8 @@ TEST(Help, ResolveColorRespectsMode) { EXPECT_EQ(always.bold(), "\033[1m"); EXPECT_EQ(always.underline(), "\033[4m"); EXPECT_EQ(always.reset(), "\033[0m"); + EXPECT_EQ(always.heading(), "\033[1;4;36m"); + EXPECT_EQ(always.option(), "\033[1;32m"); } TEST(Help, HelpFlagTriggersHelpRequested) { From fcae96b820b92cef3063ded791deb4f7c62a918a Mon Sep 17 00:00:00 2001 From: gen740 <54583542+gen740@users.noreply.github.com> Date: Tue, 12 May 2026 14:27:54 +0900 Subject: [PATCH 4/6] refactor(cli): rename ColorMode::auto_ to ColorMode::detect Rename the color mode to avoid the underscore suffix and enable color detection for help output during error finalization. BREAKING CHANGE: ColorMode::auto_ is renamed to ColorMode::detect. --- include/cli/color.hh | 8 ++++---- include/cli/help.hh | 4 ++-- include/cli/parser.hh | 10 +++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/include/cli/color.hh b/include/cli/color.hh index 695b88a..8316e97 100644 --- a/include/cli/color.hh +++ b/include/cli/color.hh @@ -13,11 +13,11 @@ namespace cli { /** * @brief Controls whether ANSI escape codes are emitted by `format_help()`. * - * - `auto_` : emit codes only when stdout is connected to a TTY (default). + * - `detect` : emit codes only when stdout is connected to a TTY (default). * - `never` : always produce plain text. * - `always` : always emit ANSI codes regardless of terminal type. */ -enum class ColorMode { auto_, never, always }; +enum class ColorMode { detect, never, always }; /** * @brief ANSI palette used by generated help output. @@ -145,7 +145,7 @@ inline auto is_tty() noexcept -> bool { * * - `ColorMode::always` → `AnsiStyle{true}` * - `ColorMode::never` → `AnsiStyle{false}` - * - `ColorMode::auto_` → `AnsiStyle{is_tty()}` + * - `ColorMode::detect` → `AnsiStyle{is_tty()}` * * @param mode The requested color mode. * @return An `AnsiStyle` with `enabled` set appropriately. @@ -154,7 +154,7 @@ inline auto resolve_color(ColorMode mode, HelpPalette palette = default_help_palette) noexcept -> AnsiStyle { const bool on = - (mode == ColorMode::always) || (mode == ColorMode::auto_ && is_tty()); + (mode == ColorMode::always) || (mode == ColorMode::detect && is_tty()); return AnsiStyle{.enabled = on, .palette = palette}; } diff --git a/include/cli/help.hh b/include/cli/help.hh index 2214e7c..a5dd835 100644 --- a/include/cli/help.hh +++ b/include/cli/help.hh @@ -821,14 +821,14 @@ auto format_help_impl(T& value, std::string_view program_name, * @param value An instance of the argument specification. * @param program_name The program name to display in the usage line. Default: * `"program"`. - * @param color_mode Controls ANSI color output. Default: `ColorMode::auto_`. + * @param color_mode Controls ANSI color output. Default: `ColorMode::detect`. * @param recurse If `true`, also append help for each subcommand. Default: * `false`. * @return The formatted help string. */ template auto format_help(T& value, std::string_view program_name = "program", - ColorMode color_mode = ColorMode::auto_, bool recurse = false, + ColorMode color_mode = ColorMode::detect, bool recurse = false, HelpPalette palette = default_help_palette) -> std::string { return detail::format_help_impl(value, program_name, color_mode, recurse, {}, palette); diff --git a/include/cli/parser.hh b/include/cli/parser.hh index 207171e..9c4f049 100644 --- a/include/cli/parser.hh +++ b/include/cli/parser.hh @@ -769,10 +769,10 @@ struct Parser { /** * @brief Generates a help string for the argument specification. * - * @param color_mode Controls ANSI color output. Default: `ColorMode::auto_`. + * @param color_mode Controls ANSI color output. Default: `ColorMode::detect`. * @return The formatted help string. */ - auto format_help(ColorMode color_mode = ColorMode::auto_, + auto format_help(ColorMode color_mode = ColorMode::detect, HelpPalette palette = default_help_palette) -> std::string { return cli::format_help(scratch_, program_name_, color_mode, false, palette); @@ -784,11 +784,11 @@ struct Parser { * @return The formatted help string with all subcommand sections appended. */ auto format_help(RecurseHelpTag) -> std::string { - return format_help(ColorMode::auto_, recurse_help); + return format_help(ColorMode::detect, recurse_help); } auto format_help(HelpPalette palette) -> std::string { - return format_help(ColorMode::auto_, palette); + return format_help(ColorMode::detect, palette); } /** @@ -1137,7 +1137,7 @@ struct Parser { auto finalize_special_error(ParseError error) -> ParseError { if (error.code == ErrorCode::help_requested && error.detail.empty()) { - error.detail = format_help(ColorMode::never); + error.detail = format_help(ColorMode::detect); } return error; } From 3098dfbb9c49f886040573303988808cfb1fb202 Mon Sep 17 00:00:00 2001 From: gen740 <54583542+gen740@users.noreply.github.com> Date: Tue, 12 May 2026 15:22:10 +0900 Subject: [PATCH 5/6] feat(docs): add ANSI color support to example playground Use the ansi_up library to parse and render ANSI escape sequences in the ExamplePlayground component output. --- docs/package-lock.json | 10 ++++++++++ docs/package.json | 1 + docs/src/components/ExamplePlayground.tsx | 14 +++++++++++++- flake.nix | 2 +- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 3a3b5b2..2942081 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -13,6 +13,7 @@ "@docusaurus/plugin-google-gtag": "^3.10.1", "@docusaurus/preset-classic": "3.10.1", "@mdx-js/react": "^3.0.0", + "ansi_up": "^6.0.6", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", "react": "^19.0.0", @@ -6796,6 +6797,15 @@ "algoliasearch": ">= 3.1 < 6" } }, + "node_modules/ansi_up": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/ansi_up/-/ansi_up-6.0.6.tgz", + "integrity": "sha512-yIa1x3Ecf8jWP4UWEunNjqNX6gzE4vg2gGz+xqRGY+TBSucnYp6RRdPV4brmtg6bQ1ljD48mZ5iGSEj7QEpRKA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ansi-align": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", diff --git a/docs/package.json b/docs/package.json index 94ea4af..4c04551 100644 --- a/docs/package.json +++ b/docs/package.json @@ -22,6 +22,7 @@ "@docusaurus/plugin-google-gtag": "^3.10.1", "@docusaurus/preset-classic": "3.10.1", "@mdx-js/react": "^3.0.0", + "ansi_up": "^6.0.6", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", "react": "^19.0.0", diff --git a/docs/src/components/ExamplePlayground.tsx b/docs/src/components/ExamplePlayground.tsx index 44c27e1..0bd2ca2 100644 --- a/docs/src/components/ExamplePlayground.tsx +++ b/docs/src/components/ExamplePlayground.tsx @@ -1,5 +1,13 @@ import React, {useEffect, useRef, useState} from 'react'; import useBaseUrl from '@docusaurus/useBaseUrl'; +import {AnsiUp} from 'ansi_up'; + +const ansi = new AnsiUp(); +ansi.escape_html = true; + +function ansiToHtml(text: string): string { + return ansi.ansi_to_html(text); +} function splitArgs(input: string): string[] { const args: string[] = []; @@ -361,7 +369,11 @@ export function ExamplePlayground({ )} {entry.output && ( -
{entry.output}
+
               )}
             
           ))}
diff --git a/flake.nix b/flake.nix
index c25bf3f..518f6ef 100644
--- a/flake.nix
+++ b/flake.nix
@@ -102,7 +102,7 @@
               npmDeps = pkgs.fetchNpmDeps {
                 name = "cli20-docs-0.0.0-npm-deps";
                 src = ./docs;
-                hash = "sha256-DOI2KP2qq8zbUjX3Kktgfe16n567pNkkGz5ABGSgSdo=";
+                hash = "sha256-ntnQTTWdih6fLx6QLqarpJQ6pIOdDWRxmysDQQVWswQ=";
               };
               nativeBuildInputs = [
                 pkgs.emscripten

From 86a5e31a7980d68310eaf151fea9433da4cb141d Mon Sep 17 00:00:00 2001
From: gen740 <54583542+gen740@users.noreply.github.com>
Date: Tue, 12 May 2026 19:05:13 +0900
Subject: [PATCH 6/6] Fix relation diagnostics and help wrapping

---
 include/cli/help.hh     | 22 +++++++++++++++++-----
 include/cli/parser.hh   | 40 +++++++++++++++++++++++++++++++++++-----
 include/cli/relation.hh |  5 ++++-
 tests/test_help.cc      | 16 ++++++++++++++++
 tests/test_parse.cc     | 40 ++++++++++++++++++++++++++++++++++++++++
 5 files changed, 112 insertions(+), 11 deletions(-)

diff --git a/include/cli/help.hh b/include/cli/help.hh
index a5dd835..a4b9c40 100644
--- a/include/cli/help.hh
+++ b/include/cli/help.hh
@@ -530,6 +530,22 @@ inline auto append_help_metadata(std::string& out,
   out += style.reset();
 }
 
+inline auto append_row_description(std::string& out,
+                                   std::string_view description,
+                                   const std::vector& metadata,
+                                   const AnsiStyle& style, std::size_t indent)
+    -> void {
+  auto lines = split_lines(description);
+  out += lines.front();
+  append_help_metadata(out, metadata, style);
+  out += '\n';
+  for (std::size_t i = 1; i < lines.size(); ++i) {
+    out.append(indent, ' ');
+    out += lines[i];
+    out += '\n';
+  }
+}
+
 template 
 auto append_group_requirements(std::string& row, std::string_view group_name)
     -> void {
@@ -596,11 +612,7 @@ auto render_help_row(Field& field, std::string_view label,
   const auto metadata = help_metadata(field);
   if (!field.help_text().empty() || !metadata.empty()) {
     out.append(width - label.size() + 2, ' ');
-    if (!field.help_text().empty()) {
-      out += field.help_text();
-    }
-    append_help_metadata(out, metadata, style);
-    out += '\n';
+    append_row_description(out, field.help_text(), metadata, style, width + 4);
   } else {
     out += '\n';
   }
diff --git a/include/cli/parser.hh b/include/cli/parser.hh
index 9c4f049..2bc7d3b 100644
--- a/include/cli/parser.hh
+++ b/include/cli/parser.hh
@@ -1011,6 +1011,34 @@ struct Parser {
     return out;
   }
 
+  static auto relation_group_exists(std::string_view name) -> bool {
+    if constexpr (!detail::HasRelations) {
+      return false;
+    } else {
+      bool found = false;
+      std::apply(
+          [&](auto... rels) -> auto {
+            (
+                [&]() -> auto {
+                  using R = std::remove_cvref_t;
+                  if constexpr (std::same_as) {
+                    if (rels.name == name) found = true;
+                  }
+                }(),
+                ...);
+          },
+          T::relations.items);
+      return found;
+    }
+  }
+
+  static auto relation_operand_label(std::string_view operand) -> std::string {
+    if (relation_group_exists(operand)) {
+      return std::string(operand);
+    }
+    return "--" + std::string(operand);
+  }
+
   static auto relation_operand_present(T& val, std::string_view name) -> bool {
     bool found_field = false;
     bool present = false;
@@ -1037,8 +1065,9 @@ struct Parser {
                   if constexpr (std::same_as) {
                     if (!found_group && rels.name == name) {
                       found_group = true;
+                      group_present = true;
                       for (auto member : rels.members) {
-                        group_present = group_present ||
+                        group_present = group_present &&
                                         relation_operand_present(val, member);
                       }
                     }
@@ -1083,8 +1112,8 @@ struct Parser {
                       res = ActionResult::fail(ParseError{
                           .code = ErrorCode::mutually_exclusive,
                           .kind = ErrorKind::validation,
-                          .subject = "--" + std::string(rels.left) + ", --" +
-                                     std::string(rels.right),
+                          .subject = relation_operand_label(rels.left) + ", " +
+                                     relation_operand_label(rels.right),
                           .detail =
                               "these arguments cannot be provided together",
                       });
@@ -1095,8 +1124,9 @@ struct Parser {
                       res = ActionResult::fail(ParseError{
                           .code = ErrorCode::dependency_missing,
                           .kind = ErrorKind::validation,
-                          .subject = "--" + std::string(rels.source),
-                          .detail = "requires --" + std::string(rels.target),
+                          .subject = relation_operand_label(rels.source),
+                          .detail =
+                              "requires " + relation_operand_label(rels.target),
                       });
                     }
                   }
diff --git a/include/cli/relation.hh b/include/cli/relation.hh
index 5e8495d..60c12ba 100644
--- a/include/cli/relation.hh
+++ b/include/cli/relation.hh
@@ -15,7 +15,10 @@ struct NameList {
 
   constexpr NameList() = default;
   constexpr NameList(std::initializer_list names) {
-    size = names.size() < Max ? names.size() : Max;
+    if (names.size() > Max) {
+      throw "cli::NameList capacity exceeded";
+    }
+    size = names.size();
     auto it = names.begin();
     for (std::size_t i = 0; i < size; ++i, ++it) {
       values[i] = *it;
diff --git a/tests/test_help.cc b/tests/test_help.cc
index 6cb0cf7..bba4561 100644
--- a/tests/test_help.cc
+++ b/tests/test_help.cc
@@ -39,6 +39,13 @@ struct OptionsOnlyArgs {
       {.help = "Enable verbose logging", .presence = cli::optional}};
 };
 
+struct MultilineHelpArgs {
+  cli::StringOption<"mode"> mode{{
+      .help = "First line\nSecond line",
+      .default_value = std::string{"auto"},
+  }};
+};
+
 struct CommandsOnlyArgs {
   cli::Command<"build", BuildArgs> build{{.help = "Run the build step"}};
 };
@@ -137,6 +144,15 @@ TEST(Help, FlagDoesNotShowMetavar) {
   EXPECT_EQ(line.find(""), std::string::npos);
 }
 
+TEST(Help, MultilineHelpTextKeepsContinuationIndented) {
+  cli::Parser parser;
+
+  const auto help = parser.format_help(cli::ColorMode::never);
+  EXPECT_NE(help.find("--mode   First line [default: auto]\n"
+                      "                   Second line\n"),
+            std::string::npos);
+}
+
 TEST(Help, EmptySectionsAreOmitted) {
   cli::Parser options_only;
   const auto options_help = options_only.format_help(cli::ColorMode::never);
diff --git a/tests/test_parse.cc b/tests/test_parse.cc
index 9ff7b7b..529ef3a 100644
--- a/tests/test_parse.cc
+++ b/tests/test_parse.cc
@@ -3,6 +3,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 
 #ifdef _WIN32
@@ -94,6 +95,18 @@ struct RelationArgs {
       cli::conflicts("json", "markdown"), cli::depends_on("deploy", "profile"));
 };
 
+struct GroupOperandRelationArgs {
+  cli::StringOption<"user"> user;
+  cli::StringOption<"password"> password;
+  cli::Flag<"token"> token;
+  cli::StringOption<"profile"> profile;
+
+  static constexpr auto relations = cli::relations(
+      cli::group({.name = "legacy_auth", .members = {"user", "password"}}),
+      cli::conflicts("legacy_auth", "token"),
+      cli::depends_on("legacy_auth", "profile"));
+};
+
 struct UnknownRelationTargetArgs {
   cli::Flag<"json"> json;
 
@@ -297,6 +310,28 @@ TEST(Parse, RelationDependsOnRequiresTarget) {
   EXPECT_EQ(result.error.subject, "--deploy");
 }
 
+TEST(Parse, RelationConflictFormatsGroupOperandWithoutOptionPrefix) {
+  auto args =
+      argv({"prog", "--user", "alice", "--password", "secret", "--token"});
+  auto result = cli::Parser{}.parse(
+      std::span(args), 1);
+
+  ASSERT_TRUE(result.has_error());
+  EXPECT_EQ(result.error.code, cli::ErrorCode::mutually_exclusive);
+  EXPECT_EQ(result.error.subject, "legacy_auth, --token");
+}
+
+TEST(Parse, RelationDependsOnFormatsGroupOperandWithoutOptionPrefix) {
+  auto args = argv({"prog", "--user", "alice", "--password", "secret"});
+  auto result = cli::Parser{}.parse(
+      std::span(args), 1);
+
+  ASSERT_TRUE(result.has_error());
+  EXPECT_EQ(result.error.code, cli::ErrorCode::dependency_missing);
+  EXPECT_EQ(result.error.subject, "legacy_auth");
+  EXPECT_EQ(result.error.detail, "requires --profile");
+}
+
 TEST(Parse, RelationsPassWhenSatisfied) {
   auto args = argv({"prog", "--user", "alice", "--password", "secret",
                     "--deploy", "--profile", "prod", "--json"});
@@ -306,6 +341,11 @@ TEST(Parse, RelationsPassWhenSatisfied) {
   ASSERT_TRUE(result.has_value());
 }
 
+TEST(Parse, NameListRejectsTooManyNames) {
+  EXPECT_ANY_THROW(
+      [] { std::ignore = cli::NameList<2>({"one", "two", "three"}); }());
+}
+
 #ifdef _WIN32
 namespace {