From e878bdaf5a9bd26ac8f6058e2301eebca4a5d319 Mon Sep 17 00:00:00 2001 From: Acrux Development Date: Tue, 18 Aug 2026 19:17:52 -0300 Subject: [PATCH] Fix did-you-mean system --- README.md | 22 +++++++++++++++++++++- include/cliforge/engine.hpp | 35 ++++++++++++++++++++++++++++------- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a737c91..45fb79b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# CLIforge +# CLIForge A header-only C++20 engine for building CLIs whose commands are ordinary free functions. Parameters and options are explicitly typed where @@ -158,6 +158,26 @@ three, clearly separated, rather than silently picking one. A fully-specified prefix (`project myapp delete --help`) narrows to the one actual match, exactly as before. +The same treatment applies to an *incomplete* command that wasn't +explicitly asking for help: `packctl project` alone is missing +``, and that's equally true of all three `project` commands (they +report the identical error). Rather than arbitrarily surfacing whichever +one happened to be registered first, every command tied for "most +specific incomplete match" is listed: + +``` +$ packctl project +Error: missing required argument '': expected a value of type string +'project' matches 3 commands: + packctl project create [OPTIONS] Scaffold a new project from a template + packctl project delete [OPTIONS] Permanently delete a project by name + packctl project [OPTIONS] Show information about a project +``` + +A single most-specific match (e.g. `packctl project x create --template`, +missing only `--template`'s value) still reports just the one relevant +`Usage:` line, unchanged. + ## Keeping allocations down * `TypeInfo`'s parser is a **plain stateless function pointer**, not diff --git a/include/cliforge/engine.hpp b/include/cliforge/engine.hpp index bd92718..619daa4 100644 --- a/include/cliforge/engine.hpp +++ b/include/cliforge/engine.hpp @@ -81,26 +81,47 @@ public: }; std::vector candidates; std::string bestPartialError; - Command* bestPartialCmd = nullptr; + std::vector bestPartialCmds; // every command tied for the best partial score 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(); + } 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 " 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 (bestPartialCmd) { + 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"; - std::cerr << "Usage: " << programName_ << " " << bestPartialCmd->usageLine() << "\n"; + if (bestPartialCmds.size() == 1) { + std::cerr << "Usage: " << programName_ << " " << bestPartialCmds.front()->usageLine() + << "\n"; + } else { + std::cerr << "'" << join(tokens) << "' matches " << bestPartialCmds.size() + << " commands:\n"; + for (auto* cmd : bestPartialCmds) { + std::string line = programName_ + " " + cmd->usageLine(); + std::cerr << " " << padRight(line, 46) << firstLine(cmd->description()) << "\n"; + } + } return 1; } std::cerr << "Error: no command matches '" << join(tokens) << "'.\n";