Skip to content

add an example of finding close matches #1152

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 30 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0549f7c
add an example of finding close matches
phlptp Apr 21, 2025
3021257
style: pre-commit.ci fixes
pre-commit-ci[bot] Apr 21, 2025
4373489
fix pre-commit items
phlptp Apr 21, 2025
91ded76
style: pre-commit.ci fixes
pre-commit-ci[bot] Apr 21, 2025
b857f35
clang-tidy fixes
phlptp Apr 21, 2025
77b33a8
style: pre-commit.ci fixes
pre-commit-ci[bot] Apr 21, 2025
2f59ab8
prototype separate function call for match and prefix matching
phlptp Apr 23, 2025
ae0735c
change to size_t for levenshteinDistance
phlptp Apr 23, 2025
31a62ac
style: pre-commit.ci fixes
pre-commit-ci[bot] Apr 23, 2025
a5768ac
clang-tidy fixes
phlptp Apr 28, 2025
bf46708
style: pre-commit.ci fixes
pre-commit-ci[bot] Apr 28, 2025
2175dc6
add prefix matching to be enabled by a flag in the app.
phlptp Apr 30, 2025
7c4a4d5
style: pre-commit.ci fixes
pre-commit-ci[bot] Apr 30, 2025
eec2211
refactor the close_match method into 2 different methods
phlptp Apr 30, 2025
f29420f
style: pre-commit.ci fixes
pre-commit-ci[bot] Apr 30, 2025
72c78a2
add some more tests and code cleanup
phlptp May 1, 2025
279e710
style: pre-commit.ci fixes
pre-commit-ci[bot] May 1, 2025
433ee18
update documentation and tests on prefix matching and clean up close …
phlptp May 2, 2025
94f08d9
style: pre-commit.ci fixes
pre-commit-ci[bot] May 2, 2025
7bc28a9
update some code and change the levenshteinDistance to only work with…
phlptp May 2, 2025
414bff9
style: pre-commit.ci fixes
pre-commit-ci[bot] May 2, 2025
d376292
fix warnings
phlptp May 2, 2025
c59489e
style: pre-commit.ci fixes
pre-commit-ci[bot] May 2, 2025
9b071f4
clean up code and comments
phlptp May 3, 2025
67c1728
more code clean up
phlptp May 3, 2025
a3c7f25
style: pre-commit.ci fixes
pre-commit-ci[bot] May 3, 2025
554ff27
try updating the codacy file
phlptp May 3, 2025
2f9c3b7
try ignoring style issues in codacy
phlptp May 3, 2025
dc79e70
reduce loc slightly for levenshteinDistance
phlptp May 5, 2025
109eb34
style: pre-commit.ci fixes
pre-commit-ci[bot] May 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .codacy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@ engines:
enabled: true
coverage:
enabled: false
cppcheck:
language: c++
languages:

ignore:
- "style"

exclude_paths:
- "fuzz/**/*"
- "fuzz/*"
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions book/chapters/subcommands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions examples/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
115 changes: 115 additions & 0 deletions examples/close_match.cpp
Original file line number Diff line number Diff line change
@@ -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;
}
24 changes: 22 additions & 2 deletions include/CLI/App.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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_; }

Expand Down Expand Up @@ -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<std::string> get_groups() const;

Expand Down
50 changes: 42 additions & 8 deletions include/CLI/impl/App_inl.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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_;
}
}

Expand Down Expand Up @@ -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_);
Expand All @@ -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_) {
Expand All @@ -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<std::string> App::get_groups() const {
Expand Down Expand Up @@ -1821,21 +1837,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) {
Expand Down
Loading
Loading