Skip to content

Commit 5602f2b

Browse files
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>
1 parent 50591fb commit 5602f2b

File tree

8 files changed

+330
-10
lines changed

8 files changed

+330
-10
lines changed

.codacy.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,13 @@ engines:
88
enabled: true
99
coverage:
1010
enabled: false
11+
cppcheck:
12+
language: c++
1113
languages:
1214

15+
ignore:
16+
- "style"
17+
1318
exclude_paths:
1419
- "fuzz/**/*"
1520
- "fuzz/*"

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -914,6 +914,11 @@ option_groups. These are:
914914
is not allowed to have a single character short option starting with the same
915915
character as a single dash long form name; for example, `-s` and `-single` are
916916
not allowed in the same application.
917+
- `.allow_subcommand_prefix_matching()`:🚧 If this modifier is enabled,
918+
unambiguious prefix portions of a subcommand will match. For example
919+
`upgrade_package` would match on `upgrade_`, `upg`, `u` as long as no other
920+
subcommand would also match. It also disallows subcommand names that are full
921+
prefixes of another subcommand.
917922
- `.fallthrough()`: Allow extra unmatched options and positionals to "fall
918923
through" and be matched on a parent option. Subcommands by default are allowed
919924
to "fall through" as in they will first attempt to match on the current

book/chapters/subcommands.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ at the point the subcommand is created:
105105
- Fallthrough
106106
- Group name
107107
- Max required subcommands
108+
- prefix_matching
108109
- validate positional arguments
109110
- validate optional arguments
110111

@@ -156,6 +157,14 @@ ignored, even if they could match. Git is the traditional example for prefix
156157
commands; if you run git with an unknown subcommand, like "`git thing`", it then
157158
calls another command called "`git-thing`" with the remaining options intact.
158159

160+
### prefix matching
161+
162+
A modifier is available for subcommand matching,
163+
`->allow_subcommand_prefix_matching()`. if this is enabled unambiguious prefix
164+
portions of a subcommand will match. For Example `upgrade_package` would match
165+
on `upgrade_`, `upg`, `u` as long as no other subcommand would also match. It
166+
also disallows subcommand names that are full prefixes of another subcommand.
167+
159168
### Silent subcommands
160169

161170
Subcommands can be modified by using the `silent` option. This will prevent the

