From 40d55e00f29626552eaad19bcf07b7a29c3ab160 Mon Sep 17 00:00:00 2001 From: Acrux Development Date: Tue, 18 Aug 2026 18:33:08 -0300 Subject: [PATCH] Add project files. --- CMakeLists.txt | 25 + examples/main.cpp | 192 ++++++ include/cliforge/cliforge.hpp | 32 + include/cliforge/command.hpp | 884 +++++++++++++++++++++++++++ include/cliforge/engine.hpp | 241 ++++++++ include/cliforge/function_traits.hpp | 65 ++ include/cliforge/levenshtein.hpp | 61 ++ include/cliforge/slot.hpp | 62 ++ include/cliforge/value.hpp | 353 +++++++++++ tests/registration_errors_test.cpp | 144 +++++ tests/type_test.cpp | 40 ++ 11 files changed, 2099 insertions(+) create mode 100644 CMakeLists.txt create mode 100644 examples/main.cpp create mode 100644 include/cliforge/cliforge.hpp create mode 100644 include/cliforge/command.hpp create mode 100644 include/cliforge/engine.hpp create mode 100644 include/cliforge/function_traits.hpp create mode 100644 include/cliforge/levenshtein.hpp create mode 100644 include/cliforge/slot.hpp create mode 100644 include/cliforge/value.hpp create mode 100644 tests/registration_errors_test.cpp create mode 100644 tests/type_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..9b42fb4 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,25 @@ +cmake_minimum_required(VERSION 3.16) +project(cliforge CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# cliforge is header-only. +add_library(cliforge INTERFACE) +target_include_directories(cliforge INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/include) + +add_executable(packctl examples/main.cpp) +target_link_libraries(packctl PRIVATE cliforge) +target_compile_options(packctl PRIVATE -Wall -Wextra) + +enable_testing() + +add_executable(type_test tests/type_test.cpp) +target_link_libraries(type_test PRIVATE cliforge) +target_compile_options(type_test PRIVATE -Wall -Wextra) + +add_executable(registration_errors_test tests/registration_errors_test.cpp) +target_link_libraries(registration_errors_test PRIVATE cliforge) +target_compile_options(registration_errors_test PRIVATE -Wall -Wextra) + +add_test(NAME registration_errors COMMAND registration_errors_test) diff --git a/examples/main.cpp b/examples/main.cpp new file mode 100644 index 0000000..3682743 --- /dev/null +++ b/examples/main.cpp @@ -0,0 +1,192 @@ +// packctl -- a demo CLI built with cliforge, showing every rule from the +// spec: keyword charset (digits/dots/underscores/dashes, digit-leading), +// explicitly typed positional + variadic parameters (both in the +// structured part AND as "loose" parameters in the unstructured part), +// combinable short flags, typed (possibly variadic, possibly enum) +// options that are always optional, structured vs unstructured command +// parts, custom enums, and free-function-only registration. Handlers +// take their arguments by value, by reference, or by const reference, +// whichever fits. +#include +#include +#include +#include +#include + +#include + +using cliforge::Engine; + +// --------------------------------------------------------------------- +// Custom enums -- plain C++ enum class, nothing cliforge-specific here. +// --------------------------------------------------------------------- +enum class ProjectTemplate { Cpp, Python, Node }; +enum class Editor { Vim, Emacs, Nano }; +enum class TwoFactorMethod { App, Sms, Email }; +enum class UpdateChannel { Stable, Beta, Nightly }; + +// --------------------------------------------------------------------- +// Every command handler is a completely ordinary free function. There is +// no cliforge type anywhere in these signatures. Options are always +// std::optional (or std::vector if variadic) because options are +// never required -- only parameters are. Arguments are taken by value, +// const reference, or reference, whichever avoids unnecessary copies. +// --------------------------------------------------------------------- + +void projectCreate(const std::string& name, std::optional tmpl, bool git) { + static const char* names[] = {"cpp", "python", "node"}; + ProjectTemplate t = tmpl.value_or(ProjectTemplate::Cpp); + std::printf("[create] project '%s' from template '%s' (git init: %s)\n", name.c_str(), + names[static_cast(t)], git ? "yes" : "no"); +} + +void projectDelete(const std::string& name, bool force, const std::optional& reason) { + std::printf("[delete] project '%s' (force=%s, reason=%s)\n", name.c_str(), + force ? "yes" : "no", reason ? reason->c_str() : ""); +} + +void projectInfo(const std::string& name, bool json) { + if (json) { + std::printf("{\"project\": \"%s\", \"status\": \"ok\"}\n", name.c_str()); + } else { + std::printf("[info] project '%s': status ok\n", name.c_str()); + } +} + +void build(const std::vector& targets, bool noAssets, bool debug, bool verbose, + const std::vector& architectures, std::optional editor, + std::optional threshold) { + static const char* editorNames[] = {"vim", "emacs", "nano"}; + std::printf("[build] targets=["); + for (size_t i = 0; i < targets.size(); ++i) std::printf("%s%d", i ? "," : "", targets[i]); + std::printf("] noAssets=%d debug=%d verbose=%d architectures=[", noAssets, debug, verbose); + for (size_t i = 0; i < architectures.size(); ++i) + std::printf("%s%s", i ? "," : "", architectures[i].c_str()); + std::printf("] editor=%s threshold=%s\n", + editor ? editorNames[static_cast(*editor)] : "", + threshold ? std::to_string(*threshold).c_str() : ""); +} + +void enable2fa(std::optional method) { + static const char* names[] = {"app", "sms", "email"}; + TwoFactorMethod m = method.value_or(TwoFactorMethod::App); + std::printf("[2fa] enabled via %s\n", names[static_cast(m)]); +} + +void disable2fa() { std::printf("[2fa] disabled\n"); } + +void selfUpdate(std::optional channel, bool checkOnly) { + static const char* names[] = {"stable", "beta", "nightly"}; + std::printf("[self-update] channel=%s checkOnly=%d\n", + channel ? names[static_cast(*channel)] : "stable(default)", checkOnly); +} + +void status(bool json) { + std::printf(json ? "{\"status\":\"ok\"}\n" : "All systems operational.\n"); +} + +// `deploy` showcases a *loose* parameter: `hosts` is declared after +// flag()/option(), so it lives in the unstructured part. The user can +// type it anywhere relative to --env/-n, e.g. +// packctl deploy web1 --env prod web2 -n web3 +// still collects hosts=[web1,web2,web3], matched by position among +// themselves, not by where they fall relative to the option/flag. +void deploy(bool dryRun, const std::optional& env, const std::vector& hosts) { + std::printf("[deploy] dryRun=%d env=%s hosts=[", dryRun, env ? env->c_str() : ""); + for (size_t i = 0; i < hosts.size(); ++i) std::printf("%s%s", i ? "," : "", hosts[i].c_str()); + std::printf("]\n"); +} + +// --------------------------------------------------------------------- +// Registration: this is the entirety of the "wiring" -- names, +// descriptions, structure, and types. action() then just validates that +// the bound function's arguments are consistent with what was declared. +// --------------------------------------------------------------------- + +int main(int argc, char** argv) { + Engine cli("packctl"); + cli.describe("A demo package/project CLI built with cliforge."); + + cli.command() + .keyword("project") + .parameter("name", "Project name") + .keyword("create") + .option("template", 't', "Project template to scaffold (default: cpp)") + .choices({{"cpp", ProjectTemplate::Cpp}, + {"python", ProjectTemplate::Python}, + {"node", ProjectTemplate::Node}}) + .flag("git", 'g', "Initialize a git repository") + .describe("Scaffold a new project from a template") + .action(&projectCreate); + + cli.command() + .keyword("project") + .parameter("name", "Project name") + .keyword("delete") + .flag("force", 'f', "Skip the confirmation prompt") + .option("reason", 'r', "Why the project is being deleted") + .describe("Permanently delete a project by name") + .action(&projectDelete); + + cli.command() + .keyword("project") + .parameter("name", "Project name") + .flag("json", 'j', "Print machine-readable JSON") + .describe("Show information about a project") + .action(&projectInfo); + + cli.command() + .keyword("build") + .parameter("targets", "Target ids to build", /*variadic=*/true) + .flag("no-assets", 'n', "Skip packaging assets") + .flag("debug", 'd', "Build with debug symbols") + .flag("verbose", 'i', "Verbose build output") + .option("architectures", 'a', "Target architectures", /*variadic=*/true) + .option("editor", 'e', "Editor to open on completion") + .choices({{"vim", Editor::Vim}, {"emacs", Editor::Emacs}, {"nano", Editor::Nano}}) + .option("threshold", 't', "Warning threshold (0 = strict)") + .describe("Build the given target ids") + .action(&build); + + cli.command() + .keyword("2fa") + .keyword("enable") + .option("method", 'm', "Verification method (default: app)") + .choices({{"app", TwoFactorMethod::App}, + {"sms", TwoFactorMethod::Sms}, + {"email", TwoFactorMethod::Email}}) + .describe("Turn on two-factor authentication") + .action(&enable2fa); + + cli.command() + .keyword("2fa") + .keyword("disable") + .describe("Turn off two-factor authentication") + .action(&disable2fa); + + cli.command() + .keyword("self-update") + .option("channel", 'c', "Release channel to update from") + .choices({{"stable", UpdateChannel::Stable}, + {"beta", UpdateChannel::Beta}, + {"nightly", UpdateChannel::Nightly}}) + .flag("check-only", 'k', "Check for updates without installing") + .describe("Update packctl to the latest version") + .action(&selfUpdate); + + cli.command() + .keyword("status") + .flag("json", 'j', "Print machine-readable JSON") + .describe("Show whether everything is operational") + .action(&status); + + cli.command() + .keyword("deploy") + .flag("dry-run", 'n', "Show what would happen without doing it") + .option("env", 'e', "Environment name") + .parameter("hosts", "Target hostnames", /*variadic=*/true) // loose! + .describe("Deploy to the given hosts, in any order relative to --env/-n") + .action(&deploy); + + return cli.run(argc, argv); +} diff --git a/include/cliforge/cliforge.hpp b/include/cliforge/cliforge.hpp new file mode 100644 index 0000000..dd3c4a3 --- /dev/null +++ b/include/cliforge/cliforge.hpp @@ -0,0 +1,32 @@ +#pragma once +// +// cliforge.hpp +// +// cliforge: a small header-only C++20 library for building CLIs whose +// commands are just free functions. +// +// #include +// +// void greet(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", "Say hello to someone") +// .parameter("name", "Who to greet") +// .flag("loud", "l", "SHOUT the greeting") +// .describe("Greets someone by name") +// .action(&greet); +// return cli.run(argc, argv); +// } +// +// See README.md for the full guide. +// +#include "command.hpp" +#include "engine.hpp" +#include "function_traits.hpp" +#include "levenshtein.hpp" +#include "slot.hpp" +#include "value.hpp" diff --git a/include/cliforge/command.hpp b/include/cliforge/command.hpp new file mode 100644 index 0000000..89e10b0 --- /dev/null +++ b/include/cliforge/command.hpp @@ -0,0 +1,884 @@ +#pragma once +// +// command.hpp +// +// Command is the fluent builder AND the parser for one CLI command. A +// command is built like: +// +// engine.command() +// .keyword("project") +// .parameter("name", "Project name") +// .keyword("delete") +// .flag("force", 'f', "Skip the confirmation prompt") +// .option("reason", 'r', "Why it's being deleted") +// .describe("Delete a project by name") +// .action(&deleteProject); +// +// Every parameter/option is explicitly typed where it's declared, so the +// engine never has to invent a type from the bound function -- `.action()` +// instead *validates* that the function's corresponding argument is a +// consistent binding (bool for a flag; T, std::optional, or +// std::vector for a parameter/option; by value, by reference, or by +// const reference all work). Mismatches are RegistrationErrors caught at +// startup, not silent surprises at run time. +// +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "function_traits.hpp" +#include "levenshtein.hpp" +#include "slot.hpp" +#include "value.hpp" + +namespace cliforge { + +namespace detail { + +// --- small type-trait helpers used only for action() binding ---------- + +template +struct is_optional : std::false_type {}; +template +struct is_optional> : std::true_type { + using inner = T; +}; +template +inline constexpr bool is_optional_v = is_optional::value; + +template +struct is_vector : std::false_type {}; +template +struct is_vector> : std::true_type { + using inner = T; +}; +template +inline constexpr bool is_vector_v = is_vector::value; + +template +inline constexpr bool is_builtin_scalar_v = + std::is_same_v || std::is_same_v || + std::is_same_v || std::is_same_v || + std::is_same_v || std::is_same_v || + std::is_same_v || std::is_same_v || + std::is_same_v || std::is_same_v || + std::is_same_v || std::is_same_v || + std::is_same_v; + +// A token is where the unstructured (flags/options) part begins if it +// starts with a dash and isn't shaped like a negative number -- that way +// `build -5 -3 -1` (variadic int parameters) still works even though the +// tokens start with '-'. +inline bool looksLikeOptionStart(std::string_view tok) { + if (tok.size() < 2 || tok[0] != '-') return false; + if (tok[1] == '-') return true; // "--foo" + if (std::isdigit(static_cast(tok[1]))) return false; // "-5" + return true; // "-n", "-ndi" +} + +inline bool isValidKeyword(std::string_view s) { + if (s.empty()) return false; + if (s[0] == '-') return false; // reserved for flags/options + for (char c : s) { + if (!(std::isalnum(static_cast(c)) || c == '.' || + c == '_' || c == '-')) { + return false; + } + } + return true; +} + +// A human-readable name for a bound action() argument type, used in +// RegistrationError messages. Only built-in scalars and enums are ever +// legal here (anything else is already rejected by parameter()'s own +// static_assert), so this never needs to handle the general case. +template +std::string friendlyTypeName() { + if constexpr (is_builtin_scalar_v) { + return TypeOf::get().displayName; + } else if constexpr (std::is_enum_v) { + return "a different enum type"; + } else { + return "an unsupported type"; + } +} + +} // namespace detail + +// Result of trying to line a command's structured (keyword+parameter) +// signature up against the front of the user's token list. +struct StructuredMatch { + bool success = false; + std::size_t consumed = 0; // tokens belonging to the structured part + int keywordScore = 0; // number of literal keywords matched + std::vector paramValues; + // Set when every literal keyword matched but a parameter's value + // either failed to parse or was never given -- lets the Engine + // surface this specific, actionable error instead of a generic + // "no match" + fuzzy suggestions. + std::string error; +}; + +class Command { +public: + // Keywords carry no description of their own -- .describe() on the + // command is what shows up in help; a keyword is purely a literal + // token to match. + Command& keyword(std::string literal) { + if (unstructuredStarted_) { + throw RegistrationError( + "can't declare keyword('" + literal + + "') after a flag()/option() was added -- keywords must " + "come entirely before the unstructured part"); + } + if (structuredSealed_) { + throw RegistrationError( + "can't declare keyword('" + literal + + "') after a variadic parameter -- a variadic parameter " + "must be the last thing in the structured part"); + } + if (!detail::isValidKeyword(literal)) { + throw RegistrationError( + "invalid keyword '" + literal + + "': keywords may contain letters, digits, '.', '_' and " + "'-', and may not start with '-'"); + } + Slot s; + s.kind = SlotKind::Keyword; + s.name = std::move(literal); + structured_.push_back(std::move(s)); + lastSlot_ = &structured_.back(); + return *this; + } + + // T is explicit and required: every parameter is typed at the point + // it's declared, not inferred later from action(). Called before any + // flag()/option(), this is a fixed-position structured parameter; + // called after, it's a "loose" parameter matched positionally among + // other loose parameters regardless of where flags/options fall + // around it. + template + Command& parameter(std::string name, std::string description, bool variadic = false) { + if (name.empty()) { + throw RegistrationError("parameter name may not be empty"); + } + Slot s; + s.kind = SlotKind::Parameter; + s.name = std::move(name); + s.description = std::move(description); + s.variadic = variadic; + s.cppType = std::type_index(typeid(T)); + if constexpr (std::is_enum_v) { + s.awaitingChoices = true; // choices(...) must follow immediately + } else { + static_assert(detail::is_builtin_scalar_v, + "cliforge: unsupported parameter type -- use a fixed-width int, " + "float/double, std::string, char, bool, or an enum (with " + "choices() called immediately after)"); + s.type = TypeOf::get(); + } + if (!unstructuredStarted_) { + if (structuredSealed_) { + throw RegistrationError( + "can't declare parameter('" + s.name + + "') after a variadic parameter -- a variadic parameter " + "must be the last thing in the structured part"); + } + structured_.push_back(std::move(s)); + lastSlot_ = &structured_.back(); + if (variadic) structuredSealed_ = true; + } else { + if (looseSealed_) { + throw RegistrationError( + "can't declare parameter('" + s.name + + "') after a variadic parameter -- a variadic parameter " + "must be the last parameter declared"); + } + unstructured_.push_back(std::move(s)); + lastSlot_ = &unstructured_.back(); + if (variadic) looseSealed_ = true; + } + return *this; + } + + // shortName is 0 (the default) for "no short form". + Command& flag(std::string longName, char shortName, std::string description) { + longName = stripDashes(std::move(longName)); + validateLongName(longName); + validateShortName(shortName); + checkNameFree(longName, shortName); + Slot s; + s.kind = SlotKind::Flag; + s.name = std::move(longName); + s.shortName = shortName; + s.description = std::move(description); + unstructured_.push_back(std::move(s)); + lastSlot_ = &unstructured_.back(); + unstructuredStarted_ = true; + return *this; + } + + // T is explicit and required, same as parameter(). Options are + // always optional in the calling convention (see action()) -- there + // is no way to declare a "required" option, by design. + template + Command& option(std::string longName, char shortName, std::string description, + bool variadic = false) { + longName = stripDashes(std::move(longName)); + validateLongName(longName); + validateShortName(shortName); + checkNameFree(longName, shortName); + Slot s; + s.kind = SlotKind::Option; + s.name = std::move(longName); + s.shortName = shortName; + s.description = std::move(description); + s.variadic = variadic; + s.cppType = std::type_index(typeid(T)); + if constexpr (std::is_enum_v) { + s.awaitingChoices = true; + } else { + static_assert(detail::is_builtin_scalar_v, + "cliforge: unsupported option type -- use a fixed-width int, " + "float/double, std::string, char, bool, or an enum (with " + "choices() called immediately after)"); + s.type = TypeOf::get(); + } + unstructured_.push_back(std::move(s)); + lastSlot_ = &unstructured_.back(); + unstructuredStarted_ = true; + return *this; + } + + Command& describe(std::string description) { + description_ = std::move(description); + return *this; + } + + // Restricts the most-recently-declared parameter/option to a fixed + // set of named values -- this is how custom enums plug in (T is + // deduced from the initializer list, no need to spell it out again), + // and it doubles as a generic "choice" restriction for any type. T + // must match the type declared at parameter()/option(). + template + Command& choices(std::initializer_list> values, + std::string typeName = "") { + if (lastSlot_ == nullptr || + (lastSlot_->kind != SlotKind::Parameter && lastSlot_->kind != SlotKind::Option)) { + throw RegistrationError( + "choices<>() must directly follow the parameter() or " + "option() it restricts"); + } + if (!lastSlot_->cppType || *lastSlot_->cppType != std::type_index(typeid(T))) { + throw RegistrationError("choices<>() type doesn't match the type declared for '" + + lastSlot_->name + "'"); + } + std::vector> table(values); + lastSlot_->type = + makeChoiceTypeInfo(typeName.empty() ? std::string("enum") : std::move(typeName), table); + lastSlot_->awaitingChoices = false; + return *this; + } + + // Binds this command to a free function (or captureless/capturing + // lambda, or std::function). Argument types are validated against + // what was already declared via parameter()/option()/flag() -- + // action() never invents a type, it only checks consistency -- and + // matched, in order, to [parameters in declaration order] then + // [flags/options/loose-parameters in declaration order]. Arguments + // may be taken by value, by reference, or by const reference. + template + Command& action(Func f) { + using Traits = detail::function_traits>; + if (structured_.empty() || + std::none_of(structured_.begin(), structured_.end(), [](const Slot& s) { + return s.kind == SlotKind::Keyword; + })) { + throw RegistrationError( + "a command must declare at least one keyword() so it can " + "be identified on the command line"); + } + for (const Slot& s : structured_) checkNotAwaitingChoices(s); + for (const Slot& s : unstructured_) checkNotAwaitingChoices(s); + + std::vector ordered = orderedBindableSlots(); + if (ordered.size() != Traits::arity) { + throw RegistrationError( + "action() function takes " + std::to_string(Traits::arity) + + " argument(s) but this command declared " + + std::to_string(ordered.size()) + + " parameter/flag/option slot(s) -- these must match 1:1, " + "in declaration order (parameters first, then flags/options)"); + } + bindArgs(std::function{f}, ordered, std::make_index_sequence{}); + sealed_ = true; + return *this; + } + + bool sealed() const { return sealed_; } + const std::string& description() const { return description_; } + + // --- matching / parsing entry points, used by Engine ------------- + + StructuredMatch tryMatchStructured(const std::vector& tokens) const { + StructuredMatch m; + std::size_t i = 0; + try { + for (std::size_t s = 0; s < structured_.size(); ++s) { + const Slot& slot = structured_[s]; + bool isLast = (s + 1 == structured_.size()); + if (slot.kind == SlotKind::Keyword) { + if (i >= tokens.size() || tokens[i] != slot.name) return StructuredMatch{}; + ++i; + ++m.keywordScore; + } else { + if (slot.variadic && isLast) { + std::vector collected; + while (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i])) { + collected.push_back(slot.type.parse(tokens[i], slot.name)); + ++i; + } + m.paramValues.push_back(Value::ofVector(std::move(collected))); + } else { + if (i >= tokens.size() || detail::looksLikeOptionStart(tokens[i])) { + // Every keyword up to here matched literally, so + // this is almost certainly the intended command + // -- just missing a required argument. + throw CliError( + "missing required argument '<" + slot.name + + ">': expected a value of type " + slot.type.displayName, + ErrorKind::MissingArgument); + } + m.paramValues.push_back(Value::ofScalar(slot.type.parse(tokens[i], slot.name))); + ++i; + } + } + } + } catch (const CliError& e) { + // Either a type mismatch (ParseError, a CliError) or the + // MissingArgument thrown just above -- both mean "this is the + // command you meant, but something about the value is wrong", + // so remember it instead of treating it as a plain non-match. + m.success = false; + m.error = e.what(); + return m; + } + m.success = true; + m.consumed = i; + return m; + } + + // Parses the (order-agnostic) flags/options/loose-parameters tail. + // Throws CliError (with a specific ErrorKind) describing exactly what + // went wrong: an unrecognized flag/option, a missing option value, a + // missing/extra positional argument, or a type mismatch. + std::vector parseUnstructured(const std::vector& tokens) const { + std::vector provided(unstructured_.size(), false); + std::vector> collected(unstructured_.size()); + std::vector looseTokens; // views into `tokens`, which outlives this call + looseTokens.reserve(tokens.size()); + + std::size_t i = 0; + while (i < tokens.size()) { + std::string_view tok = tokens[i]; + if (!detail::looksLikeOptionStart(tok)) { + // Not flag/option syntax (includes negative numbers like + // "-5") -- it's a value for the next loose parameter. + looseTokens.push_back(tok); + ++i; + continue; + } + if (tok[1] == '-') { + std::string_view rest = tok.substr(2); + auto eq = rest.find('='); + bool hasInline = (eq != std::string_view::npos); + std::string_view longName = hasInline ? rest.substr(0, eq) : rest; + std::string_view inlineValue = hasInline ? rest.substr(eq + 1) : std::string_view{}; + int idx = findByLong(longName); + if (idx < 0) throwUnknown(tok); + ++i; + consumeSlot(*this, idx, hasInline, inlineValue, tokens, i, provided, collected, tok); + } else { + std::string_view rest = tok.substr(1); + if (rest.size() == 1) { + int idx = findByShort(rest[0]); + if (idx < 0) throwUnknown(tok); + ++i; + consumeSlot(*this, idx, false, std::string_view{}, tokens, i, provided, collected, tok); + } else { + // Combined short flags, e.g. "-ndi" == -n -d -i. + for (char c : rest) { + int idx = findByShort(c); + if (idx < 0) { + throw CliError(std::string("unknown flag '-") + c + + "' in combined group '" + std::string(tok) + "'" + + suggestFlagOption(std::string("-") + c), + ErrorKind::UnknownOption); + } + if (unstructured_[static_cast(idx)].kind != SlotKind::Flag) { + throw CliError( + std::string("'-") + c + "' in combined group '" + std::string(tok) + + "' is an option (needs a value), so it can't " + "be combined with other short flags"); + } + provided[static_cast(idx)] = true; + } + ++i; + } + } + } + + // Assign the collected loose tokens to any declared loose + // Parameter slots, positionally, in declaration order -- this is + // independent of where those tokens fell relative to flags/options. + std::vector looseParamIdx; + for (std::size_t idx = 0; idx < unstructured_.size(); ++idx) { + if (unstructured_[idx].kind == SlotKind::Parameter) looseParamIdx.push_back(idx); + } + std::vector looseValues(looseParamIdx.size()); + std::size_t li = 0; + for (std::size_t k = 0; k < looseParamIdx.size(); ++k) { + const Slot& slot = unstructured_[looseParamIdx[k]]; + bool isLastLoose = (k + 1 == looseParamIdx.size()); + if (slot.variadic && isLastLoose) { + std::vector vals; + vals.reserve(looseTokens.size() > li ? looseTokens.size() - li : 0); + while (li < looseTokens.size()) { + vals.push_back(slot.type.parse(looseTokens[li], slot.name)); + ++li; + } + looseValues[k] = Value::ofVector(std::move(vals)); + } else { + if (li >= looseTokens.size()) { + throw CliError("missing required argument '<" + slot.name + + ">': expected a value of type " + slot.type.displayName, + ErrorKind::MissingArgument); + } + looseValues[k] = Value::ofScalar(slot.type.parse(looseTokens[li], slot.name)); + ++li; + } + } + if (li < looseTokens.size()) { + std::size_t extra = looseTokens.size() - li; + std::string msg = "too many arguments: unexpected '" + std::string(looseTokens[li]) + "'"; + if (extra > 1) msg += " (and " + std::to_string(extra - 1) + " more)"; + throw CliError(msg, ErrorKind::TooManyArguments); + } + + // Assemble the final values in unstructured_'s declaration order -- + // this is what gets paired with the bound function's arguments. + std::vector out; + out.reserve(unstructured_.size()); + std::size_t looseCursor = 0; + for (std::size_t idx = 0; idx < unstructured_.size(); ++idx) { + const Slot& slot = unstructured_[idx]; + if (slot.kind == SlotKind::Parameter) { + out.push_back(std::move(looseValues[looseCursor++])); + } else if (slot.kind == SlotKind::Flag) { + out.push_back(Value::ofScalar(Scalar{provided[idx]})); + } else if (slot.variadic) { + out.push_back(Value::ofVector(collected[idx])); + } else if (provided[idx]) { + out.push_back(Value::ofScalar(collected[idx][0])); + } else { + out.push_back(Value{}); // options are always optional -> unset means nullopt + } + } + return out; + } + + void invoke(std::vector allValues) const { invoke_(allValues); } + + std::size_t structuredSlotCount() const { return structured_.size(); } + + // Lenient prefix check used only for --help / `help ` : does + // `tokens` look like the start of (or a complete instance of) this + // command, without requiring every required argument to actually be + // present? Keywords still must match exactly; parameter slots accept + // any token; running out of input mid-command is fine (that's exactly + // what makes it a "prefix"). Used to find *every* command consistent + // with what's been typed so far, so an ambiguous prefix can show help + // for all of them instead of guessing one. + bool couldMatchPrefix(const std::vector& tokens) const { + std::size_t i = 0; + for (std::size_t s = 0; s < structured_.size(); ++s) { + if (i >= tokens.size()) break; // ran out of input -- a valid, incomplete prefix + const Slot& slot = structured_[s]; + bool isLast = (s + 1 == structured_.size()); + if (slot.kind == SlotKind::Keyword) { + if (tokens[i] != slot.name) return false; + ++i; + } else if (slot.variadic && isLast) { + while (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i])) ++i; + } else { + if (detail::looksLikeOptionStart(tokens[i])) break; // structured part left incomplete + ++i; + } + } + // Whatever tokens remain must be plausible flags/options (assumed + // fine without deep validation) or fit a declared loose parameter. + std::vector looseParams; + for (const Slot& s : unstructured_) { + if (s.kind == SlotKind::Parameter) looseParams.push_back(&s); + } + std::size_t looseIdx = 0; + for (; i < tokens.size(); ++i) { + if (detail::looksLikeOptionStart(tokens[i])) continue; + if (looseIdx >= looseParams.size()) return false; // nothing left to absorb this token + if (!looseParams[looseIdx]->variadic) ++looseIdx; + } + return true; + } + + // --- help / usage -------------------------------------------------- + + std::string usageLine() const { + std::ostringstream os; + for (std::size_t s = 0; s < structured_.size(); ++s) { + const Slot& slot = structured_[s]; + if (s) os << ' '; + if (slot.kind == SlotKind::Keyword) { + os << slot.name; + } else { + os << '<' << slot.name << (slot.variadic ? "..." : "") << '>'; + } + } + bool anyFlagOrOption = std::any_of(unstructured_.begin(), unstructured_.end(), + [](const Slot& s) { return s.kind != SlotKind::Parameter; }); + if (anyFlagOrOption) os << " [OPTIONS]"; + for (const Slot& slot : unstructured_) { + if (slot.kind != SlotKind::Parameter) continue; + os << " <" << slot.name << (slot.variadic ? "..." : "") << '>'; + } + return os.str(); + } + + std::string helpText(const std::string& progName) const { + std::ostringstream os; + os << "Usage: " << progName << " " << usageLine() << "\n"; + if (!description_.empty()) os << "\n" << description_ << "\n"; + + bool anyParams = std::any_of(structured_.begin(), structured_.end(), + [](const Slot& s) { return s.kind == SlotKind::Parameter; }) || + std::any_of(unstructured_.begin(), unstructured_.end(), + [](const Slot& s) { return s.kind == SlotKind::Parameter; }); + if (anyParams) { + os << "\nArguments:\n"; + for (const Slot& slot : structured_) { + if (slot.kind != SlotKind::Parameter) continue; + std::string label = "<" + slot.name + (slot.variadic ? "..." : "") + ">"; + os << " " << padRight(label, 22) << describeType(slot) << slot.description << "\n"; + } + for (const Slot& slot : unstructured_) { + if (slot.kind != SlotKind::Parameter) continue; + std::string label = "<" + slot.name + (slot.variadic ? "..." : "") + ">"; + os << " " << padRight(label, 22) << describeType(slot) << slot.description << "\n"; + } + } + + bool anyFlagOrOption = std::any_of(unstructured_.begin(), unstructured_.end(), + [](const Slot& s) { return s.kind != SlotKind::Parameter; }); + os << "\nOptions:\n"; + if (anyFlagOrOption) { + for (const Slot& slot : unstructured_) { + if (slot.kind == SlotKind::Parameter) continue; + std::string names = slot.shortName == '\0' + ? (" --" + slot.name) + : (std::string("-") + slot.shortName + ", --" + slot.name); + if (slot.kind == SlotKind::Option) { + names += " <" + slot.type.displayName + (slot.variadic ? "...>" : ">"); + } + os << " " << padRight(names, 28) << slot.description << "\n"; + } + } + os << " " << padRight("-h, --help", 28) << "Show this help message\n"; + return os.str(); + } + + std::vector flagOptionNames() const { + std::vector out; + for (const Slot& s : unstructured_) { + if (s.kind == SlotKind::Parameter) continue; + out.push_back("--" + s.name); + if (s.shortName != '\0') out.push_back(std::string("-") + s.shortName); + } + return out; + } + + // Fuzzy structural distance used for "did you mean...?" suggestions. + struct FuzzyScore { + std::size_t distance = 0; + std::size_t keywordChars = 0; // denominator for a relative threshold + }; + + FuzzyScore fuzzyStructuralDistance(const std::vector& tokens) const { + std::size_t i = 0; + FuzzyScore score; + for (std::size_t s = 0; s < structured_.size(); ++s) { + const Slot& slot = structured_[s]; + bool isLast = (s + 1 == structured_.size()); + if (slot.kind == SlotKind::Keyword) { + score.keywordChars += slot.name.size(); + if (i >= tokens.size()) { + score.distance += slot.name.size(); // command is longer than what was typed + continue; + } + score.distance += detail::levenshtein(tokens[i], slot.name); + ++i; + } else if (slot.variadic && isLast) { + while (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i])) ++i; + } else if (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i])) { + ++i; // parameters are wildcards: any token "fits", no penalty + } + } + score.distance += (tokens.size() > i) ? (tokens.size() - i) : 0; // leftover tokens + return score; + } + +private: + std::vector structured_; + std::vector unstructured_; + std::string description_; + std::function&)> invoke_; + Slot* lastSlot_ = nullptr; + bool unstructuredStarted_ = false; + bool structuredSealed_ = false; + bool looseSealed_ = false; + bool sealed_ = false; + + static std::string stripDashes(std::string s) { + while (!s.empty() && s.front() == '-') s.erase(s.begin()); + return s; + } + + void validateLongName(const std::string& s) const { + if (s.empty()) throw RegistrationError("a flag/option needs a non-empty long name"); + if (s == "help") { + throw RegistrationError("'help' is reserved for the built-in --help flag"); + } + for (char c : s) { + if (!(std::isalnum(static_cast(c)) || c == '-' || c == '_')) { + throw RegistrationError("invalid long name '--" + s + "'"); + } + } + } + + void validateShortName(char c) const { + if (c == '\0') return; // no short form + if (!std::isalnum(static_cast(c))) { + throw RegistrationError(std::string("short names must be a single letter/digit, got '-") + + c + "'"); + } + if (c == 'h') throw RegistrationError("'-h' is reserved for the built-in --help flag"); + } + + void checkNameFree(const std::string& longName, char shortName) const { + for (const Slot& s : unstructured_) { + if (s.kind == SlotKind::Parameter) continue; // different namespace (bare, not "--name") + if (s.name == longName) { + throw RegistrationError("duplicate long name '--" + longName + "'"); + } + if (shortName != '\0' && s.shortName == shortName) { + throw RegistrationError(std::string("duplicate short name '-") + shortName + "'"); + } + } + } + + static void checkNotAwaitingChoices(const Slot& s) { + if (s.awaitingChoices) { + throw RegistrationError("'" + s.name + + "' is a custom enum type and needs choices(...) " + "called right after it is declared"); + } + } + + std::vector orderedBindableSlots() { + std::vector out; + for (Slot& s : structured_) { + if (s.kind == SlotKind::Parameter) out.push_back(&s); + } + for (Slot& s : unstructured_) out.push_back(&s); + return out; + } + + // Validates (never invents) that a slot's declared type matches T. + template + static void checkTypeMatches(const Slot& slot) { + if (!slot.cppType || *slot.cppType != std::type_index(typeid(T))) { + throw RegistrationError("'" + slot.name + "' was declared as " + slot.type.displayName + + " but action() expects " + detail::friendlyTypeName()); + } + } + + // ArgT is the *raw* action() parameter type, e.g. `const std::string&` + // or `std::vector` -- validated after stripping cv/ref. + template + static void configureSlot(Slot& slot) { + using Stripped = std::remove_cvref_t; + if (slot.kind == SlotKind::Flag) { + if constexpr (!std::is_same_v) { + throw RegistrationError("flag '--" + slot.name + "' must bind to bool in action()"); + } + return; + } + if constexpr (detail::is_optional_v) { + using Inner = typename detail::is_optional::inner; + if (slot.kind == SlotKind::Parameter) { + throw RegistrationError("parameter '<" + slot.name + + ">' can't bind to std::optional<> -- positional " + "parameters are always required"); + } + if (slot.variadic) { + throw RegistrationError("option '--" + slot.name + + "' is variadic; bind it to std::vector<>, not std::optional<>"); + } + checkTypeMatches(slot); + } else if constexpr (detail::is_vector_v) { + using Inner = typename detail::is_vector::inner; + if (!slot.variadic) { + throw RegistrationError("'" + slot.name + + "' binds to std::vector<> but wasn't declared variadic=true"); + } + checkTypeMatches(slot); + } else { + if (slot.variadic) { + throw RegistrationError("'" + slot.name + + "' was declared variadic=true; bind it to std::vector<>"); + } + if (slot.kind == SlotKind::Option) { + throw RegistrationError("option '--" + slot.name + + "' must bind to std::optional (or std::vector if " + "variadic) -- options are always optional, never required"); + } + checkTypeMatches(slot); + } + } + + template + static ArgT extractArg(const Value& v) { + if constexpr (std::is_same_v) { + return v.as(); + } else if constexpr (detail::is_optional_v) { + using Inner = typename detail::is_optional::inner; + return v.hasValue() ? std::optional(v.as()) : std::nullopt; + } else if constexpr (detail::is_vector_v) { + using Inner = typename detail::is_vector::inner; + return v.asVector(); + } else { + return v.as(); + } + } + + // Extracted values are materialized into a tuple of *value* types + // first, then handed to f via std::apply -- std::get on that (named, + // lvalue) tuple yields lvalues, which is what lets f's parameters be + // declared by value, by reference, or by const reference and all + // still bind correctly. + template + void bindArgs(std::function f, std::vector& ordered, + std::index_sequence) { + (configureSlot(*ordered[I]), ...); + invoke_ = [f = std::move(f)](std::vector& values) { + std::tuple...> extracted{ + extractArg>(values[I])...}; + std::apply(f, extracted); + }; + } + + int findByLong(std::string_view name) const { + for (std::size_t i = 0; i < unstructured_.size(); ++i) { + if (unstructured_[i].kind != SlotKind::Parameter && unstructured_[i].name == name) { + return static_cast(i); + } + } + return -1; + } + int findByShort(char c) const { + if (c == '\0') return -1; + for (std::size_t i = 0; i < unstructured_.size(); ++i) { + if (unstructured_[i].kind != SlotKind::Parameter && unstructured_[i].shortName == c) { + return static_cast(i); + } + } + return -1; + } + + std::string suggestFlagOption(const std::string& given) const { + auto names = flagOptionNames(); + names.push_back("--help"); + names.push_back("-h"); + auto close = detail::closestMatches(given, names, 1); + if (close.empty()) return ""; + return " -- did you mean '" + close.front() + "'?"; + } + + [[noreturn]] void throwUnknown(std::string_view tok) const { + throw CliError("unknown flag/option '" + std::string(tok) + "' for this command" + + suggestFlagOption(std::string(tok)), + ErrorKind::UnknownOption); + } + + static std::string describeType(const Slot& slot) { + return "<" + slot.type.displayName + "> "; + } + + static std::string padRight(const std::string& s, std::size_t width) { + if (s.size() >= width) return s + " "; + return s + std::string(width - s.size(), ' '); + } + + static void consumeSlot(const Command& self, int idxIn, bool hasInline, + std::string_view inlineValue, const std::vector& tokens, + std::size_t& i, std::vector& provided, + std::vector>& collected, std::string_view tokForError) { + std::size_t idx = static_cast(idxIn); + const Slot& slot = self.unstructured_[idx]; + if (slot.kind == SlotKind::Flag) { + if (hasInline) throw CliError("flag '--" + slot.name + "' does not take a value"); + provided[idx] = true; + return; + } + // Option + if (hasInline) { + collected[idx].push_back(slot.type.parse(inlineValue, slot.name)); + provided[idx] = true; + if (slot.variadic) { + while (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i])) { + collected[idx].push_back(slot.type.parse(tokens[i], slot.name)); + ++i; + } + } + return; + } + if (slot.variadic) { + std::size_t before = i; + while (i < tokens.size() && !detail::looksLikeOptionStart(tokens[i])) { + collected[idx].push_back(slot.type.parse(tokens[i], slot.name)); + ++i; + } + if (i == before) { + throw CliError("option '" + std::string(tokForError) + "' requires at least one value", + ErrorKind::MissingValue); + } + provided[idx] = true; + } else { + if (i >= tokens.size() || detail::looksLikeOptionStart(tokens[i])) { + throw CliError("option '" + std::string(tokForError) + "' requires a value", + ErrorKind::MissingValue); + } + collected[idx] = {slot.type.parse(tokens[i], slot.name)}; + provided[idx] = true; + ++i; + } + } +}; + +} // namespace cliforge diff --git a/include/cliforge/engine.hpp b/include/cliforge/engine.hpp new file mode 100644 index 0000000..bd92718 --- /dev/null +++ b/include/cliforge/engine.hpp @@ -0,0 +1,241 @@ +#pragma once +// +// engine.hpp +// +// Engine owns every registered Command and is the single entry point: +// +// Engine cli; +// cli.describe("A demo CLI"); +// cli.command()....action(&fn); +// return cli.run(argc, argv); +// +#include +#include +#include +#include +#include +#include + +#include "command.hpp" +#include "levenshtein.hpp" + +namespace cliforge { + +class Engine { +public: + explicit Engine(std::string programName = "") : programName_(std::move(programName)) {} + + Engine& describe(std::string description) { + description_ = std::move(description); + return *this; + } + + // Registers a new, empty command and returns a reference to it so it + // can be built up with the fluent .keyword()/.parameter()/... API. + // The Engine owns it for the rest of the program's lifetime. + Command& command() { + commands_.push_back(std::make_unique()); + return *commands_.back(); + } + + int run(int argc, char** argv) { + if (programName_.empty() && argc > 0) programName_ = basename(argv[0]); + for (auto& cmd : commands_) { + if (!cmd->sealed()) { + std::cerr << "cliforge BUG: a command was registered without calling .action() " + "on it -- every command must be bound to a function.\n"; + return 2; + } + } + + std::vector tokens; + for (int i = 1; i < argc; ++i) tokens.emplace_back(argv[i]); + + if (tokens.empty()) { + printGlobalHelp(std::cout); + return 0; + } + + // Centralized help handling: `help [words...]`, or --help/-h + // appearing anywhere. Both funnel into the same prefix search, so + // an ambiguous prefix (e.g. "project --help" matching create/ + // delete/info) shows help for every command it's consistent with, + // instead of silently picking one or matching nothing. + std::optional> helpQuery; + if (tokens[0] == "help") { + helpQuery = std::vector(tokens.begin() + 1, tokens.end()); + } else if (auto it = std::find_if(tokens.begin(), tokens.end(), + [](const std::string& t) { return t == "--help" || t == "-h"; }); + it != tokens.end()) { + std::vector q = tokens; + q.erase(q.begin() + (it - tokens.begin())); + helpQuery = std::move(q); + } + if (helpQuery) { + return showHelp(*helpQuery); + } + + struct Candidate { + Command* cmd; + StructuredMatch match; + }; + std::vector candidates; + std::string bestPartialError; + Command* bestPartialCmd = nullptr; + int bestPartialScore = -1; + for (auto& cmd : commands_) { + StructuredMatch m = cmd->tryMatchStructured(tokens); + if (m.success) { + candidates.push_back({cmd.get(), std::move(m)}); + } else if (!m.error.empty() && m.keywordScore > bestPartialScore) { + bestPartialScore = m.keywordScore; + bestPartialError = m.error; + bestPartialCmd = cmd.get(); + } + } + + if (candidates.empty()) { + if (bestPartialCmd) { + // Every keyword matched some command, just with a bad or + // missing argument value -- this is much more actionable + // than a generic "no command matches" + fuzzy suggestions. + std::cerr << "Error: " << bestPartialError << "\n"; + std::cerr << "Usage: " << programName_ << " " << bestPartialCmd->usageLine() << "\n"; + return 1; + } + std::cerr << "Error: no command matches '" << join(tokens) << "'.\n"; + suggestCommands(tokens, std::cerr); + return 1; + } + + std::sort(candidates.begin(), candidates.end(), [](const Candidate& a, const Candidate& b) { + if (a.match.keywordScore != b.match.keywordScore) + return a.match.keywordScore > b.match.keywordScore; + return a.cmd->structuredSlotCount() > b.cmd->structuredSlotCount(); + }); + + std::string firstError; + Command* firstErrorCmd = nullptr; + bool haveError = false; + for (auto& c : candidates) { + std::vector remaining(tokens.begin() + static_cast(c.match.consumed), + tokens.end()); + try { + std::vector values = c.cmd->parseUnstructured(remaining); + std::vector all = c.match.paramValues; + for (auto& v : values) all.push_back(std::move(v)); + c.cmd->invoke(std::move(all)); + return 0; + } catch (const CliError& e) { + // Candidates are ranked best-first (most keyword matches + // first), so the *first* failure is the one most likely to + // be what the user actually meant -- keep that, not + // whichever candidate happens to fail last. + if (!haveError) { + firstError = e.what(); + firstErrorCmd = c.cmd; + haveError = true; + } + } + } + + std::cerr << "Error: " << firstError << "\n"; + if (firstErrorCmd) std::cerr << "Usage: " << programName_ << " " << firstErrorCmd->usageLine() << "\n"; + return 1; + } + +private: + std::string programName_; + std::string description_; + std::vector> commands_; + + static std::string basename(const std::string& path) { + auto pos = path.find_last_of("/\\"); + return pos == std::string::npos ? path : path.substr(pos + 1); + } + + static std::string join(const std::vector& tokens) { + std::string out; + for (std::size_t i = 0; i < tokens.size(); ++i) { + if (i) out += ' '; + out += tokens[i]; + } + return out; + } + + // Finds every command consistent with `queryTokens` as a prefix and + // shows its help -- one command's help if there's exactly one match, + // all of them (clearly separated) if the prefix is genuinely + // ambiguous, or the global command list plus a hint if there's none. + int showHelp(const std::vector& queryTokens) const { + if (queryTokens.empty()) { + printGlobalHelp(std::cout); + return 0; + } + std::vector matches; + for (auto& cmd : commands_) { + if (cmd->couldMatchPrefix(queryTokens)) matches.push_back(cmd.get()); + } + if (matches.empty()) { + std::cout << "No command matches '" << join(queryTokens) << "'.\n\n"; + printGlobalHelp(std::cout); + return 1; + } + if (matches.size() == 1) { + std::cout << matches.front()->helpText(programName_); + return 0; + } + std::cout << "'" << join(queryTokens) << "' matches " << matches.size() + << " commands:\n\n"; + for (std::size_t i = 0; i < matches.size(); ++i) { + if (i) std::cout << "\n"; + std::cout << matches[i]->helpText(programName_); + } + return 0; + } + + void printGlobalHelp(std::ostream& os) const { + os << "Usage: " << programName_ << " [ARGUMENTS] [OPTIONS]\n"; + if (!description_.empty()) os << "\n" << description_ << "\n"; + os << "\nCommands:\n"; + for (auto& cmd : commands_) { + std::string line = programName_ + " " + cmd->usageLine(); + os << " " << padRight(line, 46) << firstLine(cmd->description()) << "\n"; + } + os << "\nRun '" << programName_ << " help ' or add --help/-h to any command " + << "for details.\n"; + } + + void suggestCommands(const std::vector& tokens, std::ostream& os) const { + std::vector> scored; + for (auto& cmd : commands_) { + Command::FuzzyScore s = cmd->fuzzyStructuralDistance(tokens); + double ratio = s.keywordChars ? static_cast(s.distance) / static_cast(s.keywordChars) + : 1.0; + scored.emplace_back(ratio, cmd.get()); + } + std::sort(scored.begin(), scored.end(), + [](auto& a, auto& b) { return a.first < b.first; }); + + std::vector close; + for (auto& [ratio, cmd] : scored) { + if (close.size() >= 3) break; + if (ratio <= 0.5) close.push_back(cmd); + } + if (close.empty()) return; + os << "\nDid you mean:\n"; + for (auto* cmd : close) os << " " << programName_ << " " << cmd->usageLine() << "\n"; + } + + static std::string firstLine(const std::string& s) { + auto pos = s.find('\n'); + return pos == std::string::npos ? s : s.substr(0, pos); + } + + static std::string padRight(const std::string& s, std::size_t width) { + if (s.size() >= width) return s + " "; + return s + std::string(width - s.size(), ' '); + } +}; + +} // namespace cliforge diff --git a/include/cliforge/function_traits.hpp b/include/cliforge/function_traits.hpp new file mode 100644 index 0000000..ce9f21d --- /dev/null +++ b/include/cliforge/function_traits.hpp @@ -0,0 +1,65 @@ +#pragma once +// +// function_traits.hpp +// +// Deduces the return type and argument-type pack of "anything callable": +// plain free-function pointers, non-generic lambdas (captureless or +// capturing), and std::function instances. This is what lets +// Command::action() figure out, at compile time, exactly which C++ types +// it needs to convert parsed CLI tokens into -- without the user ever +// having to spell the types out twice. +// +#include +#include +#include + +namespace cliforge::detail { + +template +struct function_traits; + +// Plain free function: void foo(std::string, int) +template +struct function_traits { + using return_type = Ret; + static constexpr std::size_t arity = sizeof...(Args); + template + using arg = std::tuple_element_t>; + using args_tuple = std::tuple; +}; + +// std::function +template +struct function_traits> { + using return_type = Ret; + static constexpr std::size_t arity = sizeof...(Args); + template + using arg = std::tuple_element_t>; + using args_tuple = std::tuple; +}; + +// Pointer-to-member-function of operator(), const and non-const, used to +// peel apart lambdas (which are just anonymous functors under the hood). +template +struct function_traits { + using return_type = Ret; + static constexpr std::size_t arity = sizeof...(Args); + template + using arg = std::tuple_element_t>; + using args_tuple = std::tuple; +}; + +template +struct function_traits { + using return_type = Ret; + static constexpr std::size_t arity = sizeof...(Args); + template + using arg = std::tuple_element_t>; + using args_tuple = std::tuple; +}; + +// Fallback: any other callable (lambda, functor) -- peel off operator(). +template +struct function_traits : function_traits {}; + +} // namespace cliforge::detail diff --git a/include/cliforge/levenshtein.hpp b/include/cliforge/levenshtein.hpp new file mode 100644 index 0000000..b379fdc --- /dev/null +++ b/include/cliforge/levenshtein.hpp @@ -0,0 +1,61 @@ +#pragma once +// +// levenshtein.hpp +// +// Plain edit-distance + a small "closest matches" helper, used to power +// the "did you mean...?" suggestions for unknown commands, flags and +// options. +// +#include +#include +#include + +namespace cliforge::detail { + +inline std::size_t levenshtein(const std::string& a, const std::string& b) { + const std::size_t n = a.size(), m = b.size(); + std::vector prev(m + 1), cur(m + 1); + for (std::size_t j = 0; j <= m; ++j) prev[j] = j; + for (std::size_t i = 1; i <= n; ++i) { + cur[0] = i; + for (std::size_t j = 1; j <= m; ++j) { + std::size_t cost = (a[i - 1] == b[j - 1]) ? 0 : 1; + cur[j] = std::min({prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost}); + } + std::swap(prev, cur); + } + return prev[m]; +} + +// Returns up to `limit` candidates from `pool` that are close to `query`, +// ordered by distance. Anything farther than a generous relative +// threshold is dropped so we never suggest something wildly unrelated. +inline std::vector closestMatches(const std::string& query, + const std::vector& pool, + std::size_t limit = 3) { + std::vector> scored; + scored.reserve(pool.size()); + for (const auto& candidate : pool) { + std::size_t d = levenshtein(query, candidate); + // A prefix relationship (e.g. "--arch" for "--architectures") is a + // very strong signal -- treat it as near-identical even though its + // raw edit distance is large, so common abbreviations still surface. + if (query.size() >= 2 && candidate.size() > query.size() && + candidate.compare(0, query.size(), query) == 0) { + d = std::min(d, 1); + } + scored.emplace_back(d, candidate); + } + std::sort(scored.begin(), scored.end(), + [](auto& a, auto& b) { return a.first < b.first; }); + + std::size_t threshold = std::max(3, query.size() / 2); + std::vector out; + for (auto& [dist, name] : scored) { + if (out.size() >= limit) break; + if (dist <= threshold) out.push_back(name); + } + return out; +} + +} // namespace cliforge::detail diff --git a/include/cliforge/slot.hpp b/include/cliforge/slot.hpp new file mode 100644 index 0000000..6771364 --- /dev/null +++ b/include/cliforge/slot.hpp @@ -0,0 +1,62 @@ +#pragma once +// +// slot.hpp +// +// A `Slot` is one piece of a command's signature: a literal keyword, a +// typed positional parameter, a boolean flag, or a typed option. The +// same struct is reused for all four so Command can keep them in plain +// vectors and the matcher/parser code doesn't need four parallel types. +// +#include +#include +#include + +#include "value.hpp" + +namespace cliforge { + +enum class SlotKind { Keyword, Parameter, Flag, Option }; + +struct Slot { + SlotKind kind{}; + + // Keyword: the literal text the user must type (e.g. "delete"). + // Parameter: the display name (e.g. "name" -> shown as ). + // Flag/Option: the long name WITHOUT the leading "--" (e.g. "force"). + std::string name; + + // Flags/Options only: single-letter short name (e.g. 'f'), or '\0' + // if no short form was registered. A plain char instead of a string + // avoids a heap allocation (and the temptation to compare via + // std::string) for what is always exactly zero or one character. + char shortName = '\0'; + + std::string description; + + // Parameter/Option only: may this slot consume more than one token? + // Only the *last* structured parameter, or the *last* loose + // parameter, or any option, may be variadic. + bool variadic = false; + + // Set immediately by parameter()/option(), or by choices(), + // never deferred -- so the type is always known and displayable even + // before action() is called. + TypeInfo type; + + // The exact C++ type T declared via parameter()/option(), kept + // so action() can *validate* (not invent) the bound function's + // corresponding argument type. Unset for Keyword/Flag slots. + std::optional cppType; + + // True from parameter()/option() (when T is an enum) until the + // matching choices() call supplies the name table; action() checks + // this is false for every slot before binding. + bool awaitingChoices = false; + + std::string longFlag() const { return "--" + name; } + std::string shortFlag() const { + return shortName ? std::string("-") + shortName : std::string(); + } +}; + +} // namespace cliforge diff --git a/include/cliforge/value.hpp b/include/cliforge/value.hpp new file mode 100644 index 0000000..acf70d8 --- /dev/null +++ b/include/cliforge/value.hpp @@ -0,0 +1,353 @@ +#pragma once +// +// value.hpp +// +// The type system. A `Scalar` is a closed variant over every primitive +// type the engine understands. A `Value` wraps either a single Scalar +// (for ordinary parameters/options) or a vector of them (for variadic +// parameters/options). `TypeInfo` bundles a display name with a parser: +// for the built-in scalar types this is a *plain stateless function +// pointer* (no heap allocation, unlike std::function), and only the +// rarer enum/choice-restricted case pays for a one-time, shared, heap +// allocation to hold its name table. +// +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cliforge { + +// --------------------------------------------------------------------- +// Exceptions +// --------------------------------------------------------------------- + +// Every distinct reason a run-time parse can fail. Exposed on CliError so +// callers (and Engine internally, for its own error-selection logic) can +// branch on *why* something failed, not just read a free-text message. +enum class ErrorKind { + Generic, + UnknownOption, // a flag/option name isn't recognized for this command + TooManyArguments, // more positional values than the command declares + MissingArgument, // a required positional value (or option value) was never given + MissingValue, // an option/flag was named but its value token is missing + TypeMismatch, // a token couldn't convert to the slot's declared type +}; + +// Base of every error the engine raises while registering commands or +// parsing user input. Engine::run() catches CliError and prints it +// nicely instead of letting it escape as an unhandled exception. +class CliError : public std::runtime_error { +public: + explicit CliError(const std::string& msg, ErrorKind kind = ErrorKind::Generic) + : std::runtime_error(msg), kind_(kind) {} + ErrorKind kind() const noexcept { return kind_; } + +private: + ErrorKind kind_; +}; + +// A token couldn't be converted to the type a slot expects -- always +// ErrorKind::TypeMismatch. +class ParseError : public CliError { +public: + explicit ParseError(const std::string& msg) : CliError(msg, ErrorKind::TypeMismatch) {} +}; + +// Something is wrong with how a command was *built* (mismatched arity +// between declared slots and the bound function, duplicate names, a +// choices() call whose T doesn't match the declared type, an option +// bound to a non-optional type, an action() parameter type that doesn't +// match what was declared, etc). These fire at program startup (many of +// them at compile time via static_assert), not at end-user parse time. +struct RegistrationError : CliError { + explicit RegistrationError(const std::string& msg) : CliError(msg, ErrorKind::Generic) {} +}; + +// --------------------------------------------------------------------- +// Enum storage +// --------------------------------------------------------------------- + +// Custom enums aren't a native Scalar alternative (there are infinitely +// many enum types), so when a slot is restricted to an enum via +// choices(), the underlying value is boxed here. Value::as() knows +// to unbox + static_cast back to the caller's real enum type. +struct EnumTag { + long long value; +}; + +// --------------------------------------------------------------------- +// Scalar: the closed set of representable primitive values +// --------------------------------------------------------------------- + +using Scalar = std::variant; + +enum class Kind { + I8, U8, I16, U16, I32, U32, I64, U64, F32, F64, Str, Char, Bool, Enum +}; + +// --------------------------------------------------------------------- +// TypeInfo: how to parse + describe one type +// --------------------------------------------------------------------- + +// Holds the name table for an enum/choice-restricted slot. Built once at +// registration time (a single, amortized allocation via shared_ptr); +// parsing against it never allocates beyond the error path. +struct ChoiceTable { + std::vector> entries; + + Scalar parse(std::string_view token, std::string_view label) const { + for (const auto& [name, val] : entries) { + if (name == token) return val; + } + std::string allowed; + for (size_t i = 0; i < entries.size(); ++i) { + if (i) allowed += ", "; + allowed += entries[i].first; + } + throw ParseError("type mismatch for '" + std::string(label) + "': got '" + + std::string(token) + "', expected one of: " + allowed); + } +}; + +struct TypeInfo { + Kind kind{}; + std::string displayName; + // Stateless parser for built-in scalar types -- a plain function + // pointer costs nothing to store or copy, unlike std::function. + Scalar (*parsePlain)(std::string_view token, std::string_view label) = nullptr; + // Populated only for choices()-restricted slots. + std::shared_ptr choiceTable; + + bool valid() const { return parsePlain != nullptr || choiceTable != nullptr; } + + Scalar parse(std::string_view token, std::string_view label) const { + return choiceTable ? choiceTable->parse(token, label) : parsePlain(token, label); + } +}; + +namespace detail { + +template +Int parseInteger(std::string_view token, std::string_view label, std::string_view typeName) { + Int out{}; + auto [ptr, ec] = std::from_chars(token.data(), token.data() + token.size(), out); + if (ec != std::errc{} || ptr != token.data() + token.size()) { + throw ParseError("type mismatch for '" + std::string(label) + "': got '" + + std::string(token) + "', expected " + std::string(typeName)); + } + return out; +} + +template +Float parseFloating(std::string_view token, std::string_view label, std::string_view typeName) { + Float out{}; + auto [ptr, ec] = std::from_chars(token.data(), token.data() + token.size(), out); + if (ec != std::errc{} || ptr != token.data() + token.size()) { + throw ParseError("type mismatch for '" + std::string(label) + "': got '" + + std::string(token) + "', expected " + std::string(typeName)); + } + return out; +} + +inline std::string toLower(std::string_view s) { + std::string out(s); + std::transform(out.begin(), out.end(), out.begin(), + [](unsigned char c) { return std::tolower(c); }); + return out; +} + +} // namespace detail + +// Maps a C++ type T to its Kind + a ready-to-use TypeInfo. Specialized +// below for every built-in type; enums go through choices() instead +// since they need a name<->value table supplied by the caller. +template +struct TypeOf; // intentionally undefined for unsupported T + +#define CLIFORGE_INT_TYPE(CPP_T, KIND, NAME) \ + template <> \ + struct TypeOf { \ + static TypeInfo get() { \ + TypeInfo t; \ + t.kind = Kind::KIND; \ + t.displayName = NAME; \ + t.parsePlain = [](std::string_view tok, std::string_view lbl) -> Scalar { \ + return Scalar{detail::parseInteger(tok, lbl, NAME)}; \ + }; \ + return t; \ + } \ + }; + +CLIFORGE_INT_TYPE(int8_t, I8, "int8") +CLIFORGE_INT_TYPE(uint8_t, U8, "uint8") +CLIFORGE_INT_TYPE(int16_t, I16, "int16") +CLIFORGE_INT_TYPE(uint16_t, U16, "uint16") +CLIFORGE_INT_TYPE(int32_t, I32, "int32") +CLIFORGE_INT_TYPE(uint32_t, U32, "uint32") +CLIFORGE_INT_TYPE(int64_t, I64, "int64") +CLIFORGE_INT_TYPE(uint64_t, U64, "uint64") +#undef CLIFORGE_INT_TYPE + +template <> +struct TypeOf { + static TypeInfo get() { + TypeInfo t; + t.kind = Kind::F32; + t.displayName = "float32"; + t.parsePlain = [](std::string_view tok, std::string_view lbl) -> Scalar { + return Scalar{detail::parseFloating(tok, lbl, "float32")}; + }; + return t; + } +}; + +template <> +struct TypeOf { + static TypeInfo get() { + TypeInfo t; + t.kind = Kind::F64; + t.displayName = "float64"; + t.parsePlain = [](std::string_view tok, std::string_view lbl) -> Scalar { + return Scalar{detail::parseFloating(tok, lbl, "float64")}; + }; + return t; + } +}; + +template <> +struct TypeOf { + static TypeInfo get() { + TypeInfo t; + t.kind = Kind::Str; + t.displayName = "string"; + t.parsePlain = [](std::string_view tok, std::string_view) -> Scalar { + return Scalar{std::string(tok)}; + }; + return t; + } +}; + +template <> +struct TypeOf { + static TypeInfo get() { + TypeInfo t; + t.kind = Kind::Char; + t.displayName = "char"; + t.parsePlain = [](std::string_view tok, std::string_view lbl) -> Scalar { + if (tok.size() != 1) { + throw ParseError("type mismatch for '" + std::string(lbl) + "': got '" + + std::string(tok) + "', expected a single char"); + } + return Scalar{tok[0]}; + }; + return t; + } +}; + +template <> +struct TypeOf { + static TypeInfo get() { + TypeInfo t; + t.kind = Kind::Bool; + t.displayName = "bool"; + t.parsePlain = [](std::string_view tok, std::string_view lbl) -> Scalar { + std::string lower = detail::toLower(tok); + if (lower == "true" || lower == "1" || lower == "yes" || lower == "on") { + return Scalar{true}; + } + if (lower == "false" || lower == "0" || lower == "no" || lower == "off") { + return Scalar{false}; + } + throw ParseError("type mismatch for '" + std::string(lbl) + "': got '" + + std::string(tok) + "', expected bool (true/false, 1/0, yes/no, on/off)"); + }; + return t; + } +}; + +// Builds a TypeInfo for a `choices()` restricted slot: only the +// listed string keys are accepted, each mapped to a T. Works both for +// genuine C++ enums (boxed into EnumTag) and for restricting a built-in +// type (e.g. a string option limited to a fixed set of values). The +// choice table is converted to type-erased Scalars once, here, so +// nothing downstream needs to be templated on T again. +template +TypeInfo makeChoiceTypeInfo(std::string typeName, + const std::vector>& choices) { + auto table = std::make_shared(); + table->entries.reserve(choices.size()); + for (const auto& [name, val] : choices) { + if constexpr (std::is_enum_v) { + table->entries.emplace_back(name, Scalar{EnumTag{static_cast(val)}}); + } else { + table->entries.emplace_back(name, Scalar{val}); + } + } + TypeInfo t; + t.kind = Kind::Enum; + t.displayName = std::move(typeName); + t.choiceTable = std::move(table); + return t; +} + +// --------------------------------------------------------------------- +// Value: what actually flows out of the parser and into bound functions +// --------------------------------------------------------------------- + +class Value { +public: + Value() = default; + + static Value ofScalar(Scalar s) { + Value v; + v.data_ = std::move(s); + return v; + } + static Value ofVector(std::vector v) { + Value out; + out.data_ = std::move(v); + return out; + } + + bool hasValue() const { return !std::holds_alternative(data_); } + bool isVector() const { return std::holds_alternative>(data_); } + + template + T as() const { + const Scalar& s = std::get(data_); + if constexpr (std::is_enum_v) { + return static_cast(std::get(s).value); + } else { + return std::get(s); + } + } + + template + std::vector asVector() const { + const auto& vec = std::get>(data_); + std::vector out; + out.reserve(vec.size()); + for (const Scalar& s : vec) { + if constexpr (std::is_enum_v) { + out.push_back(static_cast(std::get(s).value)); + } else { + out.push_back(std::get(s)); + } + } + return out; + } + +private: + std::variant> data_; +}; + +} // namespace cliforge diff --git a/tests/registration_errors_test.cpp b/tests/registration_errors_test.cpp new file mode 100644 index 0000000..b323979 --- /dev/null +++ b/tests/registration_errors_test.cpp @@ -0,0 +1,144 @@ +// Exercises the *registration-time* safety guards: things a programmer +// could get wrong while wiring up commands, which should fail loudly and +// clearly at startup rather than misbehaving at run time -- plus a few +// "this should now succeed" checks for behavior that's supposed to work. +#include +#include +#include +#include + +#include + +using cliforge::Command; +using cliforge::Engine; +using cliforge::RegistrationError; + +enum class Color { Red, Green }; + +void noop(const std::string&) {} +void noop2(const std::string&, bool) {} +void noopOptStrStr(const std::optional&, const std::string&) {} +void noopBoolOptVec(bool, const std::optional&, const std::vector&) {} +void noopWrongType(int32_t) {} +void noopColor(Color) {} + +#define EXPECT_THROW(label, expr) \ + do { \ + try { \ + expr; \ + std::printf("[FAIL] %-45s did not throw\n", label); \ + } catch (const RegistrationError& e) { \ + std::printf("[ OK ] %-45s -> %s\n", label, e.what()); \ + } \ + } while (0) + +#define EXPECT_OK(label, expr) \ + do { \ + try { \ + expr; \ + std::printf("[ OK ] %-45s (correctly did not throw)\n", label); \ + } catch (const RegistrationError& e) { \ + std::printf("[FAIL] %-45s threw: %s\n", label, e.what()); \ + } \ + } while (0) + +int main() { + Engine cli; + + EXPECT_THROW("keyword after flag", ({ + cli.command() + .keyword("a") + .flag("x", '\0', "") + .keyword("b"); // keywords must come before flags/options + })); + + EXPECT_THROW("keyword after variadic parameter", ({ + cli.command() + .keyword("a") + .parameter("p", "", /*variadic=*/true) + .keyword("b"); // variadic must be last in the structured part + })); + + EXPECT_THROW("loose parameter after variadic loose parameter", ({ + cli.command() + .keyword("a") + .flag("f", '\0', "") + .parameter("p1", "", /*variadic=*/true) + .parameter("p2", ""); // variadic must be the last parameter + })); + + EXPECT_THROW("action() arity mismatch", ({ + cli.command().keyword("a").parameter("p", "").action(&noop2); + // noop2 takes 2 args, only 1 slot declared + })); + + EXPECT_THROW("no keyword at all", ({ + cli.command().parameter("p", "").action(&noop); + // a command must have at least one keyword + })); + + EXPECT_THROW("option bound to a required (bare) type", ({ + cli.command().keyword("a").option("o", '\0', "").action(&noop); + // options must be std::optional or std::vector -- never bare T + })); + + EXPECT_THROW("declared type doesn't match action() type", ({ + cli.command() + .keyword("a") + .parameter("p", "") + .action(&noopWrongType); // declared string, action expects int32_t + })); + + EXPECT_THROW("enum parameter without choices()", ({ + cli.command() + .keyword("a") + .parameter("c", "") + .action(&noopColor); // never called .choices(...) + })); + + EXPECT_THROW("duplicate long flag name", ({ + cli.command() + .keyword("a") + .flag("force", 'f', "") + .flag("force", 'g', ""); + })); + + EXPECT_THROW("duplicate short flag name", ({ + cli.command() + .keyword("a") + .flag("force", 'f', "") + .flag("full", 'f', ""); + })); + + EXPECT_THROW("reserved --help name", ({ cli.command().keyword("a").flag("help", '\0', ""); })); + + EXPECT_THROW("reserved -h short name", ({ cli.command().keyword("a").flag("x", 'h', ""); })); + + EXPECT_THROW("invalid keyword charset", ({ cli.command().keyword("bad keyword!"); })); + + EXPECT_THROW("keyword starting with dash", ({ cli.command().keyword("-bad"); })); + + EXPECT_OK("parameter() after option() (loose parameter)", ({ + cli.command() + .keyword("a") + .option("o", '\0', "") + .parameter("p", "") + .action(&noopOptStrStr); + })); + + EXPECT_OK("flag/option/loose-parameter fully interleaved", ({ + cli.command() + .keyword("b") + .flag("f", '\0', "") + .option("o", '\0', "") + .parameter("p", "", /*variadic=*/true) + .action(&noopBoolOptVec); + })); + + EXPECT_OK("action() taking arguments by const reference", ({ + cli.command().keyword("c").parameter("p", "").action(&noop); + })); + + std::printf("\nAll registration-guard checks completed.\n"); + return 0; +} diff --git a/tests/type_test.cpp b/tests/type_test.cpp new file mode 100644 index 0000000..64ba789 --- /dev/null +++ b/tests/type_test.cpp @@ -0,0 +1,40 @@ +#include +#include +#include +#include +#include + +#include + +using cliforge::Engine; + +void allTypes(int8_t a, uint8_t b, int16_t c, uint16_t d, int32_t e, uint32_t f, int64_t g, + uint64_t h, float fl, double db, const std::string& s, char ch, bool bl) { + std::printf( + "i8=%d u8=%u i16=%d u16=%u i32=%d u32=%u i64=%lld u64=%llu f=%g d=%g s=%s c=%c b=%d\n", + a, b, c, d, e, f, static_cast(g), static_cast(h), + static_cast(fl), db, s.c_str(), ch, bl); +} + +int main(int argc, char** argv) { + Engine cli("typetest"); + cli.command() + .keyword("all") + .parameter("a", "int8") + .parameter("b", "uint8") + .parameter("c", "int16") + .parameter("d", "uint16") + .parameter("e", "int32") + .parameter("f", "uint32") + .parameter("g", "int64") + .parameter("h", "uint64") + .parameter("fl", "float32") + .parameter("db", "float64") + .parameter("s", "string") + .parameter("ch", "char") + .flag("bl", 'b', "bool flag") + .describe("Exercises every scalar type") + .action(&allTypes); + + return cli.run(argc, argv); +}