mirror of
https://github.com/CLIUtils/CLI11.git
synced 2026-01-19 04:52:08 +00:00
add an example of finding close matches (#1152)
continue discussion on #1149 Adds subcommand prefix matching as a modifier to CLI. Adds an example of close matching logic for further exploration. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -8,8 +8,13 @@ engines:
|
||||
enabled: true
|
||||
coverage:
|
||||
enabled: false
|
||||
cppcheck:
|
||||
language: c++
|
||||
languages:
|
||||
|
||||
ignore:
|
||||
- "style"
|
||||
|
||||
exclude_paths:
|
||||
- "fuzz/**/*"
|
||||
- "fuzz/*"
|
||||
|
||||
@@ -914,6 +914,11 @@ option_groups. These are:
|
||||
is not allowed to have a single character short option starting with the same
|
||||
character as a single dash long form name; for example, `-s` and `-single` are
|
||||
not allowed in the same application.
|
||||
- `.allow_subcommand_prefix_matching()`:🚧 If this modifier is enabled,
|
||||
unambiguious prefix portions of a subcommand will match. For example
|
||||
`upgrade_package` would match on `upgrade_`, `upg`, `u` as long as no other
|
||||
subcommand would also match. It also disallows subcommand names that are full
|
||||
prefixes of another subcommand.
|
||||
- `.fallthrough()`: Allow extra unmatched options and positionals to "fall
|
||||
through" and be matched on a parent option. Subcommands by default are allowed
|
||||
to "fall through" as in they will first attempt to match on the current
|
||||
|
||||
@@ -105,6 +105,7 @@ at the point the subcommand is created:
|
||||
- Fallthrough
|
||||
- Group name
|
||||
- Max required subcommands
|
||||
- prefix_matching
|
||||
- validate positional arguments
|
||||
- validate optional arguments
|
||||
|
||||
@@ -156,6 +157,14 @@ ignored, even if they could match. Git is the traditional example for prefix
|
||||
commands; if you run git with an unknown subcommand, like "`git thing`", it then
|
||||
calls another command called "`git-thing`" with the remaining options intact.
|
||||
|
||||
### prefix matching
|
||||
|
||||
A modifier is available for subcommand matching,
|
||||
`->allow_subcommand_prefix_matching()`. if this is enabled unambiguious prefix
|
||||
portions of a subcommand will match. For Example `upgrade_package` would match
|
||||
on `upgrade_`, `upg`, `u` as long as no other subcommand would also match. It
|
||||
also disallows subcommand names that are full prefixes of another subcommand.
|
||||
|
||||
### Silent subcommands
|
||||
|
||||
Subcommands can be modified by using the `silent` option. This will prevent the
|
||||
|
||||
@@ -250,6 +250,22 @@ set_property(TEST retired_retired_test3 PROPERTY PASS_REGULAR_EXPRESSION "WARNIN
|
||||
|
||||
set_property(TEST retired_deprecated PROPERTY PASS_REGULAR_EXPRESSION "deprecated.*not_deprecated")
|
||||
|
||||
if(CMAKE_CXX_STANDARD GREATER 13)
|
||||
add_cli_exe(close_match close_match.cpp)
|
||||
|
||||
add_test(NAME close_match_test COMMAND close_match i)
|
||||
add_test(NAME close_match_test2 COMMAND close_match upg)
|
||||
add_test(NAME close_match_test3 COMMAND close_match rem)
|
||||
add_test(NAME close_match_test4 COMMAND close_match upgrde)
|
||||
|
||||
set_property(TEST close_match_test PROPERTY PASS_REGULAR_EXPRESSION "install")
|
||||
|
||||
set_property(TEST close_match_test2 PROPERTY PASS_REGULAR_EXPRESSION "upgrade")
|
||||
|
||||
set_property(TEST close_match_test3 PROPERTY PASS_REGULAR_EXPRESSION "remove")
|
||||
|
||||
set_property(TEST close_match_test4 PROPERTY PASS_REGULAR_EXPRESSION "closest match is upgrade")
|
||||
endif()
|
||||
#--------------------------------------------
|
||||
add_cli_exe(custom_parse custom_parse.cpp)
|
||||
add_test(NAME cp_test COMMAND custom_parse --dv 1.7)
|
||||
|
||||
115
examples/close_match.cpp
Normal file
115
examples/close_match.cpp
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2017-2025, University of Cincinnati, developed by Henry Schreiner
|
||||
// under NSF AWARD 1414736 and by the respective contributors.
|
||||
// All rights reserved.
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Code inspired by discussion from https://github.com/CLIUtils/CLI11/issues/1149
|
||||
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
#include <numeric>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <CLI/CLI.hpp>
|
||||
|
||||
// only works with C++14 or higher
|
||||
|
||||
// Levenshtein distance function code generated by chatgpt/copilot
|
||||
std::size_t levenshteinDistance(const std::string &s1, const std::string &s2) {
|
||||
std::size_t len1 = s1.size(), len2 = s2.size();
|
||||
if(len1 == 0 || len2 == 0) {
|
||||
return (std::max)(len1, len2);
|
||||
}
|
||||
std::vector<std::size_t> prev(len2 + 1), curr(len2 + 1);
|
||||
std::iota(prev.begin(), prev.end(), 0); // Fill prev with {0, 1, ..., len2}
|
||||
|
||||
for(std::size_t ii = 1; ii <= len1; ++ii) {
|
||||
curr[0] = ii;
|
||||
for(std::size_t jj = 1; jj <= len2; ++jj) {
|
||||
// If characters match, no substitution cost; otherwise, cost is 1.
|
||||
std::size_t cost = (s1[ii - 1] == s2[jj - 1]) ? 0 : 1;
|
||||
|
||||
// Compute the minimum cost between:
|
||||
// - Deleting a character from `s1` (prev[jj] + 1)
|
||||
// - Inserting a character into `s1` (curr[jj - 1] + 1)
|
||||
// - Substituting a character (prev[jj - 1] + cost)
|
||||
|
||||
curr[jj] = (std::min)({prev[jj] + 1, curr[jj - 1] + 1, prev[jj - 1] + cost});
|
||||
}
|
||||
prev = std::exchange(curr, prev); // Swap vectors efficiently
|
||||
}
|
||||
return prev[len2];
|
||||
}
|
||||
|
||||
// Finds the closest string from a list (modified from chat gpt code)
|
||||
std::pair<std::string, std::size_t> findClosestMatch(const std::string &input,
|
||||
const std::vector<std::string> &candidates) {
|
||||
std::string closest;
|
||||
std::size_t minDistance{std::string::npos};
|
||||
for(const auto &candidate : candidates) {
|
||||
std::size_t distance = levenshteinDistance(input, candidate);
|
||||
if(distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closest = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return {closest, minDistance};
|
||||
}
|
||||
|
||||
void addSubcommandCloseMatchDetection(CLI::App *app, std::size_t minDistance = 3) {
|
||||
// if extras are not allowed then there will be no remaining
|
||||
app->allow_extras(true);
|
||||
// generate a list of subcommand names
|
||||
auto subs = app->get_subcommands(nullptr);
|
||||
CLI::results_t list;
|
||||
for(const auto *sub : subs) {
|
||||
if(!sub->get_name().empty()) {
|
||||
list.emplace_back(sub->get_name());
|
||||
}
|
||||
const auto &aliases = sub->get_aliases();
|
||||
if(!aliases.empty()) {
|
||||
list.insert(list.end(), aliases.begin(), aliases.end());
|
||||
}
|
||||
}
|
||||
// add a callback that runs before a final callback and loops over the remaining arguments for subcommands
|
||||
app->parse_complete_callback([app, minDistance, list = std::move(list)]() {
|
||||
for(auto &extra : app->remaining()) {
|
||||
if(!extra.empty() && extra.front() != '-') {
|
||||
auto closest = findClosestMatch(extra, list);
|
||||
if(closest.second <= minDistance) {
|
||||
std::cout << "unmatched command \"" << extra << "\", closest match is " << closest.first << "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** This example demonstrates the use of close match detection to detect invalid commands that are close matches to
|
||||
* existing ones
|
||||
*/
|
||||
int main(int argc, const char *argv[]) {
|
||||
|
||||
int value{0};
|
||||
CLI::App app{"App for testing prefix matching and close string matching"};
|
||||
// turn on prefix matching
|
||||
app.allow_subcommand_prefix_matching();
|
||||
app.add_option("-v", value, "value");
|
||||
|
||||
app.add_subcommand("install", "");
|
||||
app.add_subcommand("upgrade", "");
|
||||
app.add_subcommand("remove", "");
|
||||
app.add_subcommand("test", "");
|
||||
// enable close matching for subcommands
|
||||
addSubcommandCloseMatchDetection(&app, 5);
|
||||
CLI11_PARSE(app, argc, argv);
|
||||
|
||||
auto subs = app.get_subcommands();
|
||||
for(const auto &sub : subs) {
|
||||
std::cout << sub->get_name() << "\n";
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -271,6 +271,9 @@ class App {
|
||||
/// indicator that the subcommand should allow non-standard option arguments, such as -single_dash_flag
|
||||
bool allow_non_standard_options_{false};
|
||||
|
||||
/// indicator to allow subcommands to match with prefix matching
|
||||
bool allow_prefix_matching_{false};
|
||||
|
||||
/// Counts the number of times this command/subcommand was parsed
|
||||
std::uint32_t parsed_{0U};
|
||||
|
||||
@@ -409,6 +412,11 @@ class App {
|
||||
return this;
|
||||
}
|
||||
|
||||
/// allow prefix matching for subcommands
|
||||
App *allow_subcommand_prefix_matching(bool allowed = true) {
|
||||
allow_prefix_matching_ = allowed;
|
||||
return this;
|
||||
}
|
||||
/// Set the subcommand to be disabled by default, so on clear(), at the start of each parse it is disabled
|
||||
App *disabled_by_default(bool disable = true) {
|
||||
if(disable) {
|
||||
@@ -1166,9 +1174,12 @@ class App {
|
||||
/// Get the status of silence
|
||||
CLI11_NODISCARD bool get_silent() const { return silent_; }
|
||||
|
||||
/// Get the status of silence
|
||||
/// Get the status of allowing non standard option names
|
||||
CLI11_NODISCARD bool get_allow_non_standard_option_names() const { return allow_non_standard_options_; }
|
||||
|
||||
/// Get the status of allowing prefix matching for subcommands
|
||||
CLI11_NODISCARD bool get_allow_subcommand_prefix_matching() const { return allow_prefix_matching_; }
|
||||
|
||||
/// Get the status of disabled
|
||||
CLI11_NODISCARD bool get_immediate_callback() const { return immediate_callback_; }
|
||||
|
||||
@@ -1227,9 +1238,18 @@ class App {
|
||||
/// Get a display name for an app
|
||||
CLI11_NODISCARD std::string get_display_name(bool with_aliases = false) const;
|
||||
|
||||
/// Check the name, case-insensitive and underscore insensitive if set
|
||||
/// Check the name, case-insensitive and underscore insensitive, and prefix matching if set
|
||||
/// @return true if matched
|
||||
CLI11_NODISCARD bool check_name(std::string name_to_check) const;
|
||||
|
||||
/// @brief enumeration of matching possibilities
|
||||
enum class NameMatch : std::uint8_t { none = 0, exact = 1, prefix = 2 };
|
||||
|
||||
/// Check the name, case-insensitive and underscore insensitive if set
|
||||
/// @return NameMatch::none if no match, NameMatch::exact if the match is exact NameMatch::prefix if prefix is
|
||||
/// enabled and a prefix matches
|
||||
CLI11_NODISCARD NameMatch check_name_detail(std::string name_to_check) const;
|
||||
|
||||
/// Get the groups available directly from this option (in order)
|
||||
CLI11_NODISCARD std::vector<std::string> get_groups() const;
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ CLI11_INLINE App::App(std::string app_description, std::string app_name, App *pa
|
||||
formatter_ = parent_->formatter_;
|
||||
config_formatter_ = parent_->config_formatter_;
|
||||
require_subcommand_max_ = parent_->require_subcommand_max_;
|
||||
allow_prefix_matching_ = parent_->allow_prefix_matching_;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -900,6 +901,11 @@ CLI11_NODISCARD CLI11_INLINE std::string App::get_display_name(bool with_aliases
|
||||
}
|
||||
|
||||
CLI11_NODISCARD CLI11_INLINE bool App::check_name(std::string name_to_check) const {
|
||||
auto result = check_name_detail(std::move(name_to_check));
|
||||
return (result != NameMatch::none);
|
||||
}
|
||||
|
||||
CLI11_NODISCARD CLI11_INLINE App::NameMatch App::check_name_detail(std::string name_to_check) const {
|
||||
std::string local_name = name_;
|
||||
if(ignore_underscore_) {
|
||||
local_name = detail::remove_underscore(name_);
|
||||
@@ -911,7 +917,12 @@ CLI11_NODISCARD CLI11_INLINE bool App::check_name(std::string name_to_check) con
|
||||
}
|
||||
|
||||
if(local_name == name_to_check) {
|
||||
return true;
|
||||
return App::NameMatch::exact;
|
||||
}
|
||||
if(allow_prefix_matching_ && name_to_check.size() < local_name.size()) {
|
||||
if(local_name.compare(0, name_to_check.size(), name_to_check) == 0) {
|
||||
return App::NameMatch::prefix;
|
||||
}
|
||||
}
|
||||
for(std::string les : aliases_) { // NOLINT(performance-for-range-copy)
|
||||
if(ignore_underscore_) {
|
||||
@@ -921,10 +932,15 @@ CLI11_NODISCARD CLI11_INLINE bool App::check_name(std::string name_to_check) con
|
||||
les = detail::to_lower(les);
|
||||
}
|
||||
if(les == name_to_check) {
|
||||
return true;
|
||||
return App::NameMatch::exact;
|
||||
}
|
||||
if(allow_prefix_matching_ && name_to_check.size() < les.size()) {
|
||||
if(les.compare(0, name_to_check.size(), name_to_check) == 0) {
|
||||
return App::NameMatch::prefix;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return App::NameMatch::none;
|
||||
}
|
||||
|
||||
CLI11_NODISCARD CLI11_INLINE std::vector<std::string> App::get_groups() const {
|
||||
@@ -1850,21 +1866,39 @@ CLI11_INLINE bool App::_parse_positional(std::vector<std::string> &args, bool ha
|
||||
|
||||
CLI11_NODISCARD CLI11_INLINE App *
|
||||
App::_find_subcommand(const std::string &subc_name, bool ignore_disabled, bool ignore_used) const noexcept {
|
||||
App *bcom{nullptr};
|
||||
for(const App_p &com : subcommands_) {
|
||||
if(com->disabled_ && ignore_disabled)
|
||||
continue;
|
||||
if(com->get_name().empty()) {
|
||||
auto *subc = com->_find_subcommand(subc_name, ignore_disabled, ignore_used);
|
||||
if(subc != nullptr) {
|
||||
return subc;
|
||||
if(bcom != nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
bcom = subc;
|
||||
if(!allow_prefix_matching_) {
|
||||
return bcom;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(com->check_name(subc_name)) {
|
||||
if((!*com) || !ignore_used)
|
||||
return com.get();
|
||||
auto res = com->check_name_detail(subc_name);
|
||||
if(res != NameMatch::none) {
|
||||
if((!*com) || !ignore_used) {
|
||||
if(res == NameMatch::exact) {
|
||||
return com.get();
|
||||
}
|
||||
if(bcom != nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
bcom = com.get();
|
||||
if(!allow_prefix_matching_) {
|
||||
return bcom;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
return bcom;
|
||||
}
|
||||
|
||||
CLI11_INLINE bool App::_parse_subcommand(std::vector<std::string> &args) {
|
||||
|
||||
@@ -110,6 +110,104 @@ TEST_CASE_METHOD(TApp, "CrazyNameSubcommand", "[subcom]") {
|
||||
CHECK(1u == sub1->count());
|
||||
}
|
||||
|
||||
TEST_CASE_METHOD(TApp, "subcommandPrefix", "[subcom]") {
|
||||
app.allow_subcommand_prefix_matching();
|
||||
auto *sub1 = app.add_subcommand("sub1");
|
||||
CHECK(app.get_allow_subcommand_prefix_matching());
|
||||
// name can be set to whatever
|
||||
CHECK_NOTHROW(sub1->name("crazy name with spaces"));
|
||||
args = {"crazy name with spaces"};
|
||||
|
||||
run();
|
||||
|
||||
CHECK(app.got_subcommand("crazy name with spaces"));
|
||||
CHECK(1u == sub1->count());
|
||||
|
||||
args = {"crazy name"};
|
||||
run();
|
||||
|
||||
CHECK(app.got_subcommand("crazy name with spaces"));
|
||||
CHECK(1u == sub1->count());
|
||||
|
||||
args = {"crazy"};
|
||||
run();
|
||||
|
||||
CHECK(app.got_subcommand("crazy name"));
|
||||
CHECK(1u == sub1->count());
|
||||
|
||||
args = {"cr"};
|
||||
run();
|
||||
|
||||
CHECK(app.got_subcommand("crazy"));
|
||||
CHECK(1u == sub1->count());
|
||||
|
||||
args = {"c"};
|
||||
run();
|
||||
|
||||
CHECK(app.got_subcommand("crazy"));
|
||||
CHECK(1u == sub1->count());
|
||||
}
|
||||
|
||||
TEST_CASE_METHOD(TApp, "subcommandPrefixAlias", "[subcom]") {
|
||||
app.allow_subcommand_prefix_matching();
|
||||
auto *sub1 = app.add_subcommand("sub1");
|
||||
CHECK(app.get_allow_subcommand_prefix_matching());
|
||||
// name can be set to whatever
|
||||
sub1->alias("crazy name with spaces");
|
||||
args = {"crazy name with spaces"};
|
||||
|
||||
run();
|
||||
|
||||
CHECK(app.got_subcommand("crazy name with spaces"));
|
||||
CHECK(1u == sub1->count());
|
||||
|
||||
args = {"crazy name"};
|
||||
run();
|
||||
|
||||
CHECK(app.got_subcommand("crazy name with spaces"));
|
||||
CHECK(1u == sub1->count());
|
||||
|
||||
args = {"crazy"};
|
||||
run();
|
||||
|
||||
CHECK(app.got_subcommand("crazy name"));
|
||||
CHECK(1u == sub1->count());
|
||||
|
||||
args = {"cr"};
|
||||
run();
|
||||
|
||||
CHECK(app.got_subcommand("crazy"));
|
||||
CHECK(1u == sub1->count());
|
||||
|
||||
args = {"c"};
|
||||
run();
|
||||
|
||||
CHECK(app.got_subcommand("crazy"));
|
||||
CHECK(1u == sub1->count());
|
||||
}
|
||||
|
||||
TEST_CASE_METHOD(TApp, "subcommandPrefixMultiple", "[subcom]") {
|
||||
app.allow_subcommand_prefix_matching();
|
||||
auto *sub1 = app.add_subcommand("sub_long_prefix");
|
||||
auto *sub2 = app.add_subcommand("sub_elong_prefix");
|
||||
// name can be set to whatever
|
||||
args = {"sub_long"};
|
||||
|
||||
run();
|
||||
|
||||
CHECK(app.got_subcommand("sub_long_prefix"));
|
||||
CHECK(1u == sub1->count());
|
||||
|
||||
args = {"sub_e"};
|
||||
run();
|
||||
|
||||
CHECK(app.got_subcommand("sub_elong_prefix"));
|
||||
CHECK(1u == sub2->count());
|
||||
|
||||
args = {"sub_"};
|
||||
CHECK_THROWS_AS(run(), CLI::ExtrasError);
|
||||
}
|
||||
|
||||
TEST_CASE_METHOD(TApp, "RequiredAndSubcommands", "[subcom]") {
|
||||
|
||||
std::string baz;
|
||||
@@ -1773,6 +1871,24 @@ TEST_CASE_METHOD(TApp, "AliasErrors", "[subcom]") {
|
||||
sub2->ignore_underscore();
|
||||
CHECK_THROWS_AS(sub2->alias("les3"), CLI::OptionAlreadyAdded);
|
||||
}
|
||||
|
||||
TEST_CASE_METHOD(TApp, "DuplicateErrorsPrefix", "[subcom]") {
|
||||
app.allow_subcommand_prefix_matching(true);
|
||||
auto *sub1 = app.add_subcommand("sub_test");
|
||||
auto *sub2 = app.add_subcommand("sub_deploy");
|
||||
|
||||
CHECK_THROWS_AS(app.add_subcommand("sub"), CLI::OptionAlreadyAdded);
|
||||
// cannot alias to an existing subcommand
|
||||
CHECK_THROWS_AS(sub2->alias("sub"), CLI::OptionAlreadyAdded);
|
||||
app.ignore_case();
|
||||
// this needs to be opposite of the subcommand the alias is being tested on to check for ambiguity
|
||||
sub2->ignore_case();
|
||||
CHECK_THROWS_AS(sub1->alias("SUB_"), CLI::OptionAlreadyAdded);
|
||||
app.ignore_underscore();
|
||||
sub1->ignore_underscore();
|
||||
CHECK_THROWS_AS(sub2->alias("su_bt"), CLI::OptionAlreadyAdded);
|
||||
}
|
||||
|
||||
// test adding a subcommand via the pointer
|
||||
TEST_CASE_METHOD(TApp, "ExistingSubcommandMatch", "[subcom]") {
|
||||
auto sshared = std::make_shared<CLI::App>("documenting the subcommand", "sub1");
|
||||
|
||||
Reference in New Issue
Block a user