diff --git a/.codacy.yml b/.codacy.yml index 03a1e522b..26506bc89 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -8,8 +8,13 @@ engines: enabled: true coverage: enabled: false + cppcheck: + language: c++ languages: +ignore: + - "style" + exclude_paths: - "fuzz/**/*" - "fuzz/*" diff --git a/README.md b/README.md index 586d5c0fe..3e5008afd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/book/chapters/subcommands.md b/book/chapters/subcommands.md index 6ab331a26..b625ec369 100644 --- a/book/chapters/subcommands.md +++ b/book/chapters/subcommands.md @@ -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 diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 07c8f64d7..3d8373d38 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -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) diff --git a/examples/close_match.cpp b/examples/close_match.cpp new file mode 100644 index 000000000..0326e6562 --- /dev/null +++ b/examples/close_match.cpp @@ -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 +#include +#include +#include +#include +#include + +#include + +// 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 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 findClosestMatch(const std::string &input, + const std::vector &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; +} diff --git a/include/CLI/App.hpp b/include/CLI/App.hpp index ffbe986fc..2dbd77040 100644 --- a/include/CLI/App.hpp +++ b/include/CLI/App.hpp @@ -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) { @@ -1163,9 +1171,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_; } @@ -1224,9 +1235,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 get_groups() const; diff --git a/include/CLI/impl/App_inl.hpp b/include/CLI/impl/App_inl.hpp index fdeab4e05..b6e28efa6 100644 --- a/include/CLI/impl/App_inl.hpp +++ b/include/CLI/impl/App_inl.hpp @@ -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_; } } @@ -894,6 +895,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_); @@ -905,7 +911,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_) { @@ -915,10 +926,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 App::get_groups() const { @@ -1821,21 +1837,39 @@ CLI11_INLINE bool App::_parse_positional(std::vector &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 &args) { diff --git a/tests/SubcommandTest.cpp b/tests/SubcommandTest.cpp index b794e6b8d..2feb6e188 100644 --- a/tests/SubcommandTest.cpp +++ b/tests/SubcommandTest.cpp @@ -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("documenting the subcommand", "sub1");