From 857b10b003a4151491fabdb0cf8005f0584b5cf2 Mon Sep 17 00:00:00 2001 From: Centimo Date: Sun, 8 Feb 2026 00:59:06 +0300 Subject: [PATCH 1/7] Add CLI argument parser (rfl::cli::read) Parse command-line arguments (--key=value) into reflectable structs. Supports nested structs, optional, vector, enum, Flatten, Rename. Automatic snake_case to kebab-case conversion via SnakeCaseToKebabCase processor. --- include/rfl.hpp | 1 + include/rfl/SnakeCaseToKebabCase.hpp | 37 ++++ include/rfl/cli.hpp | 10 + include/rfl/cli/Parser.hpp | 15 ++ include/rfl/cli/Reader.hpp | 258 ++++++++++++++++++++++++ include/rfl/cli/parse_argv.hpp | 42 ++++ include/rfl/cli/read.hpp | 27 +++ include/rfl/internal/transform_case.hpp | 17 ++ tests/CMakeLists.txt | 1 + tests/cli/CMakeLists.txt | 21 ++ tests/cli/test_basic.cpp | 45 +++++ tests/cli/test_enum.cpp | 27 +++ tests/cli/test_errors.cpp | 31 +++ tests/cli/test_flatten.cpp | 32 +++ tests/cli/test_kebab_case.cpp | 28 +++ tests/cli/test_nested.cpp | 32 +++ tests/cli/test_optional.cpp | 43 ++++ tests/cli/test_rename.cpp | 25 +++ tests/cli/test_vector.cpp | 46 +++++ 19 files changed, 738 insertions(+) create mode 100644 include/rfl/SnakeCaseToKebabCase.hpp create mode 100644 include/rfl/cli.hpp create mode 100644 include/rfl/cli/Parser.hpp create mode 100644 include/rfl/cli/Reader.hpp create mode 100644 include/rfl/cli/parse_argv.hpp create mode 100644 include/rfl/cli/read.hpp create mode 100644 tests/cli/CMakeLists.txt create mode 100644 tests/cli/test_basic.cpp create mode 100644 tests/cli/test_enum.cpp create mode 100644 tests/cli/test_errors.cpp create mode 100644 tests/cli/test_flatten.cpp create mode 100644 tests/cli/test_kebab_case.cpp create mode 100644 tests/cli/test_nested.cpp create mode 100644 tests/cli/test_optional.cpp create mode 100644 tests/cli/test_rename.cpp create mode 100644 tests/cli/test_vector.cpp diff --git a/include/rfl.hpp b/include/rfl.hpp index b57542b6..b4e8bba0 100644 --- a/include/rfl.hpp +++ b/include/rfl.hpp @@ -40,6 +40,7 @@ #include "rfl/Size.hpp" #include "rfl/Skip.hpp" #include "rfl/SnakeCaseToCamelCase.hpp" +#include "rfl/SnakeCaseToKebabCase.hpp" #include "rfl/SnakeCaseToPascalCase.hpp" #include "rfl/CamelCaseToSnakeCase.hpp" #include "rfl/TaggedUnion.hpp" diff --git a/include/rfl/SnakeCaseToKebabCase.hpp b/include/rfl/SnakeCaseToKebabCase.hpp new file mode 100644 index 00000000..86fdac8a --- /dev/null +++ b/include/rfl/SnakeCaseToKebabCase.hpp @@ -0,0 +1,37 @@ +#ifndef RFL_SNAKECASETOKEBABCASE_HPP_ +#define RFL_SNAKECASETOKEBABCASE_HPP_ + +#include "Field.hpp" +#include "internal/is_rename.hpp" +#include "internal/transform_case.hpp" + +namespace rfl { + +struct SnakeCaseToKebabCase { + public: + /// Replaces all instances of snake_case field names with kebab-case. + template + static auto process(const auto& _named_tuple) { + return _named_tuple.transform([](const FieldType& _f) { + if constexpr (FieldType::name() != "xml_content" && + !internal::is_rename_v) { + return handle_one_field(_f); + } else { + return _f; + } + }); + } + + private: + template + static auto handle_one_field(const FieldType& _f) { + using NewFieldType = + Field(), + typename FieldType::Type>; + return NewFieldType(_f.value()); + } +}; + +} // namespace rfl + +#endif diff --git a/include/rfl/cli.hpp b/include/rfl/cli.hpp new file mode 100644 index 00000000..c2eecad8 --- /dev/null +++ b/include/rfl/cli.hpp @@ -0,0 +1,10 @@ +#ifndef RFL_CLI_HPP_ +#define RFL_CLI_HPP_ + +#include "../rfl.hpp" +#include "cli/Parser.hpp" +#include "cli/Reader.hpp" +#include "cli/parse_argv.hpp" +#include "cli/read.hpp" + +#endif diff --git a/include/rfl/cli/Parser.hpp b/include/rfl/cli/Parser.hpp new file mode 100644 index 00000000..ae82964d --- /dev/null +++ b/include/rfl/cli/Parser.hpp @@ -0,0 +1,15 @@ +#ifndef RFL_CLI_PARSER_HPP_ +#define RFL_CLI_PARSER_HPP_ + +#include "../generic/Writer.hpp" +#include "../parsing/Parser.hpp" +#include "Reader.hpp" + +namespace rfl::cli { + +template +using Parser = parsing::Parser; + +} // namespace rfl::cli + +#endif diff --git a/include/rfl/cli/Reader.hpp b/include/rfl/cli/Reader.hpp new file mode 100644 index 00000000..b3ca096a --- /dev/null +++ b/include/rfl/cli/Reader.hpp @@ -0,0 +1,258 @@ +#ifndef RFL_CLI_READER_HPP_ +#define RFL_CLI_READER_HPP_ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../Result.hpp" +#include "../always_false.hpp" + +namespace rfl::cli { + +static constexpr char path_separator = '.'; +static constexpr char array_delimiter = ','; + +struct CliVarType { + const std::map* const args = nullptr; + const std::string path; + const std::optional direct_value; +}; + +struct CliObjectType { + const std::map* const args = nullptr; + const std::string prefix; +}; + +struct CliArrayType { + const std::vector values; +}; + +// --- Constrained overloads for string-to-type parsing --- + +template requires std::same_as +rfl::Result parse_value( + const std::string& _str, const std::string& +) noexcept { + return _str; +} + +template requires std::same_as +rfl::Result parse_value( + const std::string& _str, const std::string& _path +) noexcept { + if (_str.empty() || _str == "true" || _str == "1") { + return true; + } + if (_str == "false" || _str == "0") { + return false; + } + return error( + "Could not cast '" + _str + "' to boolean for key '" + _path + "'."); +} + +template requires (std::is_floating_point_v) +rfl::Result parse_value( + const std::string& _str, const std::string& _path +) noexcept { + try { + return static_cast(std::stod(_str)); + } + catch (...) { + return error( + "Could not cast '" + _str + "' to floating point for key '" + _path + "'."); + } +} + +template requires (std::is_unsigned_v && !std::same_as) +rfl::Result parse_value( + const std::string& _str, const std::string& _path +) noexcept { + try { + return static_cast(std::stoull(_str)); + } + catch (...) { + return error( + "Could not cast '" + _str + "' to unsigned integer for key '" + _path + "'."); + } +} + +template requires (std::is_integral_v && std::is_signed_v) +rfl::Result parse_value( + const std::string& _str, const std::string& _path +) noexcept { + try { + return static_cast(std::stoll(_str)); + } + catch (...) { + return error( + "Could not cast '" + _str + "' to integer for key '" + _path + "'."); + } +} + +struct Reader { + using InputArrayType = CliArrayType; + using InputObjectType = CliObjectType; + using InputVarType = CliVarType; + + template + static constexpr bool has_custom_constructor = false; + + rfl::Result get_field_from_array( + const size_t _idx, const InputArrayType& _arr) const noexcept { + if (_idx >= _arr.values.size()) { + return error("Index " + std::to_string(_idx) + " out of bounds."); + } + return InputVarType{nullptr, "", _arr.values[_idx]}; + } + + rfl::Result get_field_from_object( + const std::string& _name, const InputObjectType& _obj) const noexcept { + const auto child_path = _obj.prefix.empty() + ? _name + : _obj.prefix + _name; + return InputVarType{_obj.args, child_path, std::nullopt}; + } + + bool is_empty(const InputVarType& _var) const noexcept { + if (_var.direct_value) { + return false; + } + if (!_var.args) { + return true; + } + if (_var.args->count(_var.path)) { + return false; + } + const auto prefix = _var.path + path_separator; + const auto it = _var.args->lower_bound(prefix); + return it == _var.args->end() + || it->first.substr(0, prefix.size()) != prefix; + } + + template + std::optional read_array( + const ArrayReader& _array_reader, + const InputArrayType& _arr + ) const noexcept { + for (const auto& val : _arr.values) { + const auto err = _array_reader.read( + InputVarType{nullptr, "", val}); + if (err) { + return err; + } + } + return std::nullopt; + } + + template + std::optional read_object( + const ObjectReader& _object_reader, + const InputObjectType& _obj + ) const noexcept { + std::set seen; + auto it = _obj.prefix.empty() + ? _obj.args->begin() + : _obj.args->lower_bound(_obj.prefix); + while (it != _obj.args->end()) { + if (!_obj.prefix.empty() + && it->first.substr(0, _obj.prefix.size()) != _obj.prefix) { + break; + } + const auto rest = std::string_view(it->first).substr(_obj.prefix.size()); + const auto separator_pos = rest.find(path_separator); + const auto child = std::string( + separator_pos == std::string_view::npos + ? rest + : rest.substr(0, separator_pos)); + if (!child.empty() && seen.insert(child).second) { + const auto child_path = _obj.prefix + child; + _object_reader.read( + std::string_view(child), + InputVarType{_obj.args, child_path, std::nullopt}); + } + ++it; + } + return std::nullopt; + } + + template + rfl::Result to_basic_type(const InputVarType& _var) const noexcept { + const auto str = get_value(_var); + if (!str) { + return error("No value for key '" + _var.path + "'."); + } + return parse_value(*str, _var.path); + } + + rfl::Result to_array( + const InputVarType& _var) const noexcept { + const auto str = get_value(_var); + if (!str) { + return InputArrayType{{}}; + } + return InputArrayType{split(*str, array_delimiter)}; + } + + rfl::Result to_object( + const InputVarType& _var) const noexcept { + const auto prefix = _var.path.empty() + ? std::string("") + : _var.path + path_separator; + return InputObjectType{_var.args, prefix}; + } + + template + rfl::Result use_custom_constructor( + const InputVarType& + ) const noexcept { + return error("Custom constructors are not supported for CLI parsing."); + } + + private: + static std::optional get_value( + const InputVarType& _var + ) noexcept { + if (_var.direct_value) { + return *_var.direct_value; + } + if (!_var.args) { + return std::nullopt; + } + const auto it = _var.args->find(_var.path); + if (it == _var.args->end()) { + return std::nullopt; + } + return it->second; + } + + static std::vector split( + const std::string& _str, char _delim + ) { + std::vector result; + if (_str.empty()) { + return result; + } + size_t start = 0; + while (true) { + const auto pos = _str.find(_delim, start); + if (pos == std::string::npos) { + result.emplace_back(_str.substr(start)); + break; + } + result.emplace_back(_str.substr(start, pos - start)); + start = pos + 1; + } + return result; + } + +}; + +} // namespace rfl::cli + +#endif diff --git a/include/rfl/cli/parse_argv.hpp b/include/rfl/cli/parse_argv.hpp new file mode 100644 index 00000000..32b583eb --- /dev/null +++ b/include/rfl/cli/parse_argv.hpp @@ -0,0 +1,42 @@ +#ifndef RFL_CLI_PARSE_ARGV_HPP_ +#define RFL_CLI_PARSE_ARGV_HPP_ + +#include +#include +#include +#include + +#include "../Result.hpp" + +namespace rfl::cli { + +/// Parses command-line arguments into a flat key-value map. +/// Expects format: --key=value or --flag (boolean). +inline rfl::Result> parse_argv( + int argc, char* argv[]) { + std::map result; + if (argc <= 1) { + return result; + } + for (const std::string_view arg_raw : std::span(argv + 1, argc - 1)) { + if (arg_raw.size() < 2 || arg_raw[0] != '-' || arg_raw[1] != '-') { + return error( + "Expected argument starting with '--', got: " + std::string(arg_raw)); + } + const auto arg = arg_raw.substr(2); + const auto eq = arg.find('='); + auto key = std::string(eq == std::string_view::npos ? arg : arg.substr(0, eq)); + auto val = std::string(eq == std::string_view::npos ? "" : arg.substr(eq + 1)); + if (key.empty()) { + return error("Empty key in argument: --" + std::string(arg)); + } + if (!result.emplace(std::move(key), std::move(val)).second) { + return error("Duplicate argument: --" + std::string(key)); + } + } + return result; +} + +} // namespace rfl::cli + +#endif diff --git a/include/rfl/cli/read.hpp b/include/rfl/cli/read.hpp new file mode 100644 index 00000000..3f061673 --- /dev/null +++ b/include/rfl/cli/read.hpp @@ -0,0 +1,27 @@ +#ifndef RFL_CLI_READ_HPP_ +#define RFL_CLI_READ_HPP_ + +#include "../Processors.hpp" +#include "../SnakeCaseToKebabCase.hpp" +#include "Parser.hpp" +#include "Reader.hpp" +#include "parse_argv.hpp" + +namespace rfl::cli { + +/// Parses command-line arguments into a struct using reflection. +/// Field names are automatically converted from snake_case to kebab-case. +/// Example: struct field `host_name` matches CLI argument `--host-name`. +template +rfl::Result read(int argc, char* argv[]) { + return parse_argv(argc, argv).and_then( + [](auto _args) -> rfl::Result { + const auto r = Reader(); + const auto var = CliVarType{&_args, "", std::nullopt}; + return Parser>::read(r, var); + }); +} + +} // namespace rfl::cli + +#endif diff --git a/include/rfl/internal/transform_case.hpp b/include/rfl/internal/transform_case.hpp index fc10a4d3..eedc0f16 100644 --- a/include/rfl/internal/transform_case.hpp +++ b/include/rfl/internal/transform_case.hpp @@ -72,6 +72,23 @@ consteval auto transform_camel_case() { return transform_camel_case<_name, _i + 1, chars..., _name.arr_[_i]>(); } } +/// Transforms the field name from snake_case to kebab-case. +template +consteval auto transform_snake_to_kebab() { + if constexpr (_i == _name.arr_.size()) { + return StringLiteral(chars...); + + } else if constexpr (_name.arr_[_i] == '\0') { + return transform_snake_to_kebab<_name, _name.arr_.size(), chars...>(); + + } else if constexpr (_name.arr_[_i] == '_') { + return transform_snake_to_kebab<_name, _i + 1, chars..., '-'>(); + + } else { + return transform_snake_to_kebab<_name, _i + 1, chars..., _name.arr_[_i]>(); + } +} + } // namespace rfl::internal #endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bfcab240..b6d2e1e4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -10,6 +10,7 @@ if (REFLECTCPP_JSON) add_subdirectory(generic) add_subdirectory(json) add_subdirectory(json_c_arrays_and_inheritance) + add_subdirectory(cli) endif () if (REFLECTCPP_AVRO) diff --git a/tests/cli/CMakeLists.txt b/tests/cli/CMakeLists.txt new file mode 100644 index 00000000..73bd38dd --- /dev/null +++ b/tests/cli/CMakeLists.txt @@ -0,0 +1,21 @@ +project(reflect-cpp-cli-tests) + +file(GLOB_RECURSE SOURCES CONFIGURE_DEPENDS "*.cpp") + +add_executable( + reflect-cpp-cli-tests + ${SOURCES} +) +target_precompile_headers(reflect-cpp-cli-tests PRIVATE [["rfl.hpp"]] ) + +target_include_directories(reflect-cpp-cli-tests SYSTEM PRIVATE "${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/include") + +target_link_libraries(reflect-cpp-cli-tests PRIVATE reflectcpp_tests_crt) + +add_custom_command(TARGET reflect-cpp-cli-tests POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy -t $ $ + COMMAND_EXPAND_LISTS +) + +find_package(GTest) +gtest_discover_tests(reflect-cpp-cli-tests) diff --git a/tests/cli/test_basic.cpp b/tests/cli/test_basic.cpp new file mode 100644 index 00000000..615f2d09 --- /dev/null +++ b/tests/cli/test_basic.cpp @@ -0,0 +1,45 @@ +#include +#include +#include +#include + +namespace test_basic { + +struct Config { + std::string host; + int port; + double rate; + bool verbose; +}; + +TEST(cli, test_basic) { + const char* args[] = { + "program", + "--host=localhost", + "--port=8080", + "--rate=1.5", + "--verbose" + }; + const auto result = rfl::cli::read(5, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().host, "localhost"); + EXPECT_EQ(result.value().port, 8080); + EXPECT_DOUBLE_EQ(result.value().rate, 1.5); + EXPECT_TRUE(result.value().verbose); +} + +TEST(cli, test_basic_bool_explicit_true) { + const char* args[] = {"program", "--host=x", "--port=1", "--rate=0", "--verbose=true"}; + const auto result = rfl::cli::read(5, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_TRUE(result.value().verbose); +} + +TEST(cli, test_basic_bool_false) { + const char* args[] = {"program", "--host=x", "--port=1", "--rate=0", "--verbose=false"}; + const auto result = rfl::cli::read(5, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_FALSE(result.value().verbose); +} + +} // namespace test_basic diff --git a/tests/cli/test_enum.cpp b/tests/cli/test_enum.cpp new file mode 100644 index 00000000..29c6f033 --- /dev/null +++ b/tests/cli/test_enum.cpp @@ -0,0 +1,27 @@ +#include +#include +#include +#include + +namespace test_enum { + +enum class Mode { debug, release, test }; + +struct Config { + std::string name; + Mode mode; +}; + +TEST(cli, test_enum) { + const char* args[] = { + "program", + "--name=app", + "--mode=release" + }; + const auto result = rfl::cli::read(3, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().name, "app"); + EXPECT_EQ(result.value().mode, Mode::release); +} + +} // namespace test_enum diff --git a/tests/cli/test_errors.cpp b/tests/cli/test_errors.cpp new file mode 100644 index 00000000..0570191c --- /dev/null +++ b/tests/cli/test_errors.cpp @@ -0,0 +1,31 @@ +#include +#include +#include +#include + +namespace test_errors { + +struct Config { + std::string name; + int port; +}; + +TEST(cli, test_error_invalid_arg_format) { + const char* args[] = {"program", "not-a-flag"}; + const auto result = rfl::cli::read(2, const_cast(args)); + EXPECT_FALSE(result); +} + +TEST(cli, test_error_invalid_int) { + const char* args[] = {"program", "--name=test", "--port=abc"}; + const auto result = rfl::cli::read(3, const_cast(args)); + EXPECT_FALSE(result); +} + +TEST(cli, test_error_duplicate_key) { + const char* args[] = {"program", "--name=a", "--name=b", "--port=1"}; + const auto result = rfl::cli::read(4, const_cast(args)); + EXPECT_FALSE(result); +} + +} // namespace test_errors diff --git a/tests/cli/test_flatten.cpp b/tests/cli/test_flatten.cpp new file mode 100644 index 00000000..53f763a0 --- /dev/null +++ b/tests/cli/test_flatten.cpp @@ -0,0 +1,32 @@ +#include +#include +#include +#include + +namespace test_flatten { + +struct Address { + std::string city; + int zip; +}; + +struct Person { + std::string name; + rfl::Flatten
address; +}; + +TEST(cli, test_flatten) { + const char* args[] = { + "program", + "--name=Alice", + "--city=Springfield", + "--zip=12345" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().name, "Alice"); + EXPECT_EQ(result.value().address().city, "Springfield"); + EXPECT_EQ(result.value().address().zip, 12345); +} + +} // namespace test_flatten diff --git a/tests/cli/test_kebab_case.cpp b/tests/cli/test_kebab_case.cpp new file mode 100644 index 00000000..33a18a06 --- /dev/null +++ b/tests/cli/test_kebab_case.cpp @@ -0,0 +1,28 @@ +#include +#include +#include +#include + +namespace test_kebab_case { + +struct Config { + std::string host_name; + int max_retries; + bool dry_run; +}; + +TEST(cli, test_kebab_case_conversion) { + const char* args[] = { + "program", + "--host-name=server1", + "--max-retries=3", + "--dry-run" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().host_name, "server1"); + EXPECT_EQ(result.value().max_retries, 3); + EXPECT_TRUE(result.value().dry_run); +} + +} // namespace test_kebab_case diff --git a/tests/cli/test_nested.cpp b/tests/cli/test_nested.cpp new file mode 100644 index 00000000..a09f80e4 --- /dev/null +++ b/tests/cli/test_nested.cpp @@ -0,0 +1,32 @@ +#include +#include +#include +#include + +namespace test_nested { + +struct Database { + std::string host; + int port; +}; + +struct Server { + std::string name; + Database database; +}; + +TEST(cli, test_nested) { + const char* args[] = { + "program", + "--name=myserver", + "--database.host=db.local", + "--database.port=5432" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().name, "myserver"); + EXPECT_EQ(result.value().database.host, "db.local"); + EXPECT_EQ(result.value().database.port, 5432); +} + +} // namespace test_nested diff --git a/tests/cli/test_optional.cpp b/tests/cli/test_optional.cpp new file mode 100644 index 00000000..1a5e1217 --- /dev/null +++ b/tests/cli/test_optional.cpp @@ -0,0 +1,43 @@ +#include +#include +#include +#include +#include + +namespace test_optional { + +struct Config { + std::string name; + std::optional port; + std::optional label; +}; + +TEST(cli, test_optional_present) { + const char* args[] = { + "program", + "--name=test", + "--port=3000", + "--label=dev" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().name, "test"); + ASSERT_TRUE(result.value().port.has_value()); + EXPECT_EQ(*result.value().port, 3000); + ASSERT_TRUE(result.value().label.has_value()); + EXPECT_EQ(*result.value().label, "dev"); +} + +TEST(cli, test_optional_absent) { + const char* args[] = { + "program", + "--name=test" + }; + const auto result = rfl::cli::read(2, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().name, "test"); + EXPECT_FALSE(result.value().port.has_value()); + EXPECT_FALSE(result.value().label.has_value()); +} + +} // namespace test_optional diff --git a/tests/cli/test_rename.cpp b/tests/cli/test_rename.cpp new file mode 100644 index 00000000..39fe709e --- /dev/null +++ b/tests/cli/test_rename.cpp @@ -0,0 +1,25 @@ +#include +#include +#include +#include + +namespace test_rename { + +struct Config { + rfl::Rename<"output-dir", std::string> output_dir; + int count; +}; + +TEST(cli, test_rename) { + const char* args[] = { + "program", + "--output-dir=/tmp/out", + "--count=5" + }; + const auto result = rfl::cli::read(3, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().output_dir(), "/tmp/out"); + EXPECT_EQ(result.value().count, 5); +} + +} // namespace test_rename diff --git a/tests/cli/test_vector.cpp b/tests/cli/test_vector.cpp new file mode 100644 index 00000000..bf098e08 --- /dev/null +++ b/tests/cli/test_vector.cpp @@ -0,0 +1,46 @@ +#include +#include +#include +#include +#include + +namespace test_vector { + +struct Config { + std::string name; + std::vector tags; +}; + +TEST(cli, test_vector) { + const char* args[] = { + "program", + "--name=app", + "--tags=dev,prod,staging" + }; + const auto result = rfl::cli::read(3, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().name, "app"); + ASSERT_EQ(result.value().tags.size(), 3u); + EXPECT_EQ(result.value().tags[0], "dev"); + EXPECT_EQ(result.value().tags[1], "prod"); + EXPECT_EQ(result.value().tags[2], "staging"); +} + +struct IntConfig { + std::vector values; +}; + +TEST(cli, test_vector_int) { + const char* args[] = { + "program", + "--values=1,2,3" + }; + const auto result = rfl::cli::read(2, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + ASSERT_EQ(result.value().values.size(), 3u); + EXPECT_EQ(result.value().values[0], 1); + EXPECT_EQ(result.value().values[1], 2); + EXPECT_EQ(result.value().values[2], 3); +} + +} // namespace test_vector From 31d31f6af494e7a2c8f1459adb973fea774e6a4c Mon Sep 17 00:00:00 2001 From: Centimo Date: Wed, 11 Feb 2026 03:36:00 +0300 Subject: [PATCH 2/7] Add Positional and Short<_name, T> wrappers for CLI module Positional marks fields as positional CLI arguments (no -- prefix). Short<_name, T> adds short aliases (e.g. -p for --port). Both wrappers are transparent for non-CLI formats (JSON, YAML, etc.) via Parser specializations that delegate to the inner type. parse_argv now categorizes args into named/short/positional buckets. resolve_args merges them into a flat map using compile-time metadata. --- include/rfl.hpp | 2 + include/rfl/Positional.hpp | 124 +++++++++++++++++ include/rfl/Short.hpp | 128 +++++++++++++++++ include/rfl/cli.hpp | 1 + include/rfl/cli/parse_argv.hpp | 97 ++++++++++--- include/rfl/cli/read.hpp | 9 +- include/rfl/cli/resolve_args.hpp | 159 ++++++++++++++++++++++ include/rfl/internal/is_positional.hpp | 27 ++++ include/rfl/internal/is_short.hpp | 28 ++++ include/rfl/parsing/Parser.hpp | 2 + include/rfl/parsing/Parser_positional.hpp | 46 +++++++ include/rfl/parsing/Parser_short.hpp | 48 +++++++ include/rfl/parsing/is_required.hpp | 8 ++ tests/cli/test_positional.cpp | 98 +++++++++++++ tests/cli/test_positional_and_short.cpp | 47 +++++++ tests/cli/test_short.cpp | 74 ++++++++++ 16 files changed, 878 insertions(+), 20 deletions(-) create mode 100644 include/rfl/Positional.hpp create mode 100644 include/rfl/Short.hpp create mode 100644 include/rfl/cli/resolve_args.hpp create mode 100644 include/rfl/internal/is_positional.hpp create mode 100644 include/rfl/internal/is_short.hpp create mode 100644 include/rfl/parsing/Parser_positional.hpp create mode 100644 include/rfl/parsing/Parser_short.hpp create mode 100644 tests/cli/test_positional.cpp create mode 100644 tests/cli/test_positional_and_short.cpp create mode 100644 tests/cli/test_short.cpp diff --git a/include/rfl.hpp b/include/rfl.hpp index b4e8bba0..ab164bab 100644 --- a/include/rfl.hpp +++ b/include/rfl.hpp @@ -34,9 +34,11 @@ #include "rfl/OneOf.hpp" #include "rfl/Pattern.hpp" #include "rfl/PatternValidator.hpp" +#include "rfl/Positional.hpp" #include "rfl/Processors.hpp" #include "rfl/Ref.hpp" #include "rfl/Rename.hpp" +#include "rfl/Short.hpp" #include "rfl/Size.hpp" #include "rfl/Skip.hpp" #include "rfl/SnakeCaseToCamelCase.hpp" diff --git a/include/rfl/Positional.hpp b/include/rfl/Positional.hpp new file mode 100644 index 00000000..8a25cb91 --- /dev/null +++ b/include/rfl/Positional.hpp @@ -0,0 +1,124 @@ +#ifndef RFL_POSITIONAL_HPP_ +#define RFL_POSITIONAL_HPP_ + +#include +#include + +#include "default.hpp" + +namespace rfl { + +/// Marks a field as positional for CLI argument parsing. +/// For non-CLI formats (JSON, YAML, etc.), this is transparent. +template +struct Positional { + /// The underlying type. + using Type = T; + + Positional() : value_(Type()) {} + + Positional(const Type& _value) : value_(_value) {} + + Positional(Type&& _value) noexcept : value_(std::move(_value)) {} + + Positional(Positional&& _field) noexcept = default; + + Positional(const Positional& _field) = default; + + template + Positional(const Positional& _field) : value_(_field.get()) {} + + template + Positional(Positional&& _field) : value_(_field.get()) {} + + template + requires std::is_convertible_v + Positional(const U& _value) : value_(_value) {} + + template + requires std::is_convertible_v + Positional(U&& _value) noexcept : value_(std::forward(_value)) {} + + /// Assigns the underlying object to its default value. + template + requires std::is_default_constructible_v + Positional(const Default&) : value_(Type()) {} + + ~Positional() = default; + + /// Returns the underlying object. + const Type& get() const { return value_; } + + /// Returns the underlying object. + Type& operator()() { return value_; } + + /// Returns the underlying object. + const Type& operator()() const { return value_; } + + /// Assigns the underlying object. + auto& operator=(const Type& _value) { + value_ = _value; + return *this; + } + + /// Assigns the underlying object. + auto& operator=(Type&& _value) noexcept { + value_ = std::move(_value); + return *this; + } + + /// Assigns the underlying object. + template + requires std::is_convertible_v + auto& operator=(const U& _value) { + value_ = _value; + return *this; + } + + /// Assigns the underlying object to its default value. + template + requires std::is_default_constructible_v + auto& operator=(const Default&) { + value_ = Type(); + return *this; + } + + /// Assigns the underlying object. + Positional& operator=(const Positional& _field) = default; + + /// Assigns the underlying object. + Positional& operator=(Positional&& _field) = default; + + /// Assigns the underlying object. + template + auto& operator=(const Positional& _field) { + value_ = _field.get(); + return *this; + } + + /// Assigns the underlying object. + template + auto& operator=(Positional&& _field) { + value_ = std::forward(_field.value_); + return *this; + } + + /// Assigns the underlying object. + void set(const Type& _value) { value_ = _value; } + + /// Assigns the underlying object. + void set(Type&& _value) { value_ = std::move(_value); } + + /// Returns the underlying object. + Type& value() { return value_; } + + /// Returns the underlying object. + const Type& value() const { return value_; } + + /// The underlying value. + Type value_; +}; + +} // namespace rfl + +#endif diff --git a/include/rfl/Short.hpp b/include/rfl/Short.hpp new file mode 100644 index 00000000..70daa63c --- /dev/null +++ b/include/rfl/Short.hpp @@ -0,0 +1,128 @@ +#ifndef RFL_SHORT_HPP_ +#define RFL_SHORT_HPP_ + +#include +#include + +#include "default.hpp" +#include "internal/StringLiteral.hpp" + +namespace rfl { + +/// Assigns a short CLI alias (e.g. "-p") to a field. +/// For non-CLI formats (JSON, YAML, etc.), this is transparent. +template +struct Short { + /// The underlying type. + using Type = T; + + /// The short name. + static constexpr auto short_name_ = _name; + + Short() : value_(Type()) {} + + Short(const Type& _value) : value_(_value) {} + + Short(Type&& _value) noexcept : value_(std::move(_value)) {} + + Short(Short<_name, T>&& _field) noexcept = default; + + Short(const Short<_name, T>& _field) = default; + + template + Short(const Short<_name, U>& _field) : value_(_field.get()) {} + + template + Short(Short<_name, U>&& _field) : value_(_field.get()) {} + + template + requires std::is_convertible_v + Short(const U& _value) : value_(_value) {} + + template + requires std::is_convertible_v + Short(U&& _value) noexcept : value_(std::forward(_value)) {} + + /// Assigns the underlying object to its default value. + template + requires std::is_default_constructible_v + Short(const Default&) : value_(Type()) {} + + ~Short() = default; + + /// Returns the underlying object. + const Type& get() const { return value_; } + + /// Returns the underlying object. + Type& operator()() { return value_; } + + /// Returns the underlying object. + const Type& operator()() const { return value_; } + + /// Assigns the underlying object. + auto& operator=(const Type& _value) { + value_ = _value; + return *this; + } + + /// Assigns the underlying object. + auto& operator=(Type&& _value) noexcept { + value_ = std::move(_value); + return *this; + } + + /// Assigns the underlying object. + template + requires std::is_convertible_v + auto& operator=(const U& _value) { + value_ = _value; + return *this; + } + + /// Assigns the underlying object to its default value. + template + requires std::is_default_constructible_v + auto& operator=(const Default&) { + value_ = Type(); + return *this; + } + + /// Assigns the underlying object. + Short<_name, T>& operator=(const Short<_name, T>& _field) = default; + + /// Assigns the underlying object. + Short<_name, T>& operator=(Short<_name, T>&& _field) = default; + + /// Assigns the underlying object. + template + auto& operator=(const Short<_name, U>& _field) { + value_ = _field.get(); + return *this; + } + + /// Assigns the underlying object. + template + auto& operator=(Short<_name, U>&& _field) { + value_ = std::forward(_field.value_); + return *this; + } + + /// Assigns the underlying object. + void set(const Type& _value) { value_ = _value; } + + /// Assigns the underlying object. + void set(Type&& _value) { value_ = std::move(_value); } + + /// Returns the underlying object. + Type& value() { return value_; } + + /// Returns the underlying object. + const Type& value() const { return value_; } + + /// The underlying value. + Type value_; +}; + +} // namespace rfl + +#endif diff --git a/include/rfl/cli.hpp b/include/rfl/cli.hpp index c2eecad8..763f335b 100644 --- a/include/rfl/cli.hpp +++ b/include/rfl/cli.hpp @@ -6,5 +6,6 @@ #include "cli/Reader.hpp" #include "cli/parse_argv.hpp" #include "cli/read.hpp" +#include "cli/resolve_args.hpp" #endif diff --git a/include/rfl/cli/parse_argv.hpp b/include/rfl/cli/parse_argv.hpp index 32b583eb..a159ed2d 100644 --- a/include/rfl/cli/parse_argv.hpp +++ b/include/rfl/cli/parse_argv.hpp @@ -5,34 +5,97 @@ #include #include #include +#include #include "../Result.hpp" +#include "resolve_args.hpp" namespace rfl::cli { -/// Parses command-line arguments into a flat key-value map. -/// Expects format: --key=value or --flag (boolean). -inline rfl::Result> parse_argv( - int argc, char* argv[]) { - std::map result; +/// Parses command-line arguments into categorized buckets: +/// --key=value / --flag → named +/// -x value / -x=value / -x → short_args +/// bare arguments → positional +/// -- → everything after goes to positional +inline rfl::Result parse_argv(int argc, char* argv[]) { + ParsedArgs result; if (argc <= 1) { return result; } - for (const std::string_view arg_raw : std::span(argv + 1, argc - 1)) { - if (arg_raw.size() < 2 || arg_raw[0] != '-' || arg_raw[1] != '-') { - return error( - "Expected argument starting with '--', got: " + std::string(arg_raw)); + const auto args = std::span(argv + 1, argc - 1); + bool force_positional = false; + for (size_t i = 0; i < args.size(); ++i) { + const std::string_view arg_raw(args[i]); + + if (force_positional) { + result.positional.emplace_back(arg_raw); + continue; + } + + // "--" separator: everything after is positional. + if (arg_raw == "--") { + force_positional = true; + continue; } - const auto arg = arg_raw.substr(2); - const auto eq = arg.find('='); - auto key = std::string(eq == std::string_view::npos ? arg : arg.substr(0, eq)); - auto val = std::string(eq == std::string_view::npos ? "" : arg.substr(eq + 1)); - if (key.empty()) { - return error("Empty key in argument: --" + std::string(arg)); + + // Long argument: --key=value or --flag. + if (arg_raw.size() >= 2 && arg_raw[0] == '-' && arg_raw[1] == '-') { + const auto arg = arg_raw.substr(2); + const auto eq = arg.find('='); + auto key = std::string( + eq == std::string_view::npos ? arg : arg.substr(0, eq)); + auto val = std::string( + eq == std::string_view::npos ? "" : arg.substr(eq + 1)); + if (key.empty()) { + return error("Empty key in argument: --" + std::string(arg)); + } + const auto key_copy = key; + if (!result.named.emplace(std::move(key), std::move(val)).second) { + return error("Duplicate argument: --" + key_copy); + } + continue; } - if (!result.emplace(std::move(key), std::move(val)).second) { - return error("Duplicate argument: --" + std::string(key)); + + // Short argument: -x value, -x=value, or -x (bool). + if (arg_raw.size() >= 2 && arg_raw[0] == '-' && arg_raw[1] != '-') { + const auto arg = arg_raw.substr(1); + const auto eq = arg.find('='); + if (eq != std::string_view::npos) { + // -x=value + auto key = std::string(arg.substr(0, eq)); + auto val = std::string(arg.substr(eq + 1)); + const auto short_key_copy = key; + if (!result.short_args.emplace(std::move(key), std::move(val)).second) { + return error("Duplicate short argument: -" + short_key_copy); + } + } + else { + auto key = std::string(arg); + const auto short_key_copy = key; + // Peek at next token: if it exists and doesn't start with '-', + // consume it as the value. + if (i + 1 < args.size()) { + const std::string_view next(args[i + 1]); + if (!next.empty() && next[0] != '-') { + ++i; + auto val = std::string(next); + if (!result.short_args.emplace( + std::move(key), std::move(val)).second) { + return error("Duplicate short argument: -" + short_key_copy); + } + continue; + } + } + // No value — boolean flag. + if (!result.short_args.emplace(std::move(key), "").second) { + return error("Duplicate short argument: -" + short_key_copy); + } + } + continue; } + + // Bare argument → positional. + result.positional.emplace_back(arg_raw); } return result; } diff --git a/include/rfl/cli/read.hpp b/include/rfl/cli/read.hpp index 3f061673..e9211b96 100644 --- a/include/rfl/cli/read.hpp +++ b/include/rfl/cli/read.hpp @@ -6,6 +6,7 @@ #include "Parser.hpp" #include "Reader.hpp" #include "parse_argv.hpp" +#include "resolve_args.hpp" namespace rfl::cli { @@ -14,11 +15,13 @@ namespace rfl::cli { /// Example: struct field `host_name` matches CLI argument `--host-name`. template rfl::Result read(int argc, char* argv[]) { - return parse_argv(argc, argv).and_then( - [](auto _args) -> rfl::Result { + using ProcessorsType = Processors; + return parse_argv(argc, argv) + .and_then(resolve_args) + .and_then([](auto _args) -> rfl::Result { const auto r = Reader(); const auto var = CliVarType{&_args, "", std::nullopt}; - return Parser>::read(r, var); + return Parser::read(r, var); }); } diff --git a/include/rfl/cli/resolve_args.hpp b/include/rfl/cli/resolve_args.hpp new file mode 100644 index 00000000..80f7b403 --- /dev/null +++ b/include/rfl/cli/resolve_args.hpp @@ -0,0 +1,159 @@ +#ifndef RFL_CLI_RESOLVE_ARGS_HPP_ +#define RFL_CLI_RESOLVE_ARGS_HPP_ + +#include +#include +#include + +#include "../Result.hpp" +#include "../internal/is_positional.hpp" +#include "../internal/is_short.hpp" +#include "../internal/processed_t.hpp" + +namespace rfl::cli { + +struct ParsedArgs { + std::map named; + std::map short_args; + std::vector positional; +}; + +namespace detail { + +template +struct field_info { + using FieldType = rfl::tuple_element_t<_i, typename NamedTupleType::Fields>; + using InnerType = typename FieldType::Type; + + static constexpr bool is_positional = + rfl::internal::is_positional_v; + static constexpr bool is_short = rfl::internal::is_short_v; + + static constexpr std::string_view name() { return FieldType::name(); } +}; + +template +constexpr auto get_short_name() { + using Info = field_info; + if constexpr (Info::is_short) { + return Info::InnerType::short_name_; + } + else { + return rfl::internal::StringLiteral<1>(""); + } +} + +template +void collect_positional_names( + std::vector& _names, + std::index_sequence<_i, _is...> +) { + using Info = field_info; + if constexpr (Info::is_positional) { + _names.emplace_back(Info::name()); + } + if constexpr (sizeof...(_is) > 0) { + collect_positional_names(_names, std::index_sequence<_is...>{}); + } +} + +template +void collect_positional_names( + std::vector&, + std::index_sequence<> +) {} + +template +rfl::Result collect_short_mapping( + std::map& _mapping, + std::index_sequence<_i, _is...> +) { + using Info = field_info; + if constexpr (Info::is_short) { + constexpr auto short_lit = get_short_name(); + const auto short_str = std::string(short_lit.string_view()); + const auto long_str = std::string(Info::name()); + if (!_mapping.emplace(short_str, long_str).second) { + return rfl::error( + "Duplicate short name '-" + short_str + "' in struct definition."); + } + } + if constexpr (sizeof...(_is) > 0) { + return collect_short_mapping( + _mapping, std::index_sequence<_is...>{}); + } + return true; +} + +template +rfl::Result collect_short_mapping( + std::map&, + std::index_sequence<> +) { + return true; +} + +} // namespace detail + +/// Resolves ParsedArgs into a flat key-value map using compile-time +/// metadata from the target struct T. +template +rfl::Result> resolve_args( + ParsedArgs _parsed +) { + using NT = rfl::internal::processed_t; + constexpr auto sz = NT::size(); + using Indices = std::make_index_sequence; + + // Collect positional field names (in declaration order). + std::vector positional_names; + detail::collect_positional_names(positional_names, Indices{}); + + // Collect short-to-long mapping. + std::map short_to_long; + const auto short_result = + detail::collect_short_mapping(short_to_long, Indices{}); + if (!short_result) { + return rfl::error(short_result.error().what()); + } + + auto& result = _parsed.named; + + // Merge positional args. + if (_parsed.positional.size() > positional_names.size()) { + return rfl::error( + "Too many positional arguments: expected at most " + + std::to_string(positional_names.size()) + ", got " + + std::to_string(_parsed.positional.size()) + "."); + } + for (size_t i = 0; i < _parsed.positional.size(); ++i) { + const auto& long_name = positional_names[i]; + if (result.count(long_name)) { + return rfl::error( + "Conflict: positional argument and '--" + long_name + + "' both provided."); + } + result.emplace(long_name, std::move(_parsed.positional[i])); + } + + // Merge short args. + for (auto& [short_name, value] : _parsed.short_args) { + const auto it = short_to_long.find(short_name); + if (it == short_to_long.end()) { + return rfl::error("Unknown short argument: -" + short_name); + } + const auto& long_name = it->second; + if (result.count(long_name)) { + return rfl::error( + "Conflict: '-" + short_name + "' and '--" + long_name + + "' both provided."); + } + result.emplace(long_name, std::move(value)); + } + + return std::move(result); +} + +} // namespace rfl::cli + +#endif diff --git a/include/rfl/internal/is_positional.hpp b/include/rfl/internal/is_positional.hpp new file mode 100644 index 00000000..49de639b --- /dev/null +++ b/include/rfl/internal/is_positional.hpp @@ -0,0 +1,27 @@ +#ifndef RFL_INTERNAL_ISPOSITIONAL_HPP_ +#define RFL_INTERNAL_ISPOSITIONAL_HPP_ + +#include + +#include "../Positional.hpp" + +namespace rfl { +namespace internal { + +template +class is_positional; + +template +class is_positional : public std::false_type {}; + +template +class is_positional> : public std::true_type {}; + +template +constexpr bool is_positional_v = + is_positional>>::value; + +} // namespace internal +} // namespace rfl + +#endif diff --git a/include/rfl/internal/is_short.hpp b/include/rfl/internal/is_short.hpp new file mode 100644 index 00000000..66f20087 --- /dev/null +++ b/include/rfl/internal/is_short.hpp @@ -0,0 +1,28 @@ +#ifndef RFL_INTERNAL_ISSHORT_HPP_ +#define RFL_INTERNAL_ISSHORT_HPP_ + +#include + +#include "../Short.hpp" +#include "StringLiteral.hpp" + +namespace rfl { +namespace internal { + +template +class is_short; + +template +class is_short : public std::false_type {}; + +template +class is_short> : public std::true_type {}; + +template +constexpr bool is_short_v = + is_short>>::value; + +} // namespace internal +} // namespace rfl + +#endif diff --git a/include/rfl/parsing/Parser.hpp b/include/rfl/parsing/Parser.hpp index 4f1cb355..8b340631 100644 --- a/include/rfl/parsing/Parser.hpp +++ b/include/rfl/parsing/Parser.hpp @@ -18,6 +18,7 @@ #include "Parser_named_tuple.hpp" #include "Parser_optional.hpp" #include "Parser_pair.hpp" +#include "Parser_positional.hpp" #include "Parser_ptr.hpp" #include "Parser_ref.hpp" #include "Parser_reference_wrapper.hpp" @@ -27,6 +28,7 @@ #include "Parser_rfl_tuple.hpp" #include "Parser_rfl_variant.hpp" #include "Parser_shared_ptr.hpp" +#include "Parser_short.hpp" #include "Parser_skip.hpp" #include "Parser_span.hpp" #include "Parser_string_view.hpp" diff --git a/include/rfl/parsing/Parser_positional.hpp b/include/rfl/parsing/Parser_positional.hpp new file mode 100644 index 00000000..ffde0c89 --- /dev/null +++ b/include/rfl/parsing/Parser_positional.hpp @@ -0,0 +1,46 @@ +#ifndef RFL_PARSING_PARSER_POSITIONAL_HPP_ +#define RFL_PARSING_PARSER_POSITIONAL_HPP_ + +#include +#include + +#include "../Positional.hpp" +#include "../Result.hpp" +#include "Parser_base.hpp" +#include "schema/Type.hpp" + +namespace rfl { +namespace parsing { + +template + requires AreReaderAndWriter> +struct Parser, ProcessorsType> { + using InputVarType = typename R::InputVarType; + + static Result> read(const R& _r, + const InputVarType& _var) noexcept { + const auto to_positional = [](auto&& _t) { + return Positional(std::move(_t)); + }; + return Parser, ProcessorsType>::read(_r, _var) + .transform(to_positional); + } + + template + static void write(const W& _w, const Positional& _positional, + const P& _parent) { + Parser, ProcessorsType>::write( + _w, _positional.value(), _parent); + } + + static schema::Type to_schema( + std::map* _definitions) { + return Parser, ProcessorsType>::to_schema( + _definitions); + } +}; + +} // namespace parsing +} // namespace rfl + +#endif diff --git a/include/rfl/parsing/Parser_short.hpp b/include/rfl/parsing/Parser_short.hpp new file mode 100644 index 00000000..ad32a5f3 --- /dev/null +++ b/include/rfl/parsing/Parser_short.hpp @@ -0,0 +1,48 @@ +#ifndef RFL_PARSING_PARSER_SHORT_HPP_ +#define RFL_PARSING_PARSER_SHORT_HPP_ + +#include +#include + +#include "../Result.hpp" +#include "../Short.hpp" +#include "../internal/StringLiteral.hpp" +#include "Parser_base.hpp" +#include "schema/Type.hpp" + +namespace rfl { +namespace parsing { + +template + requires AreReaderAndWriter> +struct Parser, ProcessorsType> { + using InputVarType = typename R::InputVarType; + + static Result> read(const R& _r, + const InputVarType& _var) noexcept { + const auto to_short = [](auto&& _t) { + return Short<_name, T>(std::move(_t)); + }; + return Parser, ProcessorsType>::read(_r, _var) + .transform(to_short); + } + + template + static void write(const W& _w, const Short<_name, T>& _short, + const P& _parent) { + Parser, ProcessorsType>::write( + _w, _short.value(), _parent); + } + + static schema::Type to_schema( + std::map* _definitions) { + return Parser, ProcessorsType>::to_schema( + _definitions); + } +}; + +} // namespace parsing +} // namespace rfl + +#endif diff --git a/include/rfl/parsing/is_required.hpp b/include/rfl/parsing/is_required.hpp index bc4cb543..c7ddb676 100644 --- a/include/rfl/parsing/is_required.hpp +++ b/include/rfl/parsing/is_required.hpp @@ -6,10 +6,14 @@ #include #include "../Generic.hpp" +#include "../Positional.hpp" #include "../Rename.hpp" +#include "../Short.hpp" #include "../internal/StringLiteral.hpp" #include "../internal/has_reflection_type_v.hpp" +#include "../internal/is_positional.hpp" #include "../internal/is_rename.hpp" +#include "../internal/is_short.hpp" #include "is_map_like.hpp" #include "is_vector_like.hpp" @@ -45,6 +49,10 @@ consteval bool is_required() { _ignore_empty_containers>(); } else if constexpr (internal::is_rename_v) { return is_required(); + } else if constexpr (internal::is_positional_v) { + return is_required(); + } else if constexpr (internal::is_short_v) { + return is_required(); } else { return !(is_never_required_v || (_ignore_empty_containers && diff --git a/tests/cli/test_positional.cpp b/tests/cli/test_positional.cpp new file mode 100644 index 00000000..da1d4172 --- /dev/null +++ b/tests/cli/test_positional.cpp @@ -0,0 +1,98 @@ +#include +#include +#include +#include + +namespace test_positional { + +struct Config { + rfl::Positional input_file; + rfl::Positional output_file; + bool verbose; +}; + +TEST(cli, test_positional_basic) { + const char* args[] = { + "program", + "in.txt", + "out.txt", + "--verbose" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().input_file(), "in.txt"); + EXPECT_EQ(result.value().output_file(), "out.txt"); + EXPECT_TRUE(result.value().verbose); +} + +TEST(cli, test_positional_as_named) { + const char* args[] = { + "program", + "--input-file=in.txt", + "--output-file=out.txt", + "--verbose" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().input_file(), "in.txt"); + EXPECT_EQ(result.value().output_file(), "out.txt"); +} + +TEST(cli, test_positional_too_many) { + const char* args[] = { + "program", + "in.txt", + "out.txt", + "extra.txt" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_FALSE(result); +} + +struct SimpleConfig { + rfl::Positional input_file; + rfl::Positional output_file; +}; + +TEST(cli, test_positional_double_dash_separator) { + const char* args[] = { + "program", + "--", + "--not-a-flag", + "out.txt" + }; + const auto result = + rfl::cli::read(4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().input_file(), "--not-a-flag"); + EXPECT_EQ(result.value().output_file(), "out.txt"); +} + +TEST(cli, test_positional_conflict_with_named) { + const char* args[] = { + "program", + "in.txt", + "--input-file=other.txt" + }; + const auto result = rfl::cli::read(3, const_cast(args)); + ASSERT_FALSE(result); +} + +struct OptionalPositional { + rfl::Positional required_file; + rfl::Positional> optional_file; +}; + +TEST(cli, test_positional_optional_missing) { + const char* args[] = { + "program", + "in.txt" + }; + const auto result = + rfl::cli::read(2, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().required_file(), "in.txt"); + EXPECT_FALSE(result.value().optional_file().has_value()); +} + +} // namespace test_positional diff --git a/tests/cli/test_positional_and_short.cpp b/tests/cli/test_positional_and_short.cpp new file mode 100644 index 00000000..2b5caea0 --- /dev/null +++ b/tests/cli/test_positional_and_short.cpp @@ -0,0 +1,47 @@ +#include +#include +#include +#include + +namespace test_positional_and_short { + +struct Config { + rfl::Positional input_file; + rfl::Short<"o", std::string> output_dir; + rfl::Short<"v", bool> verbose; + int count; +}; + +TEST(cli, test_positional_and_short_combined) { + const char* args[] = { + "program", + "data.csv", + "-o", "/tmp/out", + "-v", + "--count=10" + }; + const auto result = rfl::cli::read(6, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().input_file(), "data.csv"); + EXPECT_EQ(result.value().output_dir(), "/tmp/out"); + EXPECT_TRUE(result.value().verbose()); + EXPECT_EQ(result.value().count, 10); +} + +TEST(cli, test_positional_and_short_all_long) { + const char* args[] = { + "program", + "--input-file=data.csv", + "--output-dir=/tmp/out", + "--verbose", + "--count=10" + }; + const auto result = rfl::cli::read(5, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().input_file(), "data.csv"); + EXPECT_EQ(result.value().output_dir(), "/tmp/out"); + EXPECT_TRUE(result.value().verbose()); + EXPECT_EQ(result.value().count, 10); +} + +} // namespace test_positional_and_short diff --git a/tests/cli/test_short.cpp b/tests/cli/test_short.cpp new file mode 100644 index 00000000..3653006e --- /dev/null +++ b/tests/cli/test_short.cpp @@ -0,0 +1,74 @@ +#include +#include +#include +#include + +namespace test_short { + +struct Config { + std::string host; + rfl::Short<"p", int> port; + rfl::Short<"v", bool> verbose; +}; + +TEST(cli, test_short_basic) { + const char* args[] = { + "program", + "--host=localhost", + "-p", "8080", + "-v" + }; + const auto result = rfl::cli::read(5, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().host, "localhost"); + EXPECT_EQ(result.value().port(), 8080); + EXPECT_TRUE(result.value().verbose()); +} + +TEST(cli, test_short_equals_syntax) { + const char* args[] = { + "program", + "--host=localhost", + "-p=8080", + "-v" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().port(), 8080); +} + +TEST(cli, test_short_as_long) { + const char* args[] = { + "program", + "--host=localhost", + "--port=8080", + "--verbose" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().port(), 8080); + EXPECT_TRUE(result.value().verbose()); +} + +TEST(cli, test_short_unknown) { + const char* args[] = { + "program", + "--host=localhost", + "-x", "foo" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_FALSE(result); +} + +TEST(cli, test_short_conflict_with_long) { + const char* args[] = { + "program", + "--host=localhost", + "--port=8080", + "-p", "9090" + }; + const auto result = rfl::cli::read(5, const_cast(args)); + ASSERT_FALSE(result); +} + +} // namespace test_short From d9188258ee5ef2f823e630cf45fcdfed2c02d43f Mon Sep 17 00:00:00 2001 From: Centimo Date: Fri, 13 Feb 2026 06:56:33 +0300 Subject: [PATCH 3/7] Fix CLI module bugs: move semantics, split(), null-safety, bool-flag ambiguity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix std::forward → std::move in move-assignment (Positional, Short) - Fix move constructors copying via get() instead of moving value_ - Add static_assert: Short name must be exactly one character - Add static_assert: disallow nested Positional> wrappers - Fix split() skipping empty elements ("a,,b" → ["a", "b"]) - Add null-safety check in Reader::to_object() - Reclaim non-boolean values from short bool flags (-v somefile) - Add tests for bool-short-positional interaction and empty elements - Add xml_content comment, blank line between transform functions --- include/rfl/Positional.hpp | 4 +- include/rfl/Short.hpp | 6 ++- include/rfl/SnakeCaseToKebabCase.hpp | 2 + include/rfl/cli/Reader.hpp | 14 ++++- include/rfl/cli/resolve_args.hpp | 67 ++++++++++++++++++++++++ include/rfl/internal/transform_case.hpp | 1 + tests/cli/test_bool_short_positional.cpp | 62 ++++++++++++++++++++++ tests/cli/test_vector.cpp | 13 +++++ 8 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 tests/cli/test_bool_short_positional.cpp diff --git a/include/rfl/Positional.hpp b/include/rfl/Positional.hpp index 8a25cb91..b20e8b95 100644 --- a/include/rfl/Positional.hpp +++ b/include/rfl/Positional.hpp @@ -29,7 +29,7 @@ struct Positional { Positional(const Positional& _field) : value_(_field.get()) {} template - Positional(Positional&& _field) : value_(_field.get()) {} + Positional(Positional&& _field) : value_(std::move(_field.value_)) {} template requires std::is_convertible_v @@ -99,7 +99,7 @@ struct Positional { /// Assigns the underlying object. template auto& operator=(Positional&& _field) { - value_ = std::forward(_field.value_); + value_ = std::move(_field.value_); return *this; } diff --git a/include/rfl/Short.hpp b/include/rfl/Short.hpp index 70daa63c..8d39deed 100644 --- a/include/rfl/Short.hpp +++ b/include/rfl/Short.hpp @@ -19,6 +19,8 @@ struct Short { /// The short name. static constexpr auto short_name_ = _name; + static_assert(_name.length == 1, "Short name must be exactly one character."); + Short() : value_(Type()) {} Short(const Type& _value) : value_(_value) {} @@ -33,7 +35,7 @@ struct Short { Short(const Short<_name, U>& _field) : value_(_field.get()) {} template - Short(Short<_name, U>&& _field) : value_(_field.get()) {} + Short(Short<_name, U>&& _field) : value_(std::move(_field.value_)) {} template requires std::is_convertible_v @@ -103,7 +105,7 @@ struct Short { /// Assigns the underlying object. template auto& operator=(Short<_name, U>&& _field) { - value_ = std::forward(_field.value_); + value_ = std::move(_field.value_); return *this; } diff --git a/include/rfl/SnakeCaseToKebabCase.hpp b/include/rfl/SnakeCaseToKebabCase.hpp index 86fdac8a..2350e8f8 100644 --- a/include/rfl/SnakeCaseToKebabCase.hpp +++ b/include/rfl/SnakeCaseToKebabCase.hpp @@ -13,6 +13,8 @@ struct SnakeCaseToKebabCase { template static auto process(const auto& _named_tuple) { return _named_tuple.transform([](const FieldType& _f) { + // "xml_content" is a reserved name used by the XML Reader/Writer; + // all case-transform processors must leave it unchanged. if constexpr (FieldType::name() != "xml_content" && !internal::is_rename_v) { return handle_one_field(_f); diff --git a/include/rfl/cli/Reader.hpp b/include/rfl/cli/Reader.hpp index b3ca096a..3d7db560 100644 --- a/include/rfl/cli/Reader.hpp +++ b/include/rfl/cli/Reader.hpp @@ -201,6 +201,12 @@ struct Reader { rfl::Result to_object( const InputVarType& _var) const noexcept { + if (!_var.args) { + return error("Cannot convert to object: no argument map available" + + (_var.path.empty() + ? std::string(".") + : " for key '" + _var.path + "'.")); + } const auto prefix = _var.path.empty() ? std::string("") : _var.path + path_separator; @@ -242,10 +248,14 @@ struct Reader { while (true) { const auto pos = _str.find(_delim, start); if (pos == std::string::npos) { - result.emplace_back(_str.substr(start)); + if (start < _str.size()) { + result.emplace_back(_str.substr(start)); + } break; } - result.emplace_back(_str.substr(start, pos - start)); + if (pos > start) { + result.emplace_back(_str.substr(start, pos - start)); + } start = pos + 1; } return result; diff --git a/include/rfl/cli/resolve_args.hpp b/include/rfl/cli/resolve_args.hpp index 80f7b403..90e06ac9 100644 --- a/include/rfl/cli/resolve_args.hpp +++ b/include/rfl/cli/resolve_args.hpp @@ -2,6 +2,7 @@ #define RFL_CLI_RESOLVE_ARGS_HPP_ #include +#include #include #include @@ -20,6 +21,19 @@ struct ParsedArgs { namespace detail { +template +consteval bool check_no_nested_wrappers() { + if constexpr (_is_positional) { + return !rfl::internal::is_short_v; + } + else if constexpr (_is_short) { + return !rfl::internal::is_positional_v; + } + else { + return true; + } +} + template struct field_info { using FieldType = rfl::tuple_element_t<_i, typename NamedTupleType::Fields>; @@ -29,6 +43,11 @@ struct field_info { rfl::internal::is_positional_v; static constexpr bool is_short = rfl::internal::is_short_v; + static_assert( + check_no_nested_wrappers(), + "Nested wrappers (Positional> or Short<..., Positional<...>>) " + "are not allowed."); + static constexpr std::string_view name() { return FieldType::name(); } }; @@ -93,6 +112,38 @@ rfl::Result collect_short_mapping( return true; } +template +consteval bool is_short_bool() { + using Info = field_info; + if constexpr (Info::is_short) { + return std::is_same_v; + } + else { + return false; + } +} + +template +void collect_short_bool_names( + std::set& _names, + std::index_sequence<_i, _is...> +) { + if constexpr (is_short_bool()) { + constexpr auto short_lit = get_short_name(); + _names.emplace(short_lit.string_view()); + } + if constexpr (sizeof...(_is) > 0) { + collect_short_bool_names( + _names, std::index_sequence<_is...>{}); + } +} + +template +void collect_short_bool_names( + std::set&, + std::index_sequence<> +) {} + } // namespace detail /// Resolves ParsedArgs into a flat key-value map using compile-time @@ -117,6 +168,22 @@ rfl::Result> resolve_args( return rfl::error(short_result.error().what()); } + // Reclaim non-boolean values from short bool flags. + // parse_argv doesn't know types, so `-v somefile` consumes "somefile" as + // the value of -v. Since -v is bool, "somefile" is not a valid bool value + // and must be returned to the positional list. + std::set short_bool_names; + detail::collect_short_bool_names(short_bool_names, Indices{}); + for (auto& [short_name, value] : _parsed.short_args) { + if (short_bool_names.count(short_name) + && value != "true" && value != "false" + && value != "0" && value != "1" + && !value.empty()) { + _parsed.positional.push_back(value); + value.clear(); + } + } + auto& result = _parsed.named; // Merge positional args. diff --git a/include/rfl/internal/transform_case.hpp b/include/rfl/internal/transform_case.hpp index eedc0f16..cce9b1f3 100644 --- a/include/rfl/internal/transform_case.hpp +++ b/include/rfl/internal/transform_case.hpp @@ -72,6 +72,7 @@ consteval auto transform_camel_case() { return transform_camel_case<_name, _i + 1, chars..., _name.arr_[_i]>(); } } + /// Transforms the field name from snake_case to kebab-case. template consteval auto transform_snake_to_kebab() { diff --git a/tests/cli/test_bool_short_positional.cpp b/tests/cli/test_bool_short_positional.cpp new file mode 100644 index 00000000..56415e64 --- /dev/null +++ b/tests/cli/test_bool_short_positional.cpp @@ -0,0 +1,62 @@ +#include +#include +#include +#include + +namespace test_bool_short_positional { + +struct Config { + rfl::Short<"v", bool> verbose; + rfl::Positional input_file; +}; + +TEST(cli, test_bool_short_does_not_consume_positional) { + const char* args[] = { + "program", + "-v", "somefile.txt" + }; + const auto result = rfl::cli::read(3, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_TRUE(result.value().verbose()); + EXPECT_EQ(result.value().input_file(), "somefile.txt"); +} + +TEST(cli, test_bool_short_explicit_true) { + const char* args[] = { + "program", + "-v=true", "somefile.txt" + }; + const auto result = rfl::cli::read(3, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_TRUE(result.value().verbose()); + EXPECT_EQ(result.value().input_file(), "somefile.txt"); +} + +TEST(cli, test_bool_short_explicit_false) { + const char* args[] = { + "program", + "-v=false", "somefile.txt" + }; + const auto result = rfl::cli::read(3, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_FALSE(result.value().verbose()); + EXPECT_EQ(result.value().input_file(), "somefile.txt"); +} + +struct PortConfig { + rfl::Short<"p", int> port; + rfl::Positional input_file; +}; + +TEST(cli, test_int_short_still_consumes_value) { + const char* args[] = { + "program", + "-p", "8080", "file.txt" + }; + const auto result = rfl::cli::read(4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().port(), 8080); + EXPECT_EQ(result.value().input_file(), "file.txt"); +} + +} // namespace test_bool_short_positional diff --git a/tests/cli/test_vector.cpp b/tests/cli/test_vector.cpp index bf098e08..cd672fbf 100644 --- a/tests/cli/test_vector.cpp +++ b/tests/cli/test_vector.cpp @@ -43,4 +43,17 @@ TEST(cli, test_vector_int) { EXPECT_EQ(result.value().values[2], 3); } +TEST(cli, test_vector_skips_empty_elements) { + const char* args[] = { + "program", + "--name=app", + "--tags=dev,,prod" + }; + const auto result = rfl::cli::read(3, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + ASSERT_EQ(result.value().tags.size(), 2u); + EXPECT_EQ(result.value().tags[0], "dev"); + EXPECT_EQ(result.value().tags[1], "prod"); +} + } // namespace test_vector From a1af419dd16fe1fa0cd8c396bb8b5c62256ae668 Mon Sep 17 00:00:00 2001 From: Centimo Date: Fri, 13 Feb 2026 14:47:56 +0300 Subject: [PATCH 4/7] Update CLI docs: add Positional, Short, architecture stages --- docs/cli.md | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 docs/cli.md diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 00000000..b1655551 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,126 @@ +# rfl::cli — Command-Line Argument Parser + +Parse `argc`/`argv` into any reflectable struct via `rfl::cli::read(argc, argv)`. + +## Usage + +```cpp +#include + +struct Config { + std::string host_name; + int port; + bool verbose; + std::optional rate; + std::vector tags; +}; + +int main(int argc, char* argv[]) { + const auto result = rfl::cli::read(argc, argv); + // ./app --host-name=localhost --port=8080 --verbose --tags=a,b,c +} +``` + +Field names undergo automatic `snake_case` -> `kebab-case` conversion: +`host_name` matches `--host-name`. + +## Positional arguments + +Wrap a field with `rfl::Positional` to accept it as a bare (non-flag) argument: + +```cpp +struct Config { + rfl::Positional input_file; + rfl::Positional output_file; + bool verbose; +}; + +// ./app input.txt output.txt --verbose +``` + +Positional arguments are matched in declaration order. They can also be +passed as named arguments: `--input-file=input.txt`. + +The `--` separator forces all subsequent tokens into positional: + +``` +./app --verbose -- --not-a-flag.txt +``` + +## Short aliases + +Wrap a field with `rfl::Short<"x", T>` to add a single-character alias: + +```cpp +struct Config { + rfl::Short<"p", int> port; + rfl::Short<"v", bool> verbose; + std::string host; +}; + +// ./app -p 8080 -v --host=localhost +// ./app -p=8080 -v --host=localhost +// ./app --port=8080 --verbose --host=localhost (long names still work) +``` + +Short bool flags do not consume the next token as a value — `-v somefile` +treats `somefile` as a positional argument, not as the value of `-v`. +To explicitly set a bool short flag, use `=` syntax: `-v=true`, `-v=false`. + +## Combining Positional and Short + +`Positional` and `Short` can be used together in the same struct, but +**cannot be nested** (`Positional>` is a compile-time error): + +```cpp +struct Config { + rfl::Positional input_file; + rfl::Short<"o", std::string> output_dir; + rfl::Short<"v", bool> verbose; + int count; +}; + +// ./app data.csv -o /tmp/out -v --count=10 +``` + +## Supported types + +| Type | CLI format | Notes | +|------|-----------|-------| +| `std::string` | `--key=value` | | +| `int`, `long`, ... | `--key=42` | | +| `float`, `double` | `--key=1.5` | | +| `bool` | `--flag` or `--flag=true` | No `=` implies `true` | +| `enum` | `--key=value_name` | Via `rfl::string_to_enum` | +| `std::optional` | omit for `nullopt` | | +| `std::vector` | `--key=a,b,c` | Comma-separated; empty elements skipped | +| Nested struct | `--parent.child=val` | Dot-separated path | +| `rfl::Flatten` | fields inlined | No prefix needed | +| `rfl::Rename<"x", T>` | `--x=val` | Bypasses kebab conversion | +| `rfl::Positional` | bare token | Matched in declaration order | +| `rfl::Short<"x", T>` | `-x value` or `-x=value` | Single-character alias | + +## Architecture + +Parsing proceeds in three stages: + +1. **`parse_argv`** — categorizes raw tokens into `named`, `short_args`, + and `positional` buckets (`ParsedArgs` struct). No type information needed. +2. **`resolve_args`** — uses compile-time metadata from the target struct to + map short aliases to long names, reclaim values from bool short flags, + and merge positional arguments. Produces a flat `map`. +3. **`Reader`** — implements reflect-cpp's `IsReader` concept by presenting + virtual tree nodes over the flat map. Each node is a `{map*, path}` pair — + no data copying, just prefix-based lookup via `lower_bound`. + +## Files + +- `include/rfl/cli/read.hpp` — public API +- `include/rfl/cli/Reader.hpp` — Reader + `parse_value` overloads +- `include/rfl/cli/Parser.hpp` — Parser type alias +- `include/rfl/cli/parse_argv.hpp` — `argv` -> `ParsedArgs` +- `include/rfl/cli/resolve_args.hpp` — `ParsedArgs` -> `map` +- `include/rfl/cli.hpp` — aggregator header +- `include/rfl/SnakeCaseToKebabCase.hpp` — processor +- `include/rfl/Positional.hpp` — `Positional` wrapper +- `include/rfl/Short.hpp` — `Short<"x", T>` wrapper From 89484859400e556eb4625d4ef18ff33c28eba546 Mon Sep 17 00:00:00 2001 From: Centimo Date: Sat, 14 Feb 2026 02:15:20 +0300 Subject: [PATCH 5/7] Add JSON roundtrip tests for Positional and Short wrappers --- tests/json/test_positional_and_short.cpp | 65 ++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/json/test_positional_and_short.cpp diff --git a/tests/json/test_positional_and_short.cpp b/tests/json/test_positional_and_short.cpp new file mode 100644 index 00000000..0dc3e84c --- /dev/null +++ b/tests/json/test_positional_and_short.cpp @@ -0,0 +1,65 @@ +#include +#include +#include +#include +#include + +#include "write_and_read.hpp" + +namespace test_positional_and_short_json { + +struct PositionalConfig { + rfl::Positional input_file; + rfl::Positional count; +}; + +TEST(json, test_positional_write_and_read) { + write_and_read( + PositionalConfig{"data.csv", 42}, + R"({"input_file":"data.csv","count":42})"); +} + +struct ShortConfig { + rfl::Short<"p", int> port; + rfl::Short<"v", bool> verbose; + std::string host; +}; + +TEST(json, test_short_write_and_read) { + write_and_read( + ShortConfig{8080, true, "localhost"}, + R"({"port":8080,"verbose":true,"host":"localhost"})"); +} + +struct CombinedConfig { + rfl::Positional input_file; + rfl::Short<"o", std::string> output_dir; + rfl::Short<"v", bool> verbose; + int count; + std::vector tags; +}; + +TEST(json, test_positional_and_short_combined_write_and_read) { + write_and_read( + CombinedConfig{"data.csv", "/tmp/out", true, 10, {"dev", "prod"}}, + R"({"input_file":"data.csv","output_dir":"/tmp/out","verbose":true,"count":10,"tags":["dev","prod"]})"); +} + +TEST(json, test_positional_read_from_json) { + const auto result = rfl::json::read( + R"({"input_file":"test.txt","count":7})"); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().input_file(), "test.txt"); + EXPECT_EQ(result.value().count(), 7); +} + +TEST(json, test_short_read_from_json) { + const auto result = rfl::json::read( + R"({"port":3000,"verbose":false,"host":"example.com"})"); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().port(), 3000); + EXPECT_FALSE(result.value().verbose()); + EXPECT_EQ(result.value().host, "example.com"); +} + +} // namespace test_positional_and_short_json From 1a621cd5fb562ffd96b26c8d3d53982d44668936 Mon Sep 17 00:00:00 2001 From: Centimo Date: Sun, 15 Feb 2026 05:23:40 +0300 Subject: [PATCH 6/7] Fix GCC 12 -Warray-bounds, negative number parsing, reclaim ordering - Break string literal + std::to_string concatenation chain that triggers false -Warray-bounds in GCC 12 with -O3 -Werror - Accept negative numbers as short option values (-p -42) - Preserve positional argument order when reclaiming from bool short flags - Add requires constraint on Positional/Short default constructors - Add tests for negative values and multi-bool reclaim ordering --- include/rfl/Positional.hpp | 3 +- include/rfl/Short.hpp | 3 +- include/rfl/cli/Reader.hpp | 3 +- include/rfl/cli/parse_argv.hpp | 33 ++++++++++++++------- include/rfl/cli/resolve_args.hpp | 37 +++++++++++++++++++----- tests/cli/test_bool_short_positional.cpp | 22 ++++++++++++++ tests/cli/test_short.cpp | 32 ++++++++++++++++++++ 7 files changed, 112 insertions(+), 21 deletions(-) diff --git a/include/rfl/Positional.hpp b/include/rfl/Positional.hpp index b20e8b95..40220f40 100644 --- a/include/rfl/Positional.hpp +++ b/include/rfl/Positional.hpp @@ -15,7 +15,8 @@ struct Positional { /// The underlying type. using Type = T; - Positional() : value_(Type()) {} + Positional() requires std::is_default_constructible_v + : value_(Type()) {} Positional(const Type& _value) : value_(_value) {} diff --git a/include/rfl/Short.hpp b/include/rfl/Short.hpp index 8d39deed..c1f6424d 100644 --- a/include/rfl/Short.hpp +++ b/include/rfl/Short.hpp @@ -21,7 +21,8 @@ struct Short { static_assert(_name.length == 1, "Short name must be exactly one character."); - Short() : value_(Type()) {} + Short() requires std::is_default_constructible_v + : value_(Type()) {} Short(const Type& _value) : value_(_value) {} diff --git a/include/rfl/cli/Reader.hpp b/include/rfl/cli/Reader.hpp index 3d7db560..451c8a00 100644 --- a/include/rfl/cli/Reader.hpp +++ b/include/rfl/cli/Reader.hpp @@ -106,7 +106,8 @@ struct Reader { rfl::Result get_field_from_array( const size_t _idx, const InputArrayType& _arr) const noexcept { if (_idx >= _arr.values.size()) { - return error("Index " + std::to_string(_idx) + " out of bounds."); + return error( + std::string("Index ") + std::to_string(_idx) + " out of bounds."); } return InputVarType{nullptr, "", _arr.values[_idx]}; } diff --git a/include/rfl/cli/parse_argv.hpp b/include/rfl/cli/parse_argv.hpp index a159ed2d..a3bbf54b 100644 --- a/include/rfl/cli/parse_argv.hpp +++ b/include/rfl/cli/parse_argv.hpp @@ -1,6 +1,7 @@ #ifndef RFL_CLI_PARSE_ARGV_HPP_ #define RFL_CLI_PARSE_ARGV_HPP_ +#include #include #include #include @@ -12,12 +13,24 @@ namespace rfl::cli { +/// Returns true if the token looks like a CLI option (starts with '-' +/// followed by a letter), as opposed to a negative number like "-42". +inline bool looks_like_option(std::string_view _token) noexcept { + return _token.size() >= 2 + && _token[0] == '-' + && !std::isdigit(static_cast(_token[1])) + && _token[1] != '.'; +} + /// Parses command-line arguments into categorized buckets: /// --key=value / --flag → named /// -x value / -x=value / -x → short_args /// bare arguments → positional /// -- → everything after goes to positional inline rfl::Result parse_argv(int argc, char* argv[]) { + if (argc < 0 || (argc > 0 && !argv)) { + return error("Invalid argc/argv."); + } ParsedArgs result; if (argc <= 1) { return result; @@ -47,11 +60,10 @@ inline rfl::Result parse_argv(int argc, char* argv[]) { auto val = std::string( eq == std::string_view::npos ? "" : arg.substr(eq + 1)); if (key.empty()) { - return error("Empty key in argument: --" + std::string(arg)); + return error("Empty key in argument: " + std::string(arg_raw)); } - const auto key_copy = key; if (!result.named.emplace(std::move(key), std::move(val)).second) { - return error("Duplicate argument: --" + key_copy); + return error("Duplicate argument: " + std::string(arg_raw)); } continue; } @@ -64,31 +76,32 @@ inline rfl::Result parse_argv(int argc, char* argv[]) { // -x=value auto key = std::string(arg.substr(0, eq)); auto val = std::string(arg.substr(eq + 1)); - const auto short_key_copy = key; + result.short_order.emplace_back(key); if (!result.short_args.emplace(std::move(key), std::move(val)).second) { - return error("Duplicate short argument: -" + short_key_copy); + return error("Duplicate short argument: " + std::string(arg_raw)); } } else { auto key = std::string(arg); - const auto short_key_copy = key; - // Peek at next token: if it exists and doesn't start with '-', + // Peek at next token: if it exists and doesn't look like an option, // consume it as the value. if (i + 1 < args.size()) { const std::string_view next(args[i + 1]); - if (!next.empty() && next[0] != '-') { + if (!next.empty() && !looks_like_option(next)) { ++i; auto val = std::string(next); + result.short_order.emplace_back(key); if (!result.short_args.emplace( std::move(key), std::move(val)).second) { - return error("Duplicate short argument: -" + short_key_copy); + return error("Duplicate short argument: " + std::string(arg_raw)); } continue; } } // No value — boolean flag. + result.short_order.emplace_back(key); if (!result.short_args.emplace(std::move(key), "").second) { - return error("Duplicate short argument: -" + short_key_copy); + return error("Duplicate short argument: " + std::string(arg_raw)); } } continue; diff --git a/include/rfl/cli/resolve_args.hpp b/include/rfl/cli/resolve_args.hpp index 90e06ac9..253d9d4c 100644 --- a/include/rfl/cli/resolve_args.hpp +++ b/include/rfl/cli/resolve_args.hpp @@ -17,6 +17,8 @@ struct ParsedArgs { std::map named; std::map short_args; std::vector positional; + /// Insertion order of short arg keys (for stable reclaim ordering). + std::vector short_order; }; namespace detail { @@ -172,26 +174,45 @@ rfl::Result> resolve_args( // parse_argv doesn't know types, so `-v somefile` consumes "somefile" as // the value of -v. Since -v is bool, "somefile" is not a valid bool value // and must be returned to the positional list. + // We iterate in insertion order (short_order) to preserve argv ordering. std::set short_bool_names; detail::collect_short_bool_names(short_bool_names, Indices{}); - for (auto& [short_name, value] : _parsed.short_args) { - if (short_bool_names.count(short_name) - && value != "true" && value != "false" + std::vector reclaimed; + for (const auto& short_name : _parsed.short_order) { + if (!short_bool_names.count(short_name)) { + continue; + } + auto it = _parsed.short_args.find(short_name); + if (it == _parsed.short_args.end()) { + continue; + } + auto& value = it->second; + if (value != "true" && value != "false" && value != "0" && value != "1" && !value.empty()) { - _parsed.positional.push_back(value); + reclaimed.push_back(value); value.clear(); } } + // Insert reclaimed values before existing positional args to preserve + // the original argv order: `-v file1 file2` → positional ["file1", "file2"]. + if (!reclaimed.empty()) { + _parsed.positional.insert( + _parsed.positional.begin(), + std::make_move_iterator(reclaimed.begin()), + std::make_move_iterator(reclaimed.end())); + } auto& result = _parsed.named; // Merge positional args. if (_parsed.positional.size() > positional_names.size()) { - return rfl::error( - "Too many positional arguments: expected at most " - + std::to_string(positional_names.size()) + ", got " - + std::to_string(_parsed.positional.size()) + "."); + auto msg = std::string("Too many positional arguments: expected at most "); + msg += std::to_string(positional_names.size()); + msg += ", got "; + msg += std::to_string(_parsed.positional.size()); + msg += "."; + return rfl::error(std::move(msg)); } for (size_t i = 0; i < _parsed.positional.size(); ++i) { const auto& long_name = positional_names[i]; diff --git a/tests/cli/test_bool_short_positional.cpp b/tests/cli/test_bool_short_positional.cpp index 56415e64..55a84aee 100644 --- a/tests/cli/test_bool_short_positional.cpp +++ b/tests/cli/test_bool_short_positional.cpp @@ -59,4 +59,26 @@ TEST(cli, test_int_short_still_consumes_value) { EXPECT_EQ(result.value().input_file(), "file.txt"); } +struct MultiBoolConfig { + rfl::Short<"v", bool> verbose; + rfl::Short<"d", bool> debug; + rfl::Positional first; + rfl::Positional second; +}; + +TEST(cli, test_multi_bool_short_preserves_positional_order) { + const char* args[] = { + "program", + "-v", "file1.txt", + "-d", "file2.txt" + }; + const auto result = rfl::cli::read( + 5, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_TRUE(result.value().verbose()); + EXPECT_TRUE(result.value().debug()); + EXPECT_EQ(result.value().first(), "file1.txt"); + EXPECT_EQ(result.value().second(), "file2.txt"); +} + } // namespace test_bool_short_positional diff --git a/tests/cli/test_short.cpp b/tests/cli/test_short.cpp index 3653006e..e018c72d 100644 --- a/tests/cli/test_short.cpp +++ b/tests/cli/test_short.cpp @@ -71,4 +71,36 @@ TEST(cli, test_short_conflict_with_long) { ASSERT_FALSE(result); } +struct NegativeConfig { + std::string host; + rfl::Short<"p", int> port; +}; + +TEST(cli, test_short_negative_value) { + const char* args[] = { + "program", + "--host=localhost", + "-p", "-42" + }; + const auto result = rfl::cli::read( + 4, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_EQ(result.value().port(), -42); +} + +struct FloatConfig { + rfl::Short<"t", double> threshold; +}; + +TEST(cli, test_short_negative_float) { + const char* args[] = { + "program", + "-t", "-3.14" + }; + const auto result = rfl::cli::read( + 3, const_cast(args)); + ASSERT_TRUE(result) << result.error().what(); + EXPECT_DOUBLE_EQ(result.value().threshold(), -3.14); +} + } // namespace test_short From 427b16ff3fcd2319506ba14a231d1d928f75618a Mon Sep 17 00:00:00 2001 From: Centimo Date: Sun, 15 Feb 2026 05:29:53 +0300 Subject: [PATCH 7/7] Flatten parse_argv: extract add_short lambda, simplify peek-ahead --- include/rfl/cli/parse_argv.hpp | 58 ++++++++++++++++------------------ 1 file changed, 27 insertions(+), 31 deletions(-) diff --git a/include/rfl/cli/parse_argv.hpp b/include/rfl/cli/parse_argv.hpp index a3bbf54b..a8b0d13a 100644 --- a/include/rfl/cli/parse_argv.hpp +++ b/include/rfl/cli/parse_argv.hpp @@ -35,6 +35,16 @@ inline rfl::Result parse_argv(int argc, char* argv[]) { if (argc <= 1) { return result; } + + const auto add_short = [&](std::string _key, std::string _val, + std::string_view _raw) -> rfl::Result { + result.short_order.emplace_back(_key); + if (!result.short_args.emplace(std::move(_key), std::move(_val)).second) { + return error("Duplicate short argument: " + std::string(_raw)); + } + return true; + }; + const auto args = std::span(argv + 1, argc - 1); bool force_positional = false; for (size_t i = 0; i < args.size(); ++i) { @@ -52,7 +62,7 @@ inline rfl::Result parse_argv(int argc, char* argv[]) { } // Long argument: --key=value or --flag. - if (arg_raw.size() >= 2 && arg_raw[0] == '-' && arg_raw[1] == '-') { + if (arg_raw.starts_with("--")) { const auto arg = arg_raw.substr(2); const auto eq = arg.find('='); auto key = std::string( @@ -69,41 +79,27 @@ inline rfl::Result parse_argv(int argc, char* argv[]) { } // Short argument: -x value, -x=value, or -x (bool). - if (arg_raw.size() >= 2 && arg_raw[0] == '-' && arg_raw[1] != '-') { + if (arg_raw.size() >= 2 && arg_raw[0] == '-') { const auto arg = arg_raw.substr(1); const auto eq = arg.find('='); + + // -x=value if (eq != std::string_view::npos) { - // -x=value - auto key = std::string(arg.substr(0, eq)); - auto val = std::string(arg.substr(eq + 1)); - result.short_order.emplace_back(key); - if (!result.short_args.emplace(std::move(key), std::move(val)).second) { - return error("Duplicate short argument: " + std::string(arg_raw)); - } + const auto r = add_short( + std::string(arg.substr(0, eq)), + std::string(arg.substr(eq + 1)), arg_raw); + if (!r) { return error(r.error().what()); } + continue; } - else { - auto key = std::string(arg); - // Peek at next token: if it exists and doesn't look like an option, - // consume it as the value. - if (i + 1 < args.size()) { - const std::string_view next(args[i + 1]); - if (!next.empty() && !looks_like_option(next)) { - ++i; - auto val = std::string(next); - result.short_order.emplace_back(key); - if (!result.short_args.emplace( - std::move(key), std::move(val)).second) { - return error("Duplicate short argument: " + std::string(arg_raw)); - } - continue; - } - } - // No value — boolean flag. - result.short_order.emplace_back(key); - if (!result.short_args.emplace(std::move(key), "").second) { - return error("Duplicate short argument: " + std::string(arg_raw)); - } + + // -x value or -x (bool flag). + // Peek at next token: consume as value if it doesn't look like an option. + auto val = std::string(); + if (i + 1 < args.size() && !looks_like_option(args[i + 1])) { + val = std::string(args[++i]); } + const auto r = add_short(std::string(arg), std::move(val), arg_raw); + if (!r) { return error(r.error().what()); } continue; }