Add project files.
This commit is contained in:
1 parent
9c60d1b47c
commit
40d55e00f2
11 files changed
+2099
No files matched your search
@@ -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)
|
||||
@@ -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 <cstdio>
|
||||
#include <iostream>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <cliforge/cliforge.hpp>
|
||||
|
||||
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<T> (or std::vector<T> 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<ProjectTemplate> 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<int>(t)], git ? "yes" : "no");
|
||||
}
|
||||
|
||||
void projectDelete(const std::string& name, bool force, const std::optional<std::string>& reason) {
|
||||
std::printf("[delete] project '%s' (force=%s, reason=%s)\n", name.c_str(),
|
||||
force ? "yes" : "no", reason ? reason->c_str() : "<none given>");
|
||||
}
|
||||
|
||||
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<int32_t>& targets, bool noAssets, bool debug, bool verbose,
|
||||
const std::vector<std::string>& architectures, std::optional<Editor> editor,
|
||||
std::optional<int32_t> 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<int>(*editor)] : "<none>",
|
||||
threshold ? std::to_string(*threshold).c_str() : "<default>");
|
||||
}
|
||||
|
||||
void enable2fa(std::optional<TwoFactorMethod> 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<int>(m)]);
|
||||
}
|
||||
|
||||
void disable2fa() { std::printf("[2fa] disabled\n"); }
|
||||
|
||||
void selfUpdate(std::optional<UpdateChannel> channel, bool checkOnly) {
|
||||
static const char* names[] = {"stable", "beta", "nightly"};
|
||||
std::printf("[self-update] channel=%s checkOnly=%d\n",
|
||||
channel ? names[static_cast<int>(*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<std::string>& env, const std::vector<std::string>& hosts) {
|
||||
std::printf("[deploy] dryRun=%d env=%s hosts=[", dryRun, env ? env->c_str() : "<none>");
|
||||
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<std::string>("name", "Project name")
|
||||
.keyword("create")
|
||||
.option<ProjectTemplate>("template", 't', "Project template to scaffold (default: cpp)")
|
||||
.choices<ProjectTemplate>({{"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<std::string>("name", "Project name")
|
||||
.keyword("delete")
|
||||
.flag("force", 'f', "Skip the confirmation prompt")
|
||||
.option<std::string>("reason", 'r', "Why the project is being deleted")
|
||||
.describe("Permanently delete a project by name")
|
||||
.action(&projectDelete);
|
||||
|
||||
cli.command()
|
||||
.keyword("project")
|
||||
.parameter<std::string>("name", "Project name")
|
||||
.flag("json", 'j', "Print machine-readable JSON")
|
||||
.describe("Show information about a project")
|
||||
.action(&projectInfo);
|
||||
|
||||
cli.command()
|
||||
.keyword("build")
|
||||
.parameter<int32_t>("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<std::string>("architectures", 'a', "Target architectures", /*variadic=*/true)
|
||||
.option<Editor>("editor", 'e', "Editor to open on completion")
|
||||
.choices<Editor>({{"vim", Editor::Vim}, {"emacs", Editor::Emacs}, {"nano", Editor::Nano}})
|
||||
.option<int32_t>("threshold", 't', "Warning threshold (0 = strict)")
|
||||
.describe("Build the given target ids")
|
||||
.action(&build);
|
||||
|
||||
cli.command()
|
||||
.keyword("2fa")
|
||||
.keyword("enable")
|
||||
.option<TwoFactorMethod>("method", 'm', "Verification method (default: app)")
|
||||
.choices<TwoFactorMethod>({{"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<UpdateChannel>("channel", 'c', "Release channel to update from")
|
||||
.choices<UpdateChannel>({{"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<std::string>("env", 'e', "Environment name")
|
||||
.parameter<std::string>("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);
|
||||
}
|
||||
@@ -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 <cliforge/cliforge.hpp>
|
||||
//
|
||||
// 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"
|
||||
@@ -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<std::string>("name", "Project name")
|
||||
// .keyword("delete")
|
||||
// .flag("force", 'f', "Skip the confirmation prompt")
|
||||
// .option<std::string>("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<T>, or
|
||||
// std::vector<T> 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 <algorithm>
|
||||
#include <cctype>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <tuple>
|
||||
#include <type_traits>
|
||||
#include <typeindex>
|
||||
#include <vector>
|
||||
|
||||
#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 <typename T>
|
||||
struct is_optional : std::false_type {};
|
||||
template <typename T>
|
||||
struct is_optional<std::optional<T>> : std::true_type {
|
||||
using inner = T;
|
||||
};
|
||||
template <typename T>
|
||||
inline constexpr bool is_optional_v = is_optional<T>::value;
|
||||
|
||||
template <typename T>
|
||||
struct is_vector : std::false_type {};
|
||||
template <typename T>
|
||||
struct is_vector<std::vector<T>> : std::true_type {
|
||||
using inner = T;
|
||||
};
|
||||
template <typename T>
|
||||
inline constexpr bool is_vector_v = is_vector<T>::value;
|
||||
|
||||
template <typename T>
|
||||
inline constexpr bool is_builtin_scalar_v =
|
||||
std::is_same_v<T, int8_t> || std::is_same_v<T, uint8_t> ||
|
||||
std::is_same_v<T, int16_t> || std::is_same_v<T, uint16_t> ||
|
||||
std::is_same_v<T, int32_t> || std::is_same_v<T, uint32_t> ||
|
||||
std::is_same_v<T, int64_t> || std::is_same_v<T, uint64_t> ||
|
||||
std::is_same_v<T, float> || std::is_same_v<T, double> ||
|
||||
std::is_same_v<T, std::string> || std::is_same_v<T, char> ||
|
||||
std::is_same_v<T, bool>;
|
||||
|
||||
// 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<unsigned char>(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<unsigned char>(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<T>()'s own
|
||||
// static_assert), so this never needs to handle the general case.
|
||||
template <typename T>
|
||||
std::string friendlyTypeName() {
|
||||
if constexpr (is_builtin_scalar_v<T>) {
|
||||
return TypeOf<T>::get().displayName;
|
||||
} else if constexpr (std::is_enum_v<T>) {
|
||||
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<Value> 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 <typename T>
|
||||
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<T>) {
|
||||
s.awaitingChoices = true; // choices<T>(...) must follow immediately
|
||||
} else {
|
||||
static_assert(detail::is_builtin_scalar_v<T>,
|
||||
"cliforge: unsupported parameter type -- use a fixed-width int, "
|
||||
"float/double, std::string, char, bool, or an enum (with "
|
||||
"choices<T>() called immediately after)");
|
||||
s.type = TypeOf<T>::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<T>(). Options are
|
||||
// always optional in the calling convention (see action()) -- there
|
||||
// is no way to declare a "required" option, by design.
|
||||
template <typename T>
|
||||
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<T>) {
|
||||
s.awaitingChoices = true;
|
||||
} else {
|
||||
static_assert(detail::is_builtin_scalar_v<T>,
|
||||
"cliforge: unsupported option type -- use a fixed-width int, "
|
||||
"float/double, std::string, char, bool, or an enum (with "
|
||||
"choices<T>() called immediately after)");
|
||||
s.type = TypeOf<T>::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<T>()/option<T>().
|
||||
template <typename T>
|
||||
Command& choices(std::initializer_list<std::pair<std::string, T>> values,
|
||||
std::string typeName = "") {
|
||||
if (lastSlot_ == nullptr ||
|
||||
(lastSlot_->kind != SlotKind::Parameter && lastSlot_->kind != SlotKind::Option)) {
|
||||
throw RegistrationError(
|
||||
"choices<>() must directly follow the parameter<T>() or "
|
||||
"option<T>() 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<std::pair<std::string, T>> table(values);
|
||||
lastSlot_->type =
|
||||
makeChoiceTypeInfo<T>(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<T>()/option<T>()/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 <typename Func>
|
||||
Command& action(Func f) {
|
||||
using Traits = detail::function_traits<std::decay_t<Func>>;
|
||||
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<Slot*> 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<Traits::arity>{});
|
||||
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<std::string>& 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<Scalar> 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<Value> parseUnstructured(const std::vector<std::string>& tokens) const {
|
||||
std::vector<bool> provided(unstructured_.size(), false);
|
||||
std::vector<std::vector<Scalar>> collected(unstructured_.size());
|
||||
std::vector<std::string_view> 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<size_t>(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<size_t>(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<std::size_t> looseParamIdx;
|
||||
for (std::size_t idx = 0; idx < unstructured_.size(); ++idx) {
|
||||
if (unstructured_[idx].kind == SlotKind::Parameter) looseParamIdx.push_back(idx);
|
||||
}
|
||||
std::vector<Value> 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<Scalar> 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<Value> 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<Value> allValues) const { invoke_(allValues); }
|
||||
|
||||
std::size_t structuredSlotCount() const { return structured_.size(); }
|
||||
|
||||
// Lenient prefix check used only for --help / `help <words>` : 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<std::string>& 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<const Slot*> 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<std::string> flagOptionNames() const {
|
||||
std::vector<std::string> 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<std::string>& 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<Slot> structured_;
|
||||
std::vector<Slot> unstructured_;
|
||||
std::string description_;
|
||||
std::function<void(std::vector<Value>&)> 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<unsigned char>(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<unsigned char>(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<T>(...) "
|
||||
"called right after it is declared");
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Slot*> orderedBindableSlots() {
|
||||
std::vector<Slot*> 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 <typename T>
|
||||
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<T>());
|
||||
}
|
||||
}
|
||||
|
||||
// ArgT is the *raw* action() parameter type, e.g. `const std::string&`
|
||||
// or `std::vector<int32_t>` -- validated after stripping cv/ref.
|
||||
template <typename ArgT>
|
||||
static void configureSlot(Slot& slot) {
|
||||
using Stripped = std::remove_cvref_t<ArgT>;
|
||||
if (slot.kind == SlotKind::Flag) {
|
||||
if constexpr (!std::is_same_v<Stripped, bool>) {
|
||||
throw RegistrationError("flag '--" + slot.name + "' must bind to bool in action()");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if constexpr (detail::is_optional_v<Stripped>) {
|
||||
using Inner = typename detail::is_optional<Stripped>::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<Inner>(slot);
|
||||
} else if constexpr (detail::is_vector_v<Stripped>) {
|
||||
using Inner = typename detail::is_vector<Stripped>::inner;
|
||||
if (!slot.variadic) {
|
||||
throw RegistrationError("'" + slot.name +
|
||||
"' binds to std::vector<> but wasn't declared variadic=true");
|
||||
}
|
||||
checkTypeMatches<Inner>(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<T> (or std::vector<T> if "
|
||||
"variadic) -- options are always optional, never required");
|
||||
}
|
||||
checkTypeMatches<Stripped>(slot);
|
||||
}
|
||||
}
|
||||
|
||||
template <typename ArgT>
|
||||
static ArgT extractArg(const Value& v) {
|
||||
if constexpr (std::is_same_v<ArgT, bool>) {
|
||||
return v.as<bool>();
|
||||
} else if constexpr (detail::is_optional_v<ArgT>) {
|
||||
using Inner = typename detail::is_optional<ArgT>::inner;
|
||||
return v.hasValue() ? std::optional<Inner>(v.as<Inner>()) : std::nullopt;
|
||||
} else if constexpr (detail::is_vector_v<ArgT>) {
|
||||
using Inner = typename detail::is_vector<ArgT>::inner;
|
||||
return v.asVector<Inner>();
|
||||
} else {
|
||||
return v.as<ArgT>();
|
||||
}
|
||||
}
|
||||
|
||||
// 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 <typename Ret, typename... Args, std::size_t... I>
|
||||
void bindArgs(std::function<Ret(Args...)> f, std::vector<Slot*>& ordered,
|
||||
std::index_sequence<I...>) {
|
||||
(configureSlot<Args>(*ordered[I]), ...);
|
||||
invoke_ = [f = std::move(f)](std::vector<Value>& values) {
|
||||
std::tuple<std::remove_cvref_t<Args>...> extracted{
|
||||
extractArg<std::remove_cvref_t<Args>>(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<int>(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<int>(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<std::string>& tokens,
|
||||
std::size_t& i, std::vector<bool>& provided,
|
||||
std::vector<std::vector<Scalar>>& collected, std::string_view tokForError) {
|
||||
std::size_t idx = static_cast<std::size_t>(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
|
||||
@@ -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 <algorithm>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<Command>());
|
||||
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<std::string> 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<std::vector<std::string>> helpQuery;
|
||||
if (tokens[0] == "help") {
|
||||
helpQuery = std::vector<std::string>(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<std::string> 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<Candidate> 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<std::string> remaining(tokens.begin() + static_cast<long>(c.match.consumed),
|
||||
tokens.end());
|
||||
try {
|
||||
std::vector<Value> values = c.cmd->parseUnstructured(remaining);
|
||||
std::vector<Value> 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<std::unique_ptr<Command>> 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<std::string>& 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<std::string>& queryTokens) const {
|
||||
if (queryTokens.empty()) {
|
||||
printGlobalHelp(std::cout);
|
||||
return 0;
|
||||
}
|
||||
std::vector<Command*> 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_ << " <command> [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 <command>' or add --help/-h to any command "
|
||||
<< "for details.\n";
|
||||
}
|
||||
|
||||
void suggestCommands(const std::vector<std::string>& tokens, std::ostream& os) const {
|
||||
std::vector<std::pair<double, Command*>> scored;
|
||||
for (auto& cmd : commands_) {
|
||||
Command::FuzzyScore s = cmd->fuzzyStructuralDistance(tokens);
|
||||
double ratio = s.keywordChars ? static_cast<double>(s.distance) / static_cast<double>(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<Command*> 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
|
||||
@@ -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 <cstddef>
|
||||
#include <functional>
|
||||
#include <tuple>
|
||||
|
||||
namespace cliforge::detail {
|
||||
|
||||
template <typename T>
|
||||
struct function_traits;
|
||||
|
||||
// Plain free function: void foo(std::string, int)
|
||||
template <typename Ret, typename... Args>
|
||||
struct function_traits<Ret (*)(Args...)> {
|
||||
using return_type = Ret;
|
||||
static constexpr std::size_t arity = sizeof...(Args);
|
||||
template <std::size_t I>
|
||||
using arg = std::tuple_element_t<I, std::tuple<Args...>>;
|
||||
using args_tuple = std::tuple<Args...>;
|
||||
};
|
||||
|
||||
// std::function<void(std::string, int)>
|
||||
template <typename Ret, typename... Args>
|
||||
struct function_traits<std::function<Ret(Args...)>> {
|
||||
using return_type = Ret;
|
||||
static constexpr std::size_t arity = sizeof...(Args);
|
||||
template <std::size_t I>
|
||||
using arg = std::tuple_element_t<I, std::tuple<Args...>>;
|
||||
using args_tuple = std::tuple<Args...>;
|
||||
};
|
||||
|
||||
// Pointer-to-member-function of operator(), const and non-const, used to
|
||||
// peel apart lambdas (which are just anonymous functors under the hood).
|
||||
template <typename C, typename Ret, typename... Args>
|
||||
struct function_traits<Ret (C::*)(Args...) const> {
|
||||
using return_type = Ret;
|
||||
static constexpr std::size_t arity = sizeof...(Args);
|
||||
template <std::size_t I>
|
||||
using arg = std::tuple_element_t<I, std::tuple<Args...>>;
|
||||
using args_tuple = std::tuple<Args...>;
|
||||
};
|
||||
|
||||
template <typename C, typename Ret, typename... Args>
|
||||
struct function_traits<Ret (C::*)(Args...)> {
|
||||
using return_type = Ret;
|
||||
static constexpr std::size_t arity = sizeof...(Args);
|
||||
template <std::size_t I>
|
||||
using arg = std::tuple_element_t<I, std::tuple<Args...>>;
|
||||
using args_tuple = std::tuple<Args...>;
|
||||
};
|
||||
|
||||
// Fallback: any other callable (lambda, functor) -- peel off operator().
|
||||
template <typename F>
|
||||
struct function_traits : function_traits<decltype(&F::operator())> {};
|
||||
|
||||
} // namespace cliforge::detail
|
||||
@@ -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 <algorithm>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<std::size_t> 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<std::string> closestMatches(const std::string& query,
|
||||
const std::vector<std::string>& pool,
|
||||
std::size_t limit = 3) {
|
||||
std::vector<std::pair<std::size_t, std::string>> 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<std::size_t>(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<std::size_t>(3, query.size() / 2);
|
||||
std::vector<std::string> out;
|
||||
for (auto& [dist, name] : scored) {
|
||||
if (out.size() >= limit) break;
|
||||
if (dist <= threshold) out.push_back(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace cliforge::detail
|
||||
@@ -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 <optional>
|
||||
#include <string>
|
||||
#include <typeindex>
|
||||
|
||||
#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 <name>).
|
||||
// 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<T>()/option<T>(), or by choices<T>(),
|
||||
// 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<T>()/option<T>(), kept
|
||||
// so action() can *validate* (not invent) the bound function's
|
||||
// corresponding argument type. Unset for Keyword/Flag slots.
|
||||
std::optional<std::type_index> cppType;
|
||||
|
||||
// True from parameter<T>()/option<T>() (when T is an enum) until the
|
||||
// matching choices<T>() 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
|
||||
@@ -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 <algorithm>
|
||||
#include <charconv>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <type_traits>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
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<T>() 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<T>(), the underlying value is boxed here. Value::as<T>() 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<int8_t, uint8_t, int16_t, uint16_t, int32_t,
|
||||
uint32_t, int64_t, uint64_t, float, double,
|
||||
std::string, char, bool, EnumTag>;
|
||||
|
||||
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<std::pair<std::string, Scalar>> 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<T>()-restricted slots.
|
||||
std::shared_ptr<const ChoiceTable> 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 <typename Int>
|
||||
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 <typename Float>
|
||||
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<T>() instead
|
||||
// since they need a name<->value table supplied by the caller.
|
||||
template <typename T>
|
||||
struct TypeOf; // intentionally undefined for unsupported T
|
||||
|
||||
#define CLIFORGE_INT_TYPE(CPP_T, KIND, NAME) \
|
||||
template <> \
|
||||
struct TypeOf<CPP_T> { \
|
||||
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<CPP_T>(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<float> {
|
||||
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<float>(tok, lbl, "float32")};
|
||||
};
|
||||
return t;
|
||||
}
|
||||
};
|
||||
|
||||
template <>
|
||||
struct TypeOf<double> {
|
||||
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<double>(tok, lbl, "float64")};
|
||||
};
|
||||
return t;
|
||||
}
|
||||
};
|
||||
|
||||
template <>
|
||||
struct TypeOf<std::string> {
|
||||
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<char> {
|
||||
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<bool> {
|
||||
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<T>()` 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 <typename T>
|
||||
TypeInfo makeChoiceTypeInfo(std::string typeName,
|
||||
const std::vector<std::pair<std::string, T>>& choices) {
|
||||
auto table = std::make_shared<ChoiceTable>();
|
||||
table->entries.reserve(choices.size());
|
||||
for (const auto& [name, val] : choices) {
|
||||
if constexpr (std::is_enum_v<T>) {
|
||||
table->entries.emplace_back(name, Scalar{EnumTag{static_cast<long long>(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<Scalar> v) {
|
||||
Value out;
|
||||
out.data_ = std::move(v);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool hasValue() const { return !std::holds_alternative<std::monostate>(data_); }
|
||||
bool isVector() const { return std::holds_alternative<std::vector<Scalar>>(data_); }
|
||||
|
||||
template <typename T>
|
||||
T as() const {
|
||||
const Scalar& s = std::get<Scalar>(data_);
|
||||
if constexpr (std::is_enum_v<T>) {
|
||||
return static_cast<T>(std::get<EnumTag>(s).value);
|
||||
} else {
|
||||
return std::get<T>(s);
|
||||
}
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
std::vector<T> asVector() const {
|
||||
const auto& vec = std::get<std::vector<Scalar>>(data_);
|
||||
std::vector<T> out;
|
||||
out.reserve(vec.size());
|
||||
for (const Scalar& s : vec) {
|
||||
if constexpr (std::is_enum_v<T>) {
|
||||
out.push_back(static_cast<T>(std::get<EnumTag>(s).value));
|
||||
} else {
|
||||
out.push_back(std::get<T>(s));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private:
|
||||
std::variant<std::monostate, Scalar, std::vector<Scalar>> data_;
|
||||
};
|
||||
|
||||
} // namespace cliforge
|
||||
@@ -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 <cstdio>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <cliforge/cliforge.hpp>
|
||||
|
||||
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<std::string>&, const std::string&) {}
|
||||
void noopBoolOptVec(bool, const std::optional<std::string>&, const std::vector<std::string>&) {}
|
||||
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<std::string>("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<std::string>("p1", "", /*variadic=*/true)
|
||||
.parameter<std::string>("p2", ""); // variadic must be the last parameter
|
||||
}));
|
||||
|
||||
EXPECT_THROW("action() arity mismatch", ({
|
||||
cli.command().keyword("a").parameter<std::string>("p", "").action(&noop2);
|
||||
// noop2 takes 2 args, only 1 slot declared
|
||||
}));
|
||||
|
||||
EXPECT_THROW("no keyword at all", ({
|
||||
cli.command().parameter<std::string>("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<std::string>("o", '\0', "").action(&noop);
|
||||
// options must be std::optional<T> or std::vector<T> -- never bare T
|
||||
}));
|
||||
|
||||
EXPECT_THROW("declared type doesn't match action() type", ({
|
||||
cli.command()
|
||||
.keyword("a")
|
||||
.parameter<std::string>("p", "")
|
||||
.action(&noopWrongType); // declared string, action expects int32_t
|
||||
}));
|
||||
|
||||
EXPECT_THROW("enum parameter without choices<T>()", ({
|
||||
cli.command()
|
||||
.keyword("a")
|
||||
.parameter<Color>("c", "")
|
||||
.action(&noopColor); // never called .choices<Color>(...)
|
||||
}));
|
||||
|
||||
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<std::string>("o", '\0', "")
|
||||
.parameter<std::string>("p", "")
|
||||
.action(&noopOptStrStr);
|
||||
}));
|
||||
|
||||
EXPECT_OK("flag/option/loose-parameter fully interleaved", ({
|
||||
cli.command()
|
||||
.keyword("b")
|
||||
.flag("f", '\0', "")
|
||||
.option<std::string>("o", '\0', "")
|
||||
.parameter<std::string>("p", "", /*variadic=*/true)
|
||||
.action(&noopBoolOptVec);
|
||||
}));
|
||||
|
||||
EXPECT_OK("action() taking arguments by const reference", ({
|
||||
cli.command().keyword("c").parameter<std::string>("p", "").action(&noop);
|
||||
}));
|
||||
|
||||
std::printf("\nAll registration-guard checks completed.\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <cliforge/cliforge.hpp>
|
||||
|
||||
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<long long>(g), static_cast<unsigned long long>(h),
|
||||
static_cast<double>(fl), db, s.c_str(), ch, bl);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
Engine cli("typetest");
|
||||
cli.command()
|
||||
.keyword("all")
|
||||
.parameter<int8_t>("a", "int8")
|
||||
.parameter<uint8_t>("b", "uint8")
|
||||
.parameter<int16_t>("c", "int16")
|
||||
.parameter<uint16_t>("d", "uint16")
|
||||
.parameter<int32_t>("e", "int32")
|
||||
.parameter<uint32_t>("f", "uint32")
|
||||
.parameter<int64_t>("g", "int64")
|
||||
.parameter<uint64_t>("h", "uint64")
|
||||
.parameter<float>("fl", "float32")
|
||||
.parameter<double>("db", "float64")
|
||||
.parameter<std::string>("s", "string")
|
||||
.parameter<char>("ch", "char")
|
||||
.flag("bl", 'b', "bool flag")
|
||||
.describe("Exercises every scalar type")
|
||||
.action(&allTypes);
|
||||
|
||||
return cli.run(argc, argv);
|
||||
}
|
||||
Reference in new issue
Block a user