commit 9c60d1b47c2cdbdeaaee69a91e9596e51d0a7605 Author: Acrux Development Date: Tue Aug 18 18:31:47 2026 -0300 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..98838d9 --- /dev/null +++ b/README.md @@ -0,0 +1,254 @@ +# cliforge + +A header-only C++20 engine for building CLIs whose commands are ordinary +free functions. Parameters and options are explicitly typed where +they're declared; `.action()` validates (never invents) that the bound +function's arguments are consistent with what was declared, and accepts +them by value, by reference, or by const reference. + +```cpp +#include +using cliforge::Engine; + +void greet(const std::string& name, bool loud) { + std::cout << (loud ? "HELLO, " : "Hello, ") << name << (loud ? "!!" : ".") << "\n"; +} + +int main(int argc, char** argv) { + cliforge::Engine cli("greeter"); + cli.command() + .keyword("greet") + .parameter("name", "Who to greet") + .flag("loud", 'l', "SHOUT the greeting") + .describe("Greets someone by name") + .action(&greet); + return cli.run(argc, argv); +} +``` + +``` +$ greeter greet Ada --loud +HELLO, Ada!! +``` + +## Try it + +```sh +g++ -std=c++20 -Iinclude -O2 -o packctl examples/main.cpp +./packctl # global help +./packctl project myapp create --template cpp --git +./packctl build 42 2 1 -a arm64 x64 -e vim # variadic params + variadic option +./packctl build 7 -ndi -e nano # combined short flags +./packctl deploy web1 --env prod web2 -n web3 # loose parameters, order-agnostic +./packctl project new -h # ambiguous prefix -> shows all 3 matches +./packctl projct myapp delete # typo -> "did you mean" +``` + +Or with CMake: `cmake -B build && cmake --build build`, which produces +`packctl` (the demo) plus `type_test` / `registration_errors_test`. + +## The model + +A command has exactly two parts, matching the spec: + +``` +packctl deploy web1 --env prod web2 -n web3 + \____/ \___________ unstructured ___________/ + structured flags + options + loose parameters, any order +``` + +* **Structured part** -- an ordered, *fixed-position* sequence of literal + **keywords** (`project`, `delete`) and typed positional **parameters** + (``). The user's tokens must line up with this sequence exactly, + in order. Only the *last* parameter here may be variadic. Keywords + carry no description of their own -- `.describe()` on the command is + what shows up in help. +* **Unstructured part** -- **flags** (boolean switches), **options** + (named, typed values), and additional **loose parameters**, which the + user may give in any order relative to each other: + * Flags/options are found by name (`--foo`/`-f`) wherever they appear. + Short flags combine (`-ndi` == `-n -d -i`); options can't be combined + since they need a value. + * Whatever bare (non-dash) tokens are left over are the loose + parameters, matched *positionally among themselves, in declaration + order* -- regardless of where they fall relative to the flags/ + options. `deploy web1 --env prod web2 -n web3` and + `deploy -n web1 web2 --env prod web3` both give `hosts = [web1, web2, + web3]`. + +A command must declare at least one keyword. `.keyword()` can only be +called before any `.flag()`/`.option()`. `.parameter()` can be called +on *either side* of that line: before the first `.flag()`/`.option()` +it's a structured, fixed-position parameter; after, it's a loose one. +Either way, only the last parameter declared in its half may be variadic. + +**Parameters are always required** (unless they're the trailing variadic +one, which accepts zero or more). **Options are always optional** -- +`.option()` must be bound to `std::optional` or (if variadic) +`std::vector` in `action()`; binding it to a bare, required `T` is a +`RegistrationError`. This mirrors ordinary CLI convention: positional +arguments are the required ones, named flags/options never are. + +## Explicit types, validated (not invented) at action() + +Every `.parameter()` / `.option()` call requires `T` up front -- +types are never inferred from the bound function. `.action(&fn)` deduces +`fn`'s argument types via `function_traits` and *checks* each one against +what was already declared, matched in order: + +``` +[ parameters, in the order you called .parameter() before any flag/option ] +++ +[ flags/options/loose-parameters, in the order you called them after ] +``` + +| Declared as | `action()`'s argument must be | +|---|---| +| `flag(...)` | `bool` | +| `parameter(...)` | `T` -- by value, `const T&`, or `T&` | +| `parameter(..., variadic=true)` | `std::vector` | +| `option(...)` | `std::optional` (never a bare `T` -- options are always optional) | +| `option(..., variadic=true)` | `std::vector` | +| an `enum class` (either kind) | needs `.choices({{"name", T::Value}, ...})` called immediately after; `T` is deduced from the initializer list | + +A mismatch anywhere in this table (wrong arity, a flag bound to +non-`bool`, an option bound to a bare/required type, a declared type +that doesn't match the corresponding argument type, a variadic slot +bound to a non-vector, ...) is a `RegistrationError` at startup -- see +`tests/registration_errors_test.cpp`. An unsupported scalar type (say, +`std::map`) fails immediately at the `parameter()`/ +`option()` call site via `static_assert`, before `action()` is even +reached. + +Arguments can be taken **by value, by reference, or by const +reference** -- whichever avoids an unnecessary copy for the type in +question (`const std::string&`, `const std::vector&`, etc. all work). + +## Types + +Fixed-width signed/unsigned integers (`int8_t` ... `uint64_t`), `float`, +`double`, `std::string`, `char` (exactly one byte), `bool` +(`true/false/1/0/yes/no/on/off`, case-insensitive), and custom +`enum class` types via `choices()`. Parsing uses `std::from_chars` +for numbers, so `"5abc"` or `"3.14"` handed to an `int32_t` slot is +rejected rather than silently truncated. + +## Errors are categorized, not just "something went wrong" + +Every runtime parse failure is a `CliError` carrying both a specific, +actionable message and a programmatic `ErrorKind` (`.kind()`): + +| Situation | `ErrorKind` | Example message | +|---|---|---| +| A `--flag`/`-x` isn't recognized | `UnknownOption` | `unknown flag/option '--arch' for this command -- did you mean '--architectures'?` | +| More positional values than declared slots can absorb | `TooManyArguments` | `too many arguments: unexpected 'extra' (and 2 more)` | +| A required parameter (or loose parameter) was never given | `MissingArgument` | `missing required argument '': expected a value of type string` | +| An option was named but its value token is missing | `MissingValue` | `option '--editor' requires a value` | +| A token doesn't fit its declared type | `TypeMismatch` | `type mismatch for 'threshold': got 'abc', expected int32` | + +## `--help` resolves ambiguity instead of hiding it + +`--help`/`-h` (anywhere in the command line) and the `help ` +top-level command both search for *every* command consistent with what +was typed so far -- including when a positional value has already been +filled in. `packctl project new -h` matches `project create`, +`project delete`, and `project ` (info) all at once, since +`new` is a perfectly plausible `` for any of them; it prints all +three, clearly separated, rather than silently picking one. A +fully-specified prefix (`project myapp delete --help`) narrows to the +one actual match, exactly as before. + +## Keeping allocations down + +* `TypeInfo`'s parser is a **plain stateless function pointer**, not + `std::function` -- built-in scalar types (the overwhelming majority of + slots) cost nothing to store or copy. Only `choices()`-restricted + slots pay for an allocation, once, at registration (a `shared_ptr` to + a small name table), not per parse. +* **Short flag/option names are `char`**, not `std::string` -- no + allocation to declare one, and combined short flags (`-ndi`) are + matched by direct `char` comparison instead of constructing a + temporary single-character string per letter. +* The unstructured parser (flags/options/loose-parameters) works in + terms of `std::string_view` internally -- splitting `--name=value` or + stripping the leading dashes off a token no longer copies; the token's + bytes are only ever copied once, into the final `Scalar`, when a value + actually needs to be owned. +* `choices()` no longer goes through `std::any` -- the declared C++ + type is tracked with a `std::type_index` (a couple of pointer-sized + comparisons, no allocation) and validated directly against `action()`'s + argument types. + +Parsing a single command line still does a handful of small, one-shot +`std::vector` allocations (there's no way around owning the parsed +values), so if you're embedding cliforge in a long-running REPL parsing +many commands per process, reusing buffers across calls would be the +next lever -- not implemented here, since it's not needed for the +"parse argv once and dispatch" shape most CLIs have. + +## Design decisions worth knowing about + +These are the places where the spec was open to interpretation; here's +what this implementation does and why. + +* **Keywords never start with `-`.** The spec allows keywords to start + with digits, but a leading dash would be indistinguishable from a + flag. Internal dashes/dots/underscores are fine (`self-update`, + `2fa`, `v2.load` are all valid keywords). +* **Negative numbers vs. flags.** A token is only treated as "flag/ + option syntax" if it starts with `-` and the next character *isn't a + digit* -- so `-5`/`-3` parse as negative numeric values (structured + variadic parameters *or* loose parameters), while `-n` still means the + flag `n`. Applied consistently in both the structured matcher and the + unstructured parser. +* **Disambiguating overlapping commands.** `project ` and + `project delete` can both structurally match + `project myapp delete`. Candidates are ranked by how many *literal* + keywords matched (more specific wins), with total structured-slot + count as a tiebreaker, and are tried in that order -- so `delete` + correctly wins over swallowing `"delete"` into ``. +* **Error attribution when several candidates fail.** If the + best-ranked candidate's *value* fails to parse or is missing, that's + almost certainly the user's intended command, so its error is what + gets reported -- not a generic "no command matches", and not a + less-relevant error from a lower-ranked candidate that also happened + to fail. +* **"Did you mean?" is structural, not string-blob.** Comparing the raw + input against a usage string like `project delete` would + score badly once you substitute a real value for ``. Instead, + suggestions walk the same keyword/parameter structure as real + matching, treating parameter slots as wildcards and only scoring + edit-distance on the literal keyword text -- normalized to a ratio so + a variadic parameter silently absorbing unrelated tokens can't make + garbage input look like a close match. Flag/option suggestions + additionally treat a prefix relationship (`--arch` for + `--architectures`) as a strong match even though its raw edit + distance is large. +* **`--name=value` is supported** alongside the spec's space-separated + form, as a low-cost, expected CLI convention. +* **Repeated non-variadic options: last write wins** (`--reason a + --reason b` yields `"b"`). Variadic options/loose-parameters + accumulate instead. +* **Not implemented:** a `--` "end of options" separator. Given the + negative-number heuristic already resolves the main practical + ambiguity, this was left as a documented extension point rather than + added speculatively. + +## Layout + +``` +include/cliforge/ + value.hpp Scalar/Value/TypeInfo/ErrorKind -- the type & error system + slot.hpp Keyword/Parameter/Flag/Option descriptor + function_traits.hpp Return/argument-type deduction for action() + levenshtein.hpp Edit distance + prefix-aware closest-match helper + command.hpp Builder, matcher, unstructured parser, help text + engine.hpp Registration, ranking/dispatch, help resolution + cliforge.hpp Aggregator -- the only header you include +examples/main.cpp "packctl", a demo touching every feature +tests/ + type_test.cpp every scalar type, valid + invalid input + registration_errors_test.cpp every builder-misuse guard (+ positive cases) +``` +# CLIForge + \ No newline at end of file