examples/CMakeLists.txt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,22 @@ set_property(TEST retired_retired_test3 PROPERTY PASS_REGULAR_EXPRESSION "WARNIN
250250

251251
set_property(TEST retired_deprecated PROPERTY PASS_REGULAR_EXPRESSION "deprecated.*not_deprecated")
252252

253+
if(CMAKE_CXX_STANDARD GREATER 13)
254+
add_cli_exe(close_match close_match.cpp)
255+
256+
add_test(NAME close_match_test COMMAND close_match i)
257+
add_test(NAME close_match_test2 COMMAND close_match upg)
258+
add_test(NAME close_match_test3 COMMAND close_match rem)
259+
add_test(NAME close_match_test4 COMMAND close_match upgrde)
260+
261+
set_property(TEST close_match_test PROPERTY PASS_REGULAR_EXPRESSION "install")
262+
263+
set_property(TEST close_match_test2 PROPERTY PASS_REGULAR_EXPRESSION "upgrade")
264+
265+
set_property(TEST close_match_test3 PROPERTY PASS_REGULAR_EXPRESSION "remove")
266+
267+
set_property(TEST close_match_test4 PROPERTY PASS_REGULAR_EXPRESSION "closest match is upgrade")
268+
endif()
253269
#--------------------------------------------
254270
add_cli_exe(custom_parse custom_parse.cpp)
255271
add_test(NAME cp_test COMMAND custom_parse --dv 1.7)

examples/close_match.cpp

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright (c) 2017-2025, University of Cincinnati, developed by Henry Schreiner
2+
// under NSF AWARD 1414736 and by the respective contributors.
3+
// All rights reserved.
4+
//
5+
// SPDX-License-Identifier: BSD-3-Clause
6+
7+
// Code inspired by discussion from https://github.com/CLIUtils/CLI11/issues/1149
8+
9+
#include <algorithm>
10+
#include <iostream>
11+
#include <numeric>
12+
#include <string>
13+
#include <utility>
14+
#include <vector>
15+
16+
#include <CLI/CLI.hpp>
17+
18+
// only works with C++14 or higher
19+
20+
// Levenshtein distance function code generated by chatgpt/copilot
21+
std::size_t levenshteinDistance(const std::string &s1, const std::string &s2) {
22+
std::size_t len1 = s1.size(), len2 = s2.size();
23+
if(len1 == 0 || len2 == 0) {
24+
return (std::max)(len1, len2);
25+
}
26+
std::vector<std::size_t> prev(len2 + 1), curr(len2 + 1);
27+
std::iota(prev.begin(), prev.end(), 0); // Fill prev with {0, 1, ..., len2}
28+
29+
for(std::size_t ii = 1; ii <= len1; ++ii) {
30+
curr[0] = ii;
31+
for(std::size_t jj = 1; jj <= len2; ++jj) {
32+
// If characters match, no substitution cost; otherwise, cost is 1.
33+
std::size_t cost = (s1[ii - 1] == s2[jj - 1]) ? 0 : 1;
34+
35+
// Compute the minimum cost between:
36+
// - Deleting a character from `s1` (prev[jj] + 1)
37+
// - Inserting a character into `s1` (curr[jj - 1] + 1)
38+
// - Substituting a character (prev[jj - 1] + cost)
39+
40+
curr[jj] = (std::min)({prev[jj] + 1, curr[jj - 1] + 1, prev[jj - 1] + cost});
41+
}
42+
prev = std::exchange(curr, prev); // Swap vectors efficiently
43+
}
44+
return prev[len2];
45+
}
46+
47+
// Finds the closest string from a list (modified from chat gpt code)
48+
std::pair<std::string, std::size_t> findClosestMatch(const std::string &input,
49+
const std::vector<std::string> &candidates) {
50+
std::string closest;
51+
std::size_t minDistance{std::string::npos};
52+
for(const auto &candidate : candidates) {
53+
std::size_t distance = levenshteinDistance(input, candidate);
54+
if(distance < minDistance) {
55+
minDistance = distance;
56+
closest = candidate;
57+
}
58+
}
59+
60+
return {closest, minDistance};
61+
}
62+
63+
void addSubcommandCloseMatchDetection(CLI::App *app, std::size_t minDistance = 3) {
64+
// if extras are not allowed then there will be no remaining
65+
app->allow_extras(true);
66+
// generate a list of subcommand names
67+
auto subs = app->get_subcommands(nullptr);
68+
CLI::results_t list;
69+
for(const auto *sub : subs) {
70+
if(!sub->get_name().empty()) {
71+
list.emplace_back(sub->get_name());
72+
}
73+
const auto &aliases = sub->get_aliases();
74+
if(!aliases.empty()) {
75+
list.insert(list.end(), aliases.begin(), aliases.end());
76+
}
77+
}
78+
// add a callback that runs before a final callback and loops over the remaining arguments for subcommands
79+
app->parse_complete_callback([app, minDistance, list = std::move(list)]() {
80+
for(auto &extra : app->remaining()) {
81+
if(!extra.empty() && extra.front() != '-') {
82+
auto closest = findClosestMatch(extra, list);
83+
if(closest.second <= minDistance) {
84+
std::cout << "unmatched command \"" << extra << "\", closest match is " << closest.first << "\n";
85+
}
86+
}
87+
}
88+
});
89+
}
90+
91+
/** This example demonstrates the use of close match detection to detect invalid commands that are close matches to
92+
* existing ones
93+
*/
94+
int main(int argc, const char *argv[]) {
95+
96+
int value{0};
97+
CLI::App app{"App for testing prefix matching and close string matching"};
98+
// turn on prefix matching
99+
app.allow_subcommand_prefix_matching();
100+
app.add_option("-v", value, "value");
101+
102+
app.add_subcommand("install", "");
103+
app.add_subcommand("upgrade", "");
104+
app.add_subcommand("remove", "");
105+
app.add_subcommand("test", "");
106+
// enable close matching for subcommands
107+
addSubcommandCloseMatchDetection(&app, 5);
108+
CLI11_PARSE(app, argc, argv);
109+
110+
auto subs = app.get_subcommands();
111+
for(const auto &sub : subs) {
112+
std::cout << sub->get_name() << "\n";
113+
}
114+
return 0;
115+
}

include/CLI/App.hpp

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,9 @@ class App {
271271
/// indicator that the subcommand should allow non-standard option arguments, such as -single_dash_flag
272272
bool allow_non_standard_options_{false};
273273

274+
/// indicator to allow subcommands to match with prefix matching
275+
bool allow_prefix_matching_{false};
276+
274277
/// Counts the number of times this command/subcommand was parsed
275278
std::uint32_t parsed_{0U};
276279

@@ -409,6 +412,11 @@ class App {
409412
return this;
410413
}
411414

415+
/// allow prefix matching for subcommands
416+
App *allow_subcommand_prefix_matching(bool allowed = true) {
417+
allow_prefix_matching_ = allowed;
418+
return this;
419+
}
412420
/// Set the subcommand to be disabled by default, so on clear(), at the start of each parse it is disabled
413421
App *disabled_by_default(bool disable = true) {
414422
if(disable) {
@@ -1166,9 +1174,12 @@ class App {
11661174
/// Get the status of silence
11671175
CLI11_NODISCARD bool get_silent() const { return silent_; }
11681176

1169-
/// Get the status of silence
1177+
/// Get the status of allowing non standard option names
11701178
CLI11_NODISCARD bool get_allow_non_standard_option_names() const { return allow_non_standard_options_; }
11711179

1180+
/// Get the status of allowing prefix matching for subcommands
1181+
CLI11_NODISCARD bool get_allow_subcommand_prefix_matching() const { return allow_prefix_matching_; }
1182+
11721183
/// Get the status of disabled
11731184
CLI11_NODISCARD bool get_immediate_callback() const { return immediate_callback_; }
11741185

@@ -1227,9 +1238,18 @@ class App {
12271238
/// Get a display name for an app
12281239
CLI11_NODISCARD std::string get_display_name(bool with_aliases = false) const;
12291240

1230-
/// Check the name, case-insensitive and underscore insensitive if set
1241+
/// Check the name, case-insensitive and underscore insensitive, and prefix matching if set
1242+
/// @return true if matched
12311243
CLI11_NODISCARD bool check_name(std::string name_to_check) const;
12321244

1245+
/// @brief enumeration of matching possibilities
1246+
enum class NameMatch : std::uint8_t { none = 0, exact = 1, prefix = 2 };
1247+
1248+
/// Check the name, case-insensitive and underscore insensitive if set
1249+
/// @return NameMatch::none if no match, NameMatch::exact if the match is exact NameMatch::prefix if prefix is
1250+
/// enabled and a prefix matches
1251+
CLI11_NODISCARD NameMatch check_name_detail(std::string name_to_check) const;
1252+
12331253
/// Get the groups available directly from this option (in order)
12341254
CLI11_NODISCARD std::vector<std::string> get_groups() const;
12351255

include/CLI/impl/App_inl.hpp

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ CLI11_INLINE App::App(std::string app_description, std::string app_name, App *pa
5757
formatter_ = parent_->formatter_;
5858
config_formatter_ = parent_->config_formatter_;
5959
require_subcommand_max_ = parent_->require_subcommand_max_;
60+
allow_prefix_matching_ = parent_->allow_prefix_matching_;
6061
}
6162
}
6263

@@ -900,6 +901,11 @@ CLI11_NODISCARD CLI11_INLINE std::string App::get_display_name(bool with_aliases
900901
}
901902

902903
CLI11_NODISCARD CLI11_INLINE bool App::check_name(std::string name_to_check) const {
904+
auto result = check_name_detail(std::move(name_to_check));
905+
return (result != NameMatch::none);
906+
}
907+
908+
CLI11_NODISCARD CLI11_INLINE App::NameMatch App::check_name_detail(std::string name_to_check) const {
903909
std::string local_name = name_;
904910
if(ignore_underscore_) {
905911
local_name = detail::remove_underscore(name_);
@@ -911,7 +917,12 @@ CLI11_NODISCARD CLI11_INLINE bool App::check_name(std::string name_to_check) con
911917
}
912918

913919
if(local_name == name_to_check) {
914-
return true;
920+
return App::NameMatch::exact;
921+
}
922+
if(allow_prefix_matching_ && name_to_check.size() < local_name.size()) {
923+
if(local_name.compare(0, name_to_check.size(), name_to_check) == 0) {
924+
return App::NameMatch::prefix;
925+
}
915926
}
916927
for(std::string les : aliases_) { // NOLINT(performance-for-range-copy)
917928
if(ignore_underscore_) {
@@ -921,10 +932,15 @@ CLI11_NODISCARD CLI11_INLINE bool App::check_name(std::string name_to_check) con
921932
les = detail::to_lower(les);
922933
}
923934
if(les == name_to_check) {
924-
return true;
935+
return App::NameMatch::exact;
936+
}
937+
if(allow_prefix_matching_ && name_to_check.size() < les.size()) {
938+
if(les.compare(0, name_to_check.size(), name_to_check) == 0) {
939+
return App::NameMatch::prefix;
940+
}
925941
}
926942
}
927-
return false;
943+
return App::NameMatch::none;
928944
}
929945

930946
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
18501866

18511867
CLI11_NODISCARD CLI11_INLINE App *
18521868
App::_find_subcommand(const std::string &subc_name, bool ignore_disabled, bool ignore_used) const noexcept {
1869+
App *bcom{nullptr};
18531870
for(const App_p &com : subcommands_) {
18541871
if(com->disabled_ && ignore_disabled)
18551872
continue;
18561873
if(com->get_name().empty()) {
18571874
auto *subc = com->_find_subcommand(subc_name, ignore_disabled, ignore_used);
18581875
if(subc != nullptr) {
1859-
return subc;
1876+
if(bcom != nullptr) {
1877+
return nullptr;
1878+
}
1879+
bcom = subc;
1880+
if(!allow_prefix_matching_) {
1881+
return bcom;
1882+
}
18601883
}
18611884
}
1862-
if(com->check_name(subc_name)) {
1863-
if((!*com) || !ignore_used)
1864-
return com.get();
1885+
auto res = com->check_name_detail(subc_name);
1886+
if(res != NameMatch::none) {
1887+
if((!*com) || !ignore_used) {
1888+
if(res == NameMatch::exact) {
1889+
return com.get();
1890+
}
1891+
if(bcom != nullptr) {
1892+
return nullptr;
1893+
}
1894+
bcom = com.get();
1895+
if(!allow_prefix_matching_) {
1896+
return bcom;
1897+
}
1898+
}
18651899
}
18661900
}
1867-
return nullptr;
1901+
return bcom;
18681902
}
18691903

18701904
CLI11_INLINE bool App::_parse_subcommand(std::vector<std::string> &args) {

0 commit comments

Comments
 (0)