Skip to content

Commit

Permalink
Subcommand fallthrough (#1073)
Browse files Browse the repository at this point in the history
Add modifier for subcommands to restrict subcommands falling through to
parent.
This will resolve #1022

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
  • Loading branch information
phlptp and pre-commit-ci[bot] authored Oct 9, 2024
1 parent 79b1430 commit ca66827
Show file tree
Hide file tree
Showing 8 changed files with 74 additions and 24 deletions.
2 changes: 1 addition & 1 deletion .github/actions/quick_cmake/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ runs:
using: composite
steps:
- name: CMake ${{ inputs.cmake-version }}
uses: jwlawson/actions-setup-cmake@v1.14
uses: jwlawson/actions-setup-cmake@v2.0.2
with:
cmake-version: "${{ inputs.cmake-version }}"
- run: |
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -292,10 +292,10 @@ jobs:
cmake-version: "3.13"
if: success() || failure()

- name: Check CMake 3.14
- name: Check CMake 3.14.7
uses: ./.github/actions/quick_cmake
with:
cmake-version: "3.14"
cmake-version: "3.14.7"
args: -DCLI11_SANITIZERS=ON -DCLI11_BUILD_EXAMPLES_JSON=ON
if: success() || failure()

Expand Down Expand Up @@ -387,6 +387,6 @@ jobs:
- name: Check CMake 3.28 (full)
uses: ./.github/actions/quick_cmake
with:
cmake-version: "3.28"
cmake-version: "3.28.X"
args: -DCLI11_SANITIZERS=ON -DCLI11_BUILD_EXAMPLES_JSON=ON
if: success() || failure()
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -905,10 +905,15 @@ option_groups. These are:
options are specified in the `add_option` calls or the ability to process
options in the form of `-s --long --file=file_name.ext`.
- `.fallthrough()`: Allow extra unmatched options and positionals to "fall
through" and be matched on a parent option. Subcommands always are allowed to
"fall through" as in they will first attempt to match on the current
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
subcommand and if they fail will progressively check parents for matching
subcommands.
subcommands. This can be disabled through `subcommand_fallthrough(false)` 🚧.
- `.subcommand_fallthrough()`: 🚧 Allow subcommands to "fall through" and be
matched on a parent option. Disabling this prevents additional subcommands at
the same level from being matched. It can be useful in certain circumstances
where there might be ambiguity between subcommands and positionals. The
default is true.
- `.configurable()`: Allow the subcommand to be triggered from a configuration
file. By default subcommand options in a configuration file do not trigger a
subcommand but will just update default values.
Expand Down
16 changes: 15 additions & 1 deletion book/chapters/subcommands.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,21 @@ gitbook:code $ ./my_program my_model_1 --model_flag --shared_flag
```

Here, `--shared_flag` was set on the main app, and on the command line it "falls
through" `my_model_1` to match on the main app.
through" `my_model_1` to match on the main app. This is set through
`->fallthrough()` on a subcommand.

#### Subcommand fallthrough

Subcommand fallthrough allows additional subcommands to be triggered after the
first subcommand. By default subcommand fallthrough is enabled, but it can be
turned off through `->subcommand_fallthrough(false)` on a subcommand. This will
prevent additional subcommands at the same inheritance level from triggering,
the strings would then be treated as positional values. As a technical note if
fallthrough is enabled but subcommand fallthrough disabled (this is not the
default in both cases), then subcommands on grandparents can still be triggered
from the grandchild subcommand, unless subcommand fallthrough is also disabled
on the parent. This is an unusual circumstance but may arise in some very
particular situations.

### Prefix command

Expand Down
17 changes: 15 additions & 2 deletions include/CLI/App.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,13 @@ class App {
/// If true, the program should ignore underscores INHERITABLE
bool ignore_underscore_{false};

/// Allow subcommand fallthrough, so that parent commands can collect commands after subcommand. INHERITABLE
/// Allow options or other arguments to fallthrough, so that parent commands can collect options after subcommand.
/// INHERITABLE
bool fallthrough_{false};

/// Allow subcommands to fallthrough, so that parent commands can trigger other subcommands after subcommand.
bool subcommand_fallthrough_{true};

/// Allow '/' for options for Windows like options. Defaults to true on Windows, false otherwise. INHERITABLE
bool allow_windows_style_options_{
#ifdef _WIN32
Expand Down Expand Up @@ -828,13 +832,19 @@ class App {
return this;
}

/// Stop subcommand fallthrough, so that parent commands cannot collect commands after subcommand.
/// Set fallthrough, set to true so that options will fallthrough to parent if not recognized in a subcommand
/// Default from parent, usually set on parent.
App *fallthrough(bool value = true) {
fallthrough_ = value;
return this;
}

/// Set subcommand fallthrough, set to true so that subcommands on parents are recognized
App *subcommand_fallthrough(bool value = true) {
subcommand_fallthrough_ = value;
return this;
}

/// Check to see if this subcommand was parsed, true only if received on command line.
/// This allows the subcommand to be directly checked.
explicit operator bool() const { return parsed_ > 0; }
Expand Down Expand Up @@ -1084,6 +1094,9 @@ class App {
/// Check the status of fallthrough
CLI11_NODISCARD bool get_fallthrough() const { return fallthrough_; }

/// Check the status of subcommand fallthrough
CLI11_NODISCARD bool get_subcommand_fallthrough() const { return subcommand_fallthrough_; }

/// Check the status of the allow windows style options
CLI11_NODISCARD bool get_allow_windows_style_options() const { return allow_windows_style_options_; }

Expand Down
29 changes: 17 additions & 12 deletions include/CLI/impl/App_inl.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -1038,15 +1038,19 @@ CLI11_INLINE void App::run_callback(bool final_mode, bool suppress_final_callbac

CLI11_NODISCARD CLI11_INLINE bool App::_valid_subcommand(const std::string &current, bool ignore_used) const {
// Don't match if max has been reached - but still check parents
if(require_subcommand_max_ != 0 && parsed_subcommands_.size() >= require_subcommand_max_) {
if(require_subcommand_max_ != 0 && parsed_subcommands_.size() >= require_subcommand_max_ &&
subcommand_fallthrough_) {
return parent_ != nullptr && parent_->_valid_subcommand(current, ignore_used);
}
auto *com = _find_subcommand(current, true, ignore_used);
if(com != nullptr) {
return true;
}
// Check parent if exists, else return false
return parent_ != nullptr && parent_->_valid_subcommand(current, ignore_used);
if(subcommand_fallthrough_) {
return parent_ != nullptr && parent_->_valid_subcommand(current, ignore_used);
}
return false;
}

CLI11_NODISCARD CLI11_INLINE detail::Classifier App::_recognize(const std::string &current,
Expand Down Expand Up @@ -1743,9 +1747,9 @@ CLI11_INLINE bool App::_parse_positional(std::vector<std::string> &args, bool ha
}
}
// let the parent deal with it if possible
if(parent_ != nullptr && fallthrough_)
if(parent_ != nullptr && fallthrough_) {
return _get_fallthrough_parent()->_parse_positional(args, static_cast<bool>(parse_complete_callback_));

}
/// Try to find a local subcommand that is repeated
auto *com = _find_subcommand(args.back(), true, false);
if(com != nullptr && (require_subcommand_max_ == 0 || require_subcommand_max_ > parsed_subcommands_.size())) {
Expand All @@ -1756,15 +1760,16 @@ CLI11_INLINE bool App::_parse_positional(std::vector<std::string> &args, bool ha
com->_parse(args);
return true;
}
/// now try one last gasp at subcommands that have been executed before, go to root app and try to find a
/// subcommand in a broader way, if one exists let the parent deal with it
auto *parent_app = (parent_ != nullptr) ? _get_fallthrough_parent() : this;
com = parent_app->_find_subcommand(args.back(), true, false);
if(com != nullptr && (com->parent_->require_subcommand_max_ == 0 ||
com->parent_->require_subcommand_max_ > com->parent_->parsed_subcommands_.size())) {
return false;
if(subcommand_fallthrough_) {
/// now try one last gasp at subcommands that have been executed before, go to root app and try to find a
/// subcommand in a broader way, if one exists let the parent deal with it
auto *parent_app = (parent_ != nullptr) ? _get_fallthrough_parent() : this;
com = parent_app->_find_subcommand(args.back(), true, false);
if(com != nullptr && (com->parent_->require_subcommand_max_ == 0 ||
com->parent_->require_subcommand_max_ > com->parent_->parsed_subcommands_.size())) {
return false;
}
}

if(positionals_at_end_) {
throw CLI::ExtrasError(name_, args);
}
Expand Down
2 changes: 1 addition & 1 deletion tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ if(CLI11_SANITIZERS AND ${CMAKE_VERSION} VERSION_GREATER "3.13.0")
sanitizers
GIT_REPOSITORY https://github.com/arsenm/sanitizers-cmake.git
GIT_SHALLOW 1
GIT_TAG 3f0542e)
GIT_TAG 0573e2e)

FetchContent_GetProperties(sanitizers)

Expand Down
15 changes: 14 additions & 1 deletion tests/SubcommandTest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -719,12 +719,25 @@ TEST_CASE_METHOD(TApp, "Required1SubCom", "[subcom]") {
CHECK_THROWS_AS(run(), CLI::RequiredError);

args = {"sub1"};
run();
CHECK_NOTHROW(run());

args = {"sub1", "sub2"};
CHECK_THROWS_AS(run(), CLI::ExtrasError);
}

TEST_CASE_METHOD(TApp, "subcomNoSubComfallthrough", "[subcom]") {
auto *sub1 = app.add_subcommand("sub1");
std::vector<std::string> pos;
sub1->add_option("args", pos);
app.add_subcommand("sub2");
app.add_subcommand("sub3");
sub1->subcommand_fallthrough(false);
CHECK_FALSE(sub1->get_subcommand_fallthrough());
args = {"sub1", "sub2", "sub3"};
run();
CHECK(pos.size() == 2);
}

TEST_CASE_METHOD(TApp, "BadSubcommandSearch", "[subcom]") {

auto *one = app.add_subcommand("one");
Expand Down

0 comments on commit ca66827

Please sign in to comment.