diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0ee911..28c7383 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: +env: + XLINGS_NON_INTERACTIVE: '1' + jobs: build: name: build + test (linux x86_64, mcpp) @@ -13,27 +16,28 @@ jobs: - uses: actions/checkout@v4 - name: Install xlings - env: - XLINGS_VERSION: 0.4.30 run: | - tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" - curl -fsSL -o "/tmp/${tarball}" \ - "https://github.com/d2learn/xlings/releases/download/v${XLINGS_VERSION}/${tarball}" - tar -xzf "/tmp/${tarball}" -C /tmp - "/tmp/xlings-${XLINGS_VERSION}-linux-x86_64/subos/default/bin/xlings" self install + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.10.26.1 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - - name: Install workspace tools (.xlings.json → mcpp 0.0.11) - run: xlings install -y + - name: Install mcpp (the version .xlings.json names) + run: | + MCPP_VERSION="$(sed -n 's/.*"mcpp": *"\([^"]*\)".*/\1/p' .xlings.json)" + for attempt in 1 2 3; do + xlings update > /dev/null 2>&1 || true + if xlings install "mcpp@$MCPP_VERSION" -y -g; then break; fi + [ "$attempt" = 3 ] && { echo "::error::mcpp could not be installed"; exit 1; } + sleep 30 + done - # Cache mcpp's self-bootstrapped sandbox (musl-gcc + binutils + - # glibc + ninja + patchelf, ~800 MB). Toolchain set is pinned by - # mcpp 0.0.11, so a fixed key suffices. + # Cache mcpp's self-bootstrapped sandbox (toolchain, binutils, ninja, + # patchelf). The toolchain set is pinned by the mcpp in .xlings.json, + # so its version keys the cache. - name: Cache mcpp sandbox uses: actions/cache@v4 with: - path: ~/.xlings/data/xpkgs/xim-x-mcpp/0.0.11/registry - key: mcpp-sandbox-${{ runner.os }}-mcpp0.0.11 + path: ~/.xlings/data/xpkgs/xim-x-mcpp/2026.11.8.1/registry + key: mcpp-sandbox-${{ runner.os }}-mcpp2026.11.8.1 - name: Build with mcpp run: mcpp build diff --git a/.xlings.json b/.xlings.json index 86555db..e044bb3 100644 --- a/.xlings.json +++ b/.xlings.json @@ -1,5 +1,5 @@ { "workspace": { - "mcpp": "0.0.89" + "mcpp": "2026.11.8.1" } } diff --git a/.xlings/subos/_/.shim-view/97507fee-git.json b/.xlings/subos/_/.shim-view/97507fee-git.json new file mode 100644 index 0000000..328e015 --- /dev/null +++ b/.xlings/subos/_/.shim-view/97507fee-git.json @@ -0,0 +1,65 @@ +{ + "fingerprint": { + "/home/speak/.xlings/.xlings.json": "4006342:-4643459404359340792", + "/home/speak/.xlings/data/versions.json": "3851602:-4643459404252354929", + "/home/speak/.xlings/subos/speak-agent-private/.xlings.json": "272593:-4643459743353010761", + "/tmp/cmdline-up/.xlings.json": "51:-4643456745409099346", + "/tmp/cmdline-up/.xlings/.xlings.json": "absent" + }, + "format": 1, + "program": "git", + "vinfo": { + "git": { + "filename": "git", + "type": "program", + "versions": { + "2.53.0": { + "bindingGroup": { + "group": "git", + "provider": "xim:git", + "rootTarget": "git", + "rootVersion": "2.53.0", + "version": "2.53.0" + }, + "bindingMembers": { + "git": "2.53.0" + }, + "destinationName": "git", + "envs": { + "GIT_SSL_CAINFO": "/etc/ssl/certs/ca-certificates.crt" + }, + "kind": "program", + "path": "/home/speak/.xlings/data/xpkgs/xim-x-git/2.53.0/bin", + "sourceName": "git" + }, + "2.53.0-1": { + "bindingGroup": { + "group": "git", + "provider": "xim:git", + "rootTarget": "git", + "rootVersion": "2.53.0-1", + "version": "2.53.0-1" + }, + "bindingMembers": { + "git": "2.53.0-1" + }, + "destinationName": "git", + "envs": { + "GIT_SSL_CAINFO": "/etc/ssl/certs/ca-certificates.crt" + }, + "kind": "program", + "path": "/home/speak/.xlings/data/xpkgs/xim-x-git/2.53.0-1/bin", + "sourceName": "git" + } + } + } + }, + "workspace": { + "git": { + "active": "2.53.0", + "installed": [ + "2.53.0" + ] + } + } +} \ No newline at end of file diff --git a/.xlings/subos/_/.shim-view/97507fee-python3.json b/.xlings/subos/_/.shim-view/97507fee-python3.json new file mode 100644 index 0000000..fcb5a83 --- /dev/null +++ b/.xlings/subos/_/.shim-view/97507fee-python3.json @@ -0,0 +1,43 @@ +{ + "fingerprint": { + "/home/speak/.xlings/.xlings.json": "4006342:-4643459404359340792", + "/home/speak/.xlings/data/versions.json": "3851602:-4643459404252354929", + "/home/speak/.xlings/subos/speak-agent-private/.xlings.json": "272593:-4643459743353010761", + "/tmp/cmdline-up/.xlings.json": "51:-4643456745409099346", + "/tmp/cmdline-up/.xlings/.xlings.json": "absent" + }, + "format": 1, + "program": "python3", + "vinfo": { + "python3": { + "filename": "python3", + "type": "program", + "versions": { + "3.13.12": { + "bindingGroup": { + "group": "python3", + "provider": "xim:python", + "rootTarget": "python3", + "rootVersion": "3.13.12", + "version": "3.13.12" + }, + "bindingMembers": { + "python3": "3.13.12" + }, + "destinationName": "python3", + "kind": "program", + "path": "/home/speak/.xlings/data/xpkgs/xim-x-python/3.13.12/bin", + "sourceName": "python3" + } + } + } + }, + "workspace": { + "python3": { + "active": "3.13.12", + "installed": [ + "3.13.12" + ] + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index cdef3ae..b617ebb 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ - **多种解析模式** — 支持 `argc/argv`、`cmdline::Argv`(即 `vector`)或原始 `string_view` +- **数值选项** — `value_type()` 声明选项取 `T` 类型的数字,解析时用 `std::from_chars` 校验,不合法就是解析错误;`value_as(name)` 读回 + ## 快速开始 ```cpp @@ -74,6 +76,17 @@ auto r2 = app.parse_from(cmdline::Argv{"myapp", "add", "x", "1.0"}); auto r3 = app.parse_from("myapp remove x --yes"); ``` +### 数值选项 + +```cpp +auto app = cmdline::App("myapp") + .option(cmdline::Option("rate").value_type().help("每秒多少片")) + .option(cmdline::Option("frames").short_name('f').value_type()); +// myapp --rate fast → 解析错误:"option --rate takes a number, not `fast`" +auto parsed = app.parse(argc, argv); +const double rate = parsed->value_as("rate").value_or(30.0); // 缺省时为空 +``` + ## 构建 **使用 xmake** diff --git a/mcpp.toml b/mcpp.toml index a69d0de..fb6f912 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] namespace = "mcpplibs" name = "cmdline" -version = "0.0.2" +version = "0.0.3" description = "A simple command-line parsing library/framework for modern C++" license = "Apache-2.0" authors = ["mcpplibs"] diff --git a/src/cmdline.cppm b/src/cmdline.cppm index 4d05e0e..330ad47 100644 --- a/src/cmdline.cppm +++ b/src/cmdline.cppm @@ -278,6 +278,8 @@ private: val = std::string(tokens[++i]); else return std::unexpected(ParseError{.message = std::format("option --{} requires a value", key)}); + if (found.value_check_ && !found.value_check_(val)) + return std::unexpected(ParseError{.message = std::format("option --{} takes {}, not `{}`", key, found.value_kind_, val)}); apply_option(found, key_str, std::move(val), false); } else { apply_option(found, key_str, "", true); @@ -294,6 +296,8 @@ private: if (found.takes_value_) { if (i + 1 >= tokens.size()) return std::unexpected(ParseError{.message = std::format("option -{} requires a value", short_char)}); std::string val(tokens[++i]); + if (found.value_check_ && !found.value_check_(val)) + return std::unexpected(ParseError{.message = std::format("option -{} takes {}, not `{}`", short_char, found.value_kind_, val)}); apply_option(found, key_str, std::move(val), false); } else { apply_option(found, key_str, "", true); @@ -391,6 +395,8 @@ public: OptBuilder& short_name(char c) { (void)option_.short_name(c); return *this; } OptBuilder& help(std::string_view h) { (void)option_.help(h); return *this; } OptBuilder& takes_value(bool v = true) { (void)option_.takes_value(v); return *this; } + template + OptBuilder& value_type() { (void)option_.template value_type(); return *this; } OptBuilder& value_name(std::string_view v) { (void)option_.value_name(v); return *this; } OptBuilder& multiple(bool m = true) { (void)option_.multiple(m); return *this; } OptBuilder& global(bool g = true) { (void)option_.global(g); return *this; } diff --git a/src/options.cppm b/src/options.cppm index d35bf84..641086a 100644 --- a/src/options.cppm +++ b/src/options.cppm @@ -36,6 +36,9 @@ export struct Option { bool multiple_ = false; bool global_ = false; std::string value_name_; + // A value's check, declared with value_type(): a value it refuses is a parse error. + bool (*value_check_)(std::string_view) = nullptr; + std::string value_kind_; // what it takes, for that error: "a number", "an integer" … constexpr Option(std::string_view long_opt) : long_name(long_opt) {} constexpr Option(char short_char) : short_(short_char) {} @@ -68,6 +71,26 @@ export struct Option { value_name_ = value_name; return *this; } + /// The option takes a number of type `Number`, read with std::from_chars: + /// `--rate 2.5` with value_type(), `--frames 60` with value_type(). + /// A value that is not wholly one (`12px`, `-1` for an unsigned, out of + /// range) is refused when the command line is parsed; afterwards + /// ParsedArgs::value_as() reads it. + template + requires(std::is_arithmetic_v && !std::is_same_v) + [[nodiscard]] Option& value_type() { + takes_value_ = true; + value_check_ = [](std::string_view text) { + Number number{}; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), number); + return error == std::errc{} && end == text.data() + text.size(); + }; + value_kind_ = std::is_floating_point_v ? "a number" + : std::is_unsigned_v ? "a whole number, not negative" + : "a whole number"; + if (value_name_.empty()) value_name_ = std::is_floating_point_v ? "NUMBER" : "N"; + return *this; + } [[nodiscard]] bool matches_short(char short_char) const { return short_ && short_ == short_char; } [[nodiscard]] bool matches_long(std::string_view long_opt) const { diff --git a/src/parse.cppm b/src/parse.cppm index 66a4f5e..7531e69 100644 --- a/src/parse.cppm +++ b/src/parse.cppm @@ -62,6 +62,22 @@ export struct ParsedArgs { if (positional_names_[idx] == name) return positionals[idx]; return std::nullopt; } + /// An option's (or a named positional's) value as a number: what an option + /// declared with value_type() takes, already checked by the parse. + /// Nothing when it was not given — or, for a value no declaration checked, + /// when it is not wholly a number of that type. + template + requires(std::is_arithmetic_v && !std::is_same_v) + [[nodiscard]] std::optional value_as(std::string_view name) const { + const auto text = value(name); + if (!text) return std::nullopt; + Number number{}; + const char* first = text->data(); + const char* last = first + text->size(); + const auto [end, error] = std::from_chars(first, last, number); + if (error != std::errc{} || end != last) return std::nullopt; + return number; + } [[nodiscard]] std::string positional(std::size_t index) const { return index < positionals.size() ? positionals[index] : std::string{}; } diff --git a/tests/cmdline_test.cpp b/tests/cmdline_test.cpp index 5aaeebc..bd9015e 100644 --- a/tests/cmdline_test.cpp +++ b/tests/cmdline_test.cpp @@ -442,3 +442,48 @@ TEST(Parse, DoubleDashPositionalsOnly) { EXPECT_EQ(result->positional(1), "-x"); EXPECT_EQ(result->positional(2), "--second"); } + +// ----------------------------------------------------------------------------- +// value_type: a number checked by the parse; value_as: read back +// ----------------------------------------------------------------------------- +TEST(ValueType, NumbersOfEachTypeReadBack) { + App app("prog"); + (void)app.option(Option("rate").value_type()); + (void)app.option(Option("frames").short_name('f').value_type()); + (void)app.option(Option("seed").value_type()); + auto result = app.parse_from(Argv{"prog", "--rate=2.5", "-f", "60", "--seed", "18446744073709551615"}); + ASSERT_TRUE(result.has_value()) << result.error(); + EXPECT_EQ(result->value_as("rate"), 2.5); + EXPECT_EQ(result->value_as("frames"), 60); + EXPECT_EQ(result->value_as("seed"), 18446744073709551615ull); + EXPECT_FALSE(result->value_as("missing").has_value()); +} + +TEST(ValueType, AValueThatIsNotOneIsAParseError) { + App app("prog"); + (void)app.option(Option("size").value_type()); + (void)app.option(Option("level").short_name('l').value_type()); + (void)app.option(Option("rate").value_type()); + auto size = app.parse_from(Argv{"prog", "--size", "12px"}); + ASSERT_FALSE(size.has_value()); + EXPECT_TRUE(size.error().is_error()); + EXPECT_EQ(size.error().message, "option --size takes a whole number, not `12px`"); + auto level = app.parse_from(Argv{"prog", "-l", "-1"}); + ASSERT_FALSE(level.has_value()); + EXPECT_EQ(level.error().message, "option -l takes a whole number, not negative, not `-1`"); + auto rate = app.parse_from(Argv{"prog", "--rate", "fast"}); + ASSERT_FALSE(rate.has_value()); + EXPECT_EQ(rate.error().message, "option --rate takes a number, not `fast`"); +} + +TEST(ValueType, BuilderFormAndUncheckedValues) { + App app("prog"); + (void)app.option("count").value_type().help("how many"); + (void)app.option("name").takes_value(); + auto result = app.parse_from(Argv{"prog", "--count", "3", "--name", "12px"}); + ASSERT_TRUE(result.has_value()) << result.error(); + EXPECT_EQ(result->value_as("count"), 3); + // No declaration checked it: value_as answers nothing for what is not a number. + EXPECT_FALSE(result->value_as("name").has_value()); +} +