# 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!! ``` ## 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. The same treatment applies to an *incomplete* command that wasn't explicitly asking for help: `packctl project` alone is missing ``, and that's equally true of all three `project` commands (they report the identical error). Rather than arbitrarily surfacing whichever one happened to be registered first, every command tied for "most specific incomplete match" is listed: ``` $ packctl project Error: missing required argument '': expected a value of type string 'project' matches 3 commands: packctl project create [OPTIONS] Scaffold a new project from a template packctl project delete [OPTIONS] Permanently delete a project by name packctl project [OPTIONS] Show information about a project ``` A single most-specific match (e.g. `packctl project x create --template`, missing only `--template`'s value) still reports just the one relevant `Usage:` line, unchanged. ## 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 ``` Value.hpp Scalar/Value/TypeInfo/ErrorKind -- the type & error system Slot.hpp Keyword/Parameter/Flag/Option descriptor FunctionTraits.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 ```