#pragma once // // engine.hpp // // Engine owns every registered Command and is the single entry point: // // Engine cli; // cli.describe("A demo CLI"); // cli.command()....action(&fn); // return cli.run(argc, argv); // #include #include #include #include #include #include #include "command.hpp" #include "levenshtein.hpp" namespace cliforge { class Engine { public: explicit Engine(std::string programName = "") : programName_(std::move(programName)) {} Engine& describe(std::string description) { description_ = std::move(description); return *this; } // Registers a new, empty command and returns a reference to it so it // can be built up with the fluent .keyword()/.parameter()/... API. // The Engine owns it for the rest of the program's lifetime. Command& command() { commands_.push_back(std::make_unique()); return *commands_.back(); } int run(int argc, char** argv) { if (programName_.empty() && argc > 0) programName_ = basename(argv[0]); for (auto& cmd : commands_) { if (!cmd->sealed()) { std::cerr << "cliforge BUG: a command was registered without calling .action() " "on it -- every command must be bound to a function.\n"; return 2; } } std::vector tokens; for (int i = 1; i < argc; ++i) tokens.emplace_back(argv[i]); if (tokens.empty()) { printGlobalHelp(std::cout); return 0; } // Centralized help handling: `help [words...]`, or --help/-h // appearing anywhere. Both funnel into the same prefix search, so // an ambiguous prefix (e.g. "project --help" matching create/ // delete/info) shows help for every command it's consistent with, // instead of silently picking one or matching nothing. std::optional> helpQuery; if (tokens[0] == "help") { helpQuery = std::vector(tokens.begin() + 1, tokens.end()); } else if (auto it = std::find_if(tokens.begin(), tokens.end(), [](const std::string& t) { return t == "--help" || t == "-h"; }); it != tokens.end()) { std::vector q = tokens; q.erase(q.begin() + (it - tokens.begin())); helpQuery = std::move(q); } if (helpQuery) { return showHelp(*helpQuery); } struct Candidate { Command* cmd; StructuredMatch match; }; std::vector candidates; std::string bestPartialError; Command* bestPartialCmd = nullptr; int bestPartialScore = -1; for (auto& cmd : commands_) { StructuredMatch m = cmd->tryMatchStructured(tokens); if (m.success) { candidates.push_back({cmd.get(), std::move(m)}); } else if (!m.error.empty() && m.keywordScore > bestPartialScore) { bestPartialScore = m.keywordScore; bestPartialError = m.error; bestPartialCmd = cmd.get(); } } if (candidates.empty()) { if (bestPartialCmd) { // Every keyword matched some command, just with a bad or // missing argument value -- this is much more actionable // than a generic "no command matches" + fuzzy suggestions. std::cerr << "Error: " << bestPartialError << "\n"; std::cerr << "Usage: " << programName_ << " " << bestPartialCmd->usageLine() << "\n"; return 1; } std::cerr << "Error: no command matches '" << join(tokens) << "'.\n"; suggestCommands(tokens, std::cerr); return 1; } std::sort(candidates.begin(), candidates.end(), [](const Candidate& a, const Candidate& b) { if (a.match.keywordScore != b.match.keywordScore) return a.match.keywordScore > b.match.keywordScore; return a.cmd->structuredSlotCount() > b.cmd->structuredSlotCount(); }); std::string firstError; Command* firstErrorCmd = nullptr; bool haveError = false; for (auto& c : candidates) { std::vector remaining(tokens.begin() + static_cast(c.match.consumed), tokens.end()); try { std::vector values = c.cmd->parseUnstructured(remaining); std::vector all = c.match.paramValues; for (auto& v : values) all.push_back(std::move(v)); c.cmd->invoke(std::move(all)); return 0; } catch (const CliError& e) { // Candidates are ranked best-first (most keyword matches // first), so the *first* failure is the one most likely to // be what the user actually meant -- keep that, not // whichever candidate happens to fail last. if (!haveError) { firstError = e.what(); firstErrorCmd = c.cmd; haveError = true; } } } std::cerr << "Error: " << firstError << "\n"; if (firstErrorCmd) std::cerr << "Usage: " << programName_ << " " << firstErrorCmd->usageLine() << "\n"; return 1; } private: std::string programName_; std::string description_; std::vector> commands_; static std::string basename(const std::string& path) { auto pos = path.find_last_of("/\\"); return pos == std::string::npos ? path : path.substr(pos + 1); } static std::string join(const std::vector& tokens) { std::string out; for (std::size_t i = 0; i < tokens.size(); ++i) { if (i) out += ' '; out += tokens[i]; } return out; } // Finds every command consistent with `queryTokens` as a prefix and // shows its help -- one command's help if there's exactly one match, // all of them (clearly separated) if the prefix is genuinely // ambiguous, or the global command list plus a hint if there's none. int showHelp(const std::vector& queryTokens) const { if (queryTokens.empty()) { printGlobalHelp(std::cout); return 0; } std::vector matches; for (auto& cmd : commands_) { if (cmd->couldMatchPrefix(queryTokens)) matches.push_back(cmd.get()); } if (matches.empty()) { std::cout << "No command matches '" << join(queryTokens) << "'.\n\n"; printGlobalHelp(std::cout); return 1; } if (matches.size() == 1) { std::cout << matches.front()->helpText(programName_); return 0; } std::cout << "'" << join(queryTokens) << "' matches " << matches.size() << " commands:\n\n"; for (std::size_t i = 0; i < matches.size(); ++i) { if (i) std::cout << "\n"; std::cout << matches[i]->helpText(programName_); } return 0; } void printGlobalHelp(std::ostream& os) const { os << "Usage: " << programName_ << " [ARGUMENTS] [OPTIONS]\n"; if (!description_.empty()) os << "\n" << description_ << "\n"; os << "\nCommands:\n"; for (auto& cmd : commands_) { std::string line = programName_ + " " + cmd->usageLine(); os << " " << padRight(line, 46) << firstLine(cmd->description()) << "\n"; } os << "\nRun '" << programName_ << " help ' or add --help/-h to any command " << "for details.\n"; } void suggestCommands(const std::vector& tokens, std::ostream& os) const { std::vector> scored; for (auto& cmd : commands_) { Command::FuzzyScore s = cmd->fuzzyStructuralDistance(tokens); double ratio = s.keywordChars ? static_cast(s.distance) / static_cast(s.keywordChars) : 1.0; scored.emplace_back(ratio, cmd.get()); } std::sort(scored.begin(), scored.end(), [](auto& a, auto& b) { return a.first < b.first; }); std::vector close; for (auto& [ratio, cmd] : scored) { if (close.size() >= 3) break; if (ratio <= 0.5) close.push_back(cmd); } if (close.empty()) return; os << "\nDid you mean:\n"; for (auto* cmd : close) os << " " << programName_ << " " << cmd->usageLine() << "\n"; } static std::string firstLine(const std::string& s) { auto pos = s.find('\n'); return pos == std::string::npos ? s : s.substr(0, pos); } static std::string padRight(const std::string& s, std::size_t width) { if (s.size() >= width) return s + " "; return s + std::string(width - s.size(), ' '); } }; } // namespace cliforge