Add project files

This commit is contained in:
alejo committed 2026-10-24 09:53:48 -03:00
commit f2fe6b8855
114 files changed
+20525

No files matched your search

+1
View File
@@ -0,0 +1 @@
add_lib_module(cliforge OUTPUT_NAME "cliforge")
@@ -0,0 +1,8 @@
#pragma once
#include "Command.hpp"
#include "Engine.hpp"
#include "FunctionTraits.hpp"
#include "Levenshtein.hpp"
#include "Slot.hpp"
#include "Value.hpp"
@@ -0,0 +1,499 @@
#pragma once
// 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 <cctype>
#include <algorithm>
#include <functional>
#include <optional>
#include <sstream>
#include <string>
#include <string_view>
#include <tuple>
#include <type_traits>
#include <typeindex>
#include <vector>
#include "FunctionTraits.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 IsOptional : std::false_type
{
};
template <typename T> struct IsOptional<std::optional<T>> : std::true_type
{
using Inner = T;
};
template <typename T> inline constexpr bool IsOptionalV = IsOptional<T>::value;
template <typename T> struct IsVector : std::false_type
{
};
template <typename T> struct IsVector<std::vector<T>> : std::true_type
{
using Inner = T;
};
template <typename T> inline constexpr bool IsVectorV = IsVector<T>::value;
template <typename T>
inline constexpr bool IsBuiltinScalarV =
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 '-'.
bool looksLikeOptionStart(std::string_view tok);
bool isValidKeyword(std::string_view s);
// 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 (IsBuiltinScalarV<T>)
{
return TypeOf<T>::get().displayName;
}
else if constexpr (std::is_enum_v<T>)
{
return "a different enum type";
}
else
{
return "an unsupported type";
}
}
}
// 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(const std::string& literal);
// 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::IsBuiltinScalarV<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 (!m_unstructuredStarted)
{
if (m_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");
}
m_structured.push_back(std::move(s));
m_lastSlot = &m_structured.back();
if (variadic)
m_structuredSealed = true;
}
else
{
if (m_looseSealed)
{
throw RegistrationError("can't declare parameter('" + s.name +
"') after a variadic parameter -- a variadic parameter "
"must be the last parameter declared");
}
m_unstructured.push_back(std::move(s));
m_lastSlot = &m_unstructured.back();
if (variadic)
m_looseSealed = true;
}
return *this;
}
// shortName is 0 (the default) for "no short form".
Command& flag(std::string longName, char shortName, const std::string& description);
// 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::IsBuiltinScalarV<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();
}
m_unstructured.push_back(std::move(s));
m_lastSlot = &m_unstructured.back();
m_unstructuredStarted = true;
return *this;
}
Command& describe(const std::string& description);
// 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 (m_lastSlot == nullptr ||
(m_lastSlot->kind != SlotKind::Parameter && m_lastSlot->kind != SlotKind::Option))
{
throw RegistrationError("choices<>() must directly follow the parameter<T>() or "
"option<T>() it restricts");
}
if (!m_lastSlot->cppType || *m_lastSlot->cppType != std::type_index(typeid(T)))
{
throw RegistrationError("choices<>() type doesn't match the type declared for '" +
m_lastSlot->name + "'");
}
std::vector<std::pair<std::string, T>> table(values);
m_lastSlot->type = makeChoiceTypeInfo<T>(
typeName.empty() ? std::string("enum") : std::move(typeName), table);
m_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::FunctionTraits<std::decay_t<Func>>;
if (m_structured.empty() ||
std::none_of(m_structured.begin(), m_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 : m_structured)
checkNotAwaitingChoices(s);
for (const Slot& s : m_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>{});
m_sealed = true;
return *this;
}
[[nodiscard]] bool sealed() const;
[[nodiscard]] const std::string& description() const;
// --- matching / parsing entry points, used by Engine -------------
[[nodiscard]] StructuredMatch tryMatchStructured(
const std::vector<std::string>& tokens) const;
// 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.
[[nodiscard]] std::vector<Value> parseUnstructured(
const std::vector<std::string>& tokens) const;
void invoke(std::vector<Value> allValues) const;
[[nodiscard]] std::size_t structuredSlotCount() const;
// 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.
[[nodiscard]] bool couldMatchPrefix(const std::vector<std::string>& tokens) const;
// --- help / usage --------------------------------------------------
[[nodiscard]] std::string usageLine() const;
[[nodiscard]] std::string helpText(const std::string& progName) const;
[[nodiscard]] std::vector<std::string> flagOptionNames() const;
// 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
};
[[nodiscard]] FuzzyScore fuzzyStructuralDistance(
const std::vector<std::string>& tokens) const;
private:
std::vector<Slot> m_structured;
std::vector<Slot> m_unstructured;
std::string m_description;
std::function<void(std::vector<Value>&)> m_invoke;
Slot* m_lastSlot = nullptr;
bool m_unstructuredStarted = false;
bool m_structuredSealed = false;
bool m_looseSealed = false;
bool m_sealed = false;
static std::string stripDashes(std::string s);
void validateLongName(const std::string& s) const;
void validateShortName(char c) const;
void checkNameFree(const std::string& longName, char shortName) const;
static void checkNotAwaitingChoices(const Slot& s);
std::vector<Slot*> orderedBindableSlots();
// 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::IsOptionalV<Stripped>)
{
using Inner = typename detail::IsOptional<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::IsVectorV<Stripped>)
{
using Inner = typename detail::IsVector<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::IsOptionalV<ArgT>)
{
using Inner = typename detail::IsOptional<ArgT>::Inner;
return v.hasValue() ? std::optional<Inner>(v.as<Inner>()) : std::nullopt;
}
else if constexpr (detail::IsVectorV<ArgT>)
{
using Inner = typename detail::IsVector<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]), ...);
m_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);
};
}
[[nodiscard]] int findByLong(std::string_view name) const;
[[nodiscard]] int findByShort(char c) const;
[[nodiscard]] std::string suggestFlagOption(const std::string& given) const;
[[noreturn]] void throwUnknown(std::string_view tok) const;
static std::string describeType(const Slot& slot);
static std::string padRight(const std::string& s, std::size_t width);
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);
};
}
@@ -0,0 +1,56 @@
#pragma once
// 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 = "");
Engine& describe(const std::string& description);
// 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();
int run(int argc, char** argv);
private:
std::string m_programName;
std::string m_description;
std::vector<std::unique_ptr<Command>> m_commands;
static std::string basename(const std::string& path);
static std::string join(const std::vector<std::string>& tokens);
// 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.
[[nodiscard]] int showHelp(const std::vector<std::string>& queryTokens) const;
void printGlobalHelp(std::ostream& os) const;
void suggestCommands(const std::vector<std::string>& tokens, std::ostream& os) const;
static std::string firstLine(const std::string& s);
static std::string padRight(const std::string& s, std::size_t width);
};
}
@@ -0,0 +1,61 @@
#pragma once
// 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 FunctionTraits;
// Plain free function: void foo(std::string, int)
template <typename Ret, typename... Args> struct FunctionTraits<Ret (*)(Args...)>
{
using ReturnType = 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 ArgsTuple = std::tuple<Args...>;
};
// std::function<void(std::string, int)>
template <typename Ret, typename... Args> struct FunctionTraits<std::function<Ret(Args...)>>
{
using ReturnType = 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 ArgsTuple = 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 FunctionTraits<Ret (C::*)(Args...) const>
{
using ReturnType = 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 ArgsTuple = std::tuple<Args...>;
};
template <typename C, typename Ret, typename... Args> struct FunctionTraits<Ret (C::*)(Args...)>
{
using ReturnType = 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 ArgsTuple = std::tuple<Args...>;
};
// Fallback: any other callable (lambda, functor) -- peel off operator().
template <typename F> struct FunctionTraits : FunctionTraits<decltype(&F::operator())>
{
};
}
@@ -0,0 +1,21 @@
#pragma once
// 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
{
std::size_t levenshtein(const std::string& a, const std::string& b);
// 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.
std::vector<std::string> closestMatches(const std::string& query,
const std::vector<std::string>& pool,
std::size_t limit = 3);
}
+64
View File
@@ -0,0 +1,64 @@
#pragma once
// 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;
[[nodiscard]] std::string longFlag() const;
[[nodiscard]] std::string shortFlag() const;
};
}
+367
View File
@@ -0,0 +1,367 @@
#pragma once
// 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 <cstdint>
#include <algorithm>
#include <charconv>
#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);
[[nodiscard]] ErrorKind kind() const noexcept;
private:
ErrorKind m_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);
};
// 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);
};
// ---------------------------------------------------------------------
// 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;
[[nodiscard]] Scalar parse(std::string_view token, std::string_view label) const;
};
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;
[[nodiscard]] bool valid() const;
[[nodiscard]] Scalar parse(std::string_view token, std::string_view label) const;
};
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;
}
std::string toLower(std::string_view s);
}
// 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();
static Value ofScalar(Scalar s);
static Value ofVector(std::vector<Scalar> v);
[[nodiscard]] bool hasValue() const;
[[nodiscard]] bool isVector() const;
template <typename T> [[nodiscard]] T as() const
{
const Scalar& s = std::get<Scalar>(m_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>>(m_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>> m_data;
};
}
+705
View File
@@ -0,0 +1,705 @@
#include "CLIForge/Command.hpp"
namespace cliforge
{
namespace detail
{
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"
}
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;
}
}
Command& Command::keyword(const std::string& literal)
{
if (m_unstructuredStarted)
{
throw RegistrationError("can't declare keyword('" + literal +
"') after a flag()/option() was added -- keywords must "
"come entirely before the unstructured part");
}
if (m_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);
m_structured.push_back(std::move(s));
m_lastSlot = &m_structured.back();
return *this;
}
// shortName is 0 (the default) for "no short form".
Command& Command::flag(std::string longName, char shortName, const 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);
m_unstructured.push_back(std::move(s));
m_lastSlot = &m_unstructured.back();
m_unstructuredStarted = true;
return *this;
}
Command& Command::describe(const std::string& description)
{
m_description = std::move(description);
return *this;
}
bool Command::sealed() const
{
return m_sealed;
}
const std::string& Command::description() const
{
return m_description;
}
// --- matching / parsing entry points, used by Engine -------------
StructuredMatch Command::tryMatchStructured(const std::vector<std::string>& tokens) const
{
StructuredMatch m;
std::size_t i = 0;
try
{
for (std::size_t s = 0; s < m_structured.size(); ++s)
{
const Slot& slot = m_structured[s];
bool isLast = (s + 1 == m_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> Command::parseUnstructured(const std::vector<std::string>& tokens) const
{
std::vector<bool> provided(m_unstructured.size(), false);
std::vector<std::vector<Scalar>> collected(m_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 (m_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 < m_unstructured.size(); ++idx)
{
if (m_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 = m_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 m_unstructured's declaration order --
// this is what gets paired with the bound function's arguments.
std::vector<Value> out;
out.reserve(m_unstructured.size());
std::size_t looseCursor = 0;
for (std::size_t idx = 0; idx < m_unstructured.size(); ++idx)
{
const Slot& slot = m_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 Command::invoke(std::vector<Value> allValues) const
{
m_invoke(allValues);
}
std::size_t Command::structuredSlotCount() const
{
return m_structured.size();
}
bool Command::couldMatchPrefix(const std::vector<std::string>& tokens) const
{
std::size_t i = 0;
for (std::size_t s = 0; s < m_structured.size(); ++s)
{
if (i >= tokens.size())
break; // ran out of input -- a valid, incomplete prefix
const Slot& slot = m_structured[s];
bool isLast = (s + 1 == m_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 : m_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 Command::usageLine() const
{
std::ostringstream os;
for (std::size_t s = 0; s < m_structured.size(); ++s)
{
const Slot& slot = m_structured[s];
if (s)
os << ' ';
if (slot.kind == SlotKind::Keyword)
{
os << slot.name;
}
else
{
os << '<' << slot.name << (slot.variadic ? "..." : "") << '>';
}
}
bool anyFlagOrOption =
std::any_of(m_unstructured.begin(), m_unstructured.end(),
[](const Slot& s) { return s.kind != SlotKind::Parameter; });
if (anyFlagOrOption)
os << " [OPTIONS]";
for (const Slot& slot : m_unstructured)
{
if (slot.kind != SlotKind::Parameter)
continue;
os << " <" << slot.name << (slot.variadic ? "..." : "") << '>';
}
return os.str();
}
std::string Command::helpText(const std::string& progName) const
{
std::ostringstream os;
os << "Usage: " << progName << " " << usageLine() << "\n";
if (!m_description.empty())
os << "\n" << m_description << "\n";
bool anyParams = std::any_of(m_structured.begin(), m_structured.end(),
[](const Slot& s) { return s.kind == SlotKind::Parameter; }) ||
std::any_of(m_unstructured.begin(), m_unstructured.end(),
[](const Slot& s) { return s.kind == SlotKind::Parameter; });
if (anyParams)
{
os << "\nArguments:\n";
for (const Slot& slot : m_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 : m_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(m_unstructured.begin(), m_unstructured.end(),
[](const Slot& s) { return s.kind != SlotKind::Parameter; });
os << "\nOptions:\n";
if (anyFlagOrOption)
{
for (const Slot& slot : m_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> Command::flagOptionNames() const
{
std::vector<std::string> out;
for (const Slot& s : m_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;
}
Command::FuzzyScore Command::fuzzyStructuralDistance(
const std::vector<std::string>& tokens) const
{
std::size_t i = 0;
FuzzyScore score;
for (std::size_t s = 0; s < m_structured.size(); ++s)
{
const Slot& slot = m_structured[s];
bool isLast = (s + 1 == m_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;
}
std::string Command::stripDashes(std::string s)
{
while (!s.empty() && s.front() == '-')
s.erase(s.begin());
return s;
}
void Command::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 Command::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 Command::checkNameFree(const std::string& longName, char shortName) const
{
for (const Slot& s : m_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 + "'");
}
}
}
void Command::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*> Command::orderedBindableSlots()
{
std::vector<Slot*> out;
for (Slot& s : m_structured)
{
if (s.kind == SlotKind::Parameter)
out.push_back(&s);
}
for (Slot& s : m_unstructured)
out.push_back(&s);
return out;
}
int Command::findByLong(std::string_view name) const
{
for (std::size_t i = 0; i < m_unstructured.size(); ++i)
{
if (m_unstructured[i].kind != SlotKind::Parameter && m_unstructured[i].name == name)
{
return static_cast<int>(i);
}
}
return -1;
}
int Command::findByShort(char c) const
{
if (c == '\0')
return -1;
for (std::size_t i = 0; i < m_unstructured.size(); ++i)
{
if (m_unstructured[i].kind != SlotKind::Parameter && m_unstructured[i].shortName == c)
{
return static_cast<int>(i);
}
}
return -1;
}
std::string Command::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 Command::throwUnknown(std::string_view tok) const
{
throw CliError("unknown flag/option '" + std::string(tok) + "' for this command" +
suggestFlagOption(std::string(tok)),
ErrorKind::UnknownOption);
}
std::string Command::describeType(const Slot& slot)
{
return "<" + slot.type.displayName + "> ";
}
std::string Command::padRight(const std::string& s, std::size_t width)
{
if (s.size() >= width)
return s + " ";
return s + std::string(width - s.size(), ' ');
}
void Command::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.m_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;
}
};
}
+296
View File
@@ -0,0 +1,296 @@
#include "CLIForge/Engine.hpp"
namespace cliforge
{
Engine::Engine(std::string programName) : m_programName(std::move(programName))
{
}
Engine& Engine::describe(const std::string& description)
{
m_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& Engine::command()
{
m_commands.push_back(std::make_unique<Command>());
return *m_commands.back();
}
int Engine::run(int argc, char** argv)
{
if (m_programName.empty() && argc > 0)
m_programName = basename(argv[0]);
for (auto& cmd : m_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;
std::vector<Command*> bestPartialCmds; // every command tied for the best partial score
int bestPartialScore = -1;
for (auto& cmd : m_commands)
{
StructuredMatch m = cmd->tryMatchStructured(tokens);
if (m.success)
{
candidates.push_back({cmd.get(), std::move(m)});
}
else if (!m.error.empty())
{
if (m.keywordScore > bestPartialScore)
{
bestPartialScore = m.keywordScore;
bestPartialError = m.error;
bestPartialCmds.clear();
bestPartialCmds.push_back(cmd.get());
}
else if (m.keywordScore == bestPartialScore)
{
// Genuinely tied -- e.g. "project" alone is an equally
// incomplete prefix of create/delete/info, all of which
// report the identical "missing <name>" error. Rather
// than silently picking whichever was registered first
// (which read as an arbitrary, misleading suggestion),
// remember all of them and show every option.
bestPartialCmds.push_back(cmd.get());
}
}
}
if (candidates.empty())
{
if (!bestPartialCmds.empty())
{
// 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";
if (bestPartialCmds.size() == 1)
{
std::cerr << "Usage: " << m_programName << " "
<< bestPartialCmds.front()->usageLine() << "\n";
}
else
{
std::cerr << "'" << join(tokens) << "' matches " << bestPartialCmds.size()
<< " commands:\n";
for (auto* cmd : bestPartialCmds)
{
std::string line = m_programName + " " + cmd->usageLine();
std::cerr << " " << padRight(line, 46) << firstLine(cmd->description())
<< "\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: " << m_programName << " " << firstErrorCmd->usageLine() << "\n";
return 1;
}
std::string Engine::basename(const std::string& path)
{
auto pos = path.find_last_of("/\\");
return pos == std::string::npos ? path : path.substr(pos + 1);
}
std::string Engine::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 Engine::showHelp(const std::vector<std::string>& queryTokens) const
{
if (queryTokens.empty())
{
printGlobalHelp(std::cout);
return 0;
}
std::vector<Command*> matches;
for (auto& cmd : m_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(m_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(m_programName);
}
return 0;
}
void Engine::printGlobalHelp(std::ostream& os) const
{
os << "Usage: " << m_programName << " <command> [ARGUMENTS] [OPTIONS]\n";
if (!m_description.empty())
os << "\n" << m_description << "\n";
os << "\nCommands:\n";
for (auto& cmd : m_commands)
{
std::string line = m_programName + " " + cmd->usageLine();
os << " " << padRight(line, 46) << firstLine(cmd->description()) << "\n";
}
os << "\nRun '" << m_programName << " help <command>' or add --help/-h to any command "
<< "for details.\n";
}
void Engine::suggestCommands(const std::vector<std::string>& tokens, std::ostream& os) const
{
std::vector<std::pair<double, Command*>> scored;
for (auto& cmd : m_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 << " " << m_programName << " " << cmd->usageLine() << "\n";
}
std::string Engine::firstLine(const std::string& s)
{
auto pos = s.find('\n');
return pos == std::string::npos ? s : s.substr(0, pos);
}
std::string Engine::padRight(const std::string& s, std::size_t width)
{
if (s.size() >= width)
return s + " ";
return s + std::string(width - s.size(), ' ');
}
}
+58
View File
@@ -0,0 +1,58 @@
#include "CLIForge/Levenshtein.hpp"
namespace cliforge::detail
{
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.
std::vector<std::string> closestMatches(const std::string& query,
const std::vector<std::string>& pool, std::size_t limit)
{
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;
}
}
+13
View File
@@ -0,0 +1,13 @@
#include "CLIForge/Slot.hpp"
namespace cliforge
{
std::string Slot::longFlag() const
{
return "--" + name;
}
std::string Slot::shortFlag() const
{
return shortName ? std::string("-") + shortName : std::string();
}
}
+85
View File
@@ -0,0 +1,85 @@
#include "CLIForge/Value.hpp"
namespace cliforge
{
CliError::CliError(const std::string& msg, ErrorKind kind)
: std::runtime_error(msg), m_kind(kind)
{
}
ErrorKind CliError::kind() const noexcept
{
return m_kind;
}
ParseError::ParseError(const std::string& msg) : CliError(msg, ErrorKind::TypeMismatch)
{
}
RegistrationError::RegistrationError(const std::string& msg) : CliError(msg, ErrorKind::Generic)
{
}
Scalar ChoiceTable::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);
}
bool TypeInfo::valid() const
{
return parsePlain != nullptr || choiceTable != nullptr;
}
Scalar TypeInfo::parse(std::string_view token, std::string_view label) const
{
return choiceTable ? choiceTable->parse(token, label) : parsePlain(token, label);
}
namespace detail
{
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;
}
}
Value::Value() = default;
Value Value::ofScalar(Scalar s)
{
Value v;
v.m_data = std::move(s);
return v;
}
Value Value::ofVector(std::vector<Scalar> v)
{
Value out;
out.m_data = std::move(v);
return out;
}
bool Value::hasValue() const
{
return !std::holds_alternative<std::monostate>(m_data);
}
bool Value::isVector() const
{
return std::holds_alternative<std::vector<Scalar>>(m_data);
}
}