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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 18 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
branches: [main]
pull_request:

env:
XLINGS_NON_INTERACTIVE: '1'

jobs:
build:
name: build + test (linux x86_64, mcpp)
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .xlings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"workspace": {
"mcpp": "0.0.89"
"mcpp": "2026.11.8.1"
}
}
65 changes: 65 additions & 0 deletions .xlings/subos/_/.shim-view/97507fee-git.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
}
43 changes: 43 additions & 0 deletions .xlings/subos/_/.shim-view/97507fee-python3.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
}
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

- **多种解析模式** — 支持 `argc/argv`、`cmdline::Argv`(即 `vector<string>`)或原始 `string_view`

- **数值选项** — `value_type<T>()` 声明选项取 `T` 类型的数字,解析时用 `std::from_chars` 校验,不合法就是解析错误;`value_as<T>(name)` 读回

## 快速开始

```cpp
Expand Down Expand Up @@ -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<double>().help("每秒多少片"))
.option(cmdline::Option("frames").short_name('f').value_type<int>());
// myapp --rate fast → 解析错误:"option --rate takes a number, not `fast`"
auto parsed = app.parse(argc, argv);
const double rate = parsed->value_as<double>("rate").value_or(30.0); // 缺省时为空
```

## 构建

**使用 xmake**
Expand Down
2 changes: 1 addition & 1 deletion mcpp.toml
Original file line number Diff line number Diff line change
@@ -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"]
Expand Down
6 changes: 6 additions & 0 deletions src/cmdline.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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 <class Number>
OptBuilder& value_type() { (void)option_.template value_type<Number>(); 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; }
Expand Down
23 changes: 23 additions & 0 deletions src/options.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(): 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) {}
Expand Down Expand Up @@ -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<double>(), `--frames 60` with value_type<int>().
/// 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<Number>() reads it.
template <class Number>
requires(std::is_arithmetic_v<Number> && !std::is_same_v<Number, bool>)
[[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<Number> ? "a number"
: std::is_unsigned_v<Number> ? "a whole number, not negative"
: "a whole number";
if (value_name_.empty()) value_name_ = std::is_floating_point_v<Number> ? "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 {
Expand Down
16 changes: 16 additions & 0 deletions src/parse.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -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<Number>() 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 <class Number>
requires(std::is_arithmetic_v<Number> && !std::is_same_v<Number, bool>)
[[nodiscard]] std::optional<Number> 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{};
}
Expand Down
45 changes: 45 additions & 0 deletions tests/cmdline_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -442,3 +442,48 @@ TEST(Parse, DoubleDashPositionalsOnly) {
EXPECT_EQ(result->positional(1), "-x");
EXPECT_EQ(result->positional(2), "--second");
}

// -----------------------------------------------------------------------------
// value_type<T>: a number checked by the parse; value_as<T>: read back
// -----------------------------------------------------------------------------
TEST(ValueType, NumbersOfEachTypeReadBack) {
App app("prog");
(void)app.option(Option("rate").value_type<double>());
(void)app.option(Option("frames").short_name('f').value_type<int>());
(void)app.option(Option("seed").value_type<std::uint64_t>());
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<double>("rate"), 2.5);
EXPECT_EQ(result->value_as<int>("frames"), 60);
EXPECT_EQ(result->value_as<std::uint64_t>("seed"), 18446744073709551615ull);
EXPECT_FALSE(result->value_as<int>("missing").has_value());
}

TEST(ValueType, AValueThatIsNotOneIsAParseError) {
App app("prog");
(void)app.option(Option("size").value_type<int>());
(void)app.option(Option("level").short_name('l').value_type<unsigned>());
(void)app.option(Option("rate").value_type<double>());
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<int>().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<int>("count"), 3);
// No declaration checked it: value_as answers nothing for what is not a number.
EXPECT_FALSE(result->value_as<int>("name").has_value());
}

Loading