Skip to content

feat: Suggest closest command on typo - #4367

Open
faze-geek wants to merge 2 commits into
mamba-org:mainfrom
faze-geek:suggest_typos
Open

feat: Suggest closest command on typo#4367
faze-geek wants to merge 2 commits into
mamba-org:mainfrom
faze-geek:suggest_typos

Conversation

@faze-geek

Copy link
Copy Markdown
Contributor

Description

Fixes #3856 This PR adds hints when typos are made to subcommands.

Before : Mamba prints only a cryptic CLI11 error message with no hints.

anurag@Anurags-MacBook-Air mamba % mamba instal xtensor
The following arguments were not expected: instal xtensor
Run with --help for more information.

Now : Conda style "Did you mean <cmd>" suggestion is added.

anurag@Anurags-MacBook-Air mamba % mamba instal xtensor
The following arguments were not expected: instal xtensor
Did you mean 'install'?
Run with --help for more information.

Type of Change

  • Bugfix
  • Feature / enhancement
  • CI / Documentation
  • Maintenance

Checklist

  • My code follows the general style and conventions of the codebase, ensuring consistency
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings
  • I have run pre-commit run --all locally in the source folder and confirmed that there are no linter errors.
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing tests pass locally with my changes

@github-actions github-actions Bot added the release::enhancements For enhancements PRs or implementing features label Jul 30, 2026
@faze-geek

Copy link
Copy Markdown
Contributor Author

Approach

  • Added two utilities to libmamba/util

    1. similarity_ratio(str1, str2) : Mimics Python's difflib.SequenceMatcher(str1, str2).ratio().
    2. closest_matches(input, candidates, cutoff, max_results) : Mimics difflib.get_close_matches.
      It ensures conda parity, as it uses the same algorithm.
  • Hooked into CLI11 via App::failure_message : Descent to the deepest parsed subcommand, take the first non flag leftover token & return the single best match.

@faze-geek

Copy link
Copy Markdown
Contributor Author

More testing from my terminal

1. Top level typo

anurag@Anurags-MacBook-Air mamba % mamba instal xtensor
The following arguments were not expected: instal xtensor
Did you mean 'install'?
Run with --help for more information.

2. Transition typo

anurag@Anurags-MacBook-Air mamba % $MAMBA lsit
The following argument was not expected: lsit
Did you mean 'list'?
Run with --help for more information.
anurag@Anurags-MacBook-Air mamba % $MAMBA activ

3. Truncated

The following argument was not expected: activ
Did you mean 'activate'?
Run with --help for more information.

4. Subcommand

anurag@Anurags-MacBook-Air mamba % $MAMBA repoquery whoneds openmp
The following arguments were not expected: whoneds openmp
Did you mean 'whoneeds'?
Run with --help for more information.

5. Alias

anurag@Anurags-MacBook-Air mamba % $MAMBA uninstal xtensor
The following arguments were not expected: uninstal xtensor
Did you mean 'uninstall'?
Run with --help for more information.

6. Flags do not get suggestions

anurag@Anurags-MacBook-Air mamba % $MAMBA list --badflag
The following argument was not expected: --badflag
Run with --help for more information.

7. Randoms do not get suggestions

anurag@Anurags-MacBook-Air mamba % $MAMBA zzzzzzzz
The following argument was not expected: zzzzzzzz
Run with --help for more information.

@jjerphan jjerphan changed the title Suggest closest command on typo feat: Suggest closest command on typo Jul 31, 2026
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 56.47059% with 37 lines in your changes missing coverage. Please review.
✅ Project coverage is 55.40%. Comparing base (7f86ebf) to head (171319f).

Files with missing lines Patch % Lines
micromamba/src/umamba.cpp 0.00% 37 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4367   +/-   ##
=======================================
  Coverage   55.39%   55.40%           
=======================================
  Files         243      243           
  Lines       30360    30445   +85     
  Branches     3253     3273   +20     
=======================================
+ Hits        16818    16867   +49     
- Misses      13539    13575   +36     
  Partials        3        3           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jjerphan

jjerphan commented Aug 4, 2026

Copy link
Copy Markdown
Member

Hello @faze-geek,

Thank you for this contribution.

Although I also think it makes sense for mamba to suggest commands, I wonder whether this issue could better be handled by CLI11 as proposed by @Hind-M with CLIUtils/CLI11#1149.

Edit: I guess we could propose this implementation to CLI11 and in the meantime vendor it in mamba.

@faze-geek

Copy link
Copy Markdown
Contributor Author

Hi @jjerphan, thanks for the background.

I did go through CLIUtils/CLI11#1149 and see 2 distinct things -

  1. Prefix Matching - This makes myapp ins run install when the prefix is unambiguous. Auto execution is a different functionality that the typo feature requested in BUG: typos in subcommand lead to confusing warnings #3856 .
  2. Close Matching - Not in the library, lives in an examples/ file for exploration. @phlptp does explicitly mention in comments that they are not ready to integrate close matching as a standard feature (or whether or not that should be included at all).

Also I feel the implementation here ratcliff_obershelp_matches is polished for conda parity compared to plain Levenshtein Distance in there (mishandles typos like lsit, ifno etc.)

The design here already anticipates your vendor solution as it's essentially two pure, dependency-free, unit tested functions isolated in libmamba/util. We delete them and swap the hook if/when CLI11 promotes close matching.

Happy to open a CLI11 issue proposing this as a feature and keep working on the mamba fix as well ! Would like to know what you think ?

@jjerphan

jjerphan commented Aug 6, 2026

Copy link
Copy Markdown
Member

Happy to open a CLI11 issue proposing this as a feature and keep working on the mamba fix as well ! Would like to know what you think ?

+1 for mentioning this solution in CLIUtils/CLI11#1149 and having this implementation in this code-base for now.

@jjerphan jjerphan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

Mostly nitpicks.

f24b628 aggregates some of the suggestions.

Comment on lines +739 to +741
// A permissive cutoff matches several commands; cap to 2.
const auto matches = closest_matches("in", commands, 0.1, 2);
REQUIRE(matches.size() <= 2);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// A permissive cutoff matches several commands; cap to 2.
const auto matches = closest_matches("in", commands, 0.1, 2);
REQUIRE(matches.size() <= 2);
// A permissive cutoff matches several commands; cap to 2.
const auto matches = closest_matches("in", commands, 0.1, 2);
REQUIRE(matches.size() > 2);
const auto matches_capped = closest_matches("in", commands, 0.1, 2);
REQUIRE(matches_capped.size() <= 2);

Comment on lines +728 to +734
double previous = 1.0;
for (const auto& m : matches)
{
const double ratio = similarity_ratio(m, "instal");
REQUIRE(ratio <= previous + 1e-9);
previous = ratio;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you use std::is_sorted here?

Suggested change
double previous = 1.0;
for (const auto& m : matches)
{
const double ratio = similarity_ratio(m, "instal");
REQUIRE(ratio <= previous + 1e-9);
previous = ratio;
}
REQUIRE(std::is_sorted(matches, std::greater<double>));


SECTION("repoquery -> repoquery")
{
const auto matches = closest_matches("Repoquery", commands);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add more test cases for cases with upper case letter to specify edge-cases?

E.g. Should the similarity ratio of "Repoquery" and "repoquery" be one?


SECTION("Ratio is bounded between 0 and 1")
{
const char* words[] = { "install", "lst", "repoquery", "", "x", "activate" };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer:

Suggested change
const char* words[] = { "install", "lst", "repoquery", "", "x", "activate" };
constexpr std::array<std::string_view, 6> words = { "install", "lst", "repoquery", "", "x", "activate" };


TEST_CASE("closest_matches")
{
const std::vector<std::string> commands = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const std::vector<std::string> commands = {
constexpr std::array<std::string_view, 17> commands = {

Comment on lines +1079 to +1087
scored.reserve(candidates.size());
for (const auto& candidate : candidates)
{
const double ratio = similarity_ratio(input, candidate);
if (ratio >= cutoff)
{
scored.push_back({ ratio, &candidate });
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neatpick: We could make use of C++20 here.

namespace views = std::ranges::views;

auto scored_view = candidates
                   | views::transform(
                         [&](const std::string& candidate) -> Scored
                         { return { similarity_ratio(input, candidate), &candidate }; }
                     )
                   | views::filter([cutoff](const Scored& s) { return s.ratio >= cutoff; });

// TODO(C++23): std::ranges::to<std::vector>
auto scored = std::vector<Scored>(scored_view.begin(), scored_view.end());

Comment on lines +1104 to +1111
std::vector<std::string> results;
const std::size_t count = std::min(max_results, scored.size());
results.reserve(count);
for (std::size_t i = 0; i < count; ++i)
{
results.push_back(*scored[i].value);
}
return results;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also neatpick.

Suggested change
std::vector<std::string> results;
const std::size_t count = std::min(max_results, scored.size());
results.reserve(count);
for (std::size_t i = 0; i < count; ++i)
{
results.push_back(*scored[i].value);
}
return results;
auto results_view = scored
| views::take(max_results)
| views::transform([](const Scored& s) { return *s.value; });
// TODO(C++23): std::ranges::to<std::vector>
return std::vector<std::string>(results_view.begin(), results_view.end());

Comment on lines +1020 to +1023
if (a[i] != b[j])
{
continue;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we relax this test to be case insensitive?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release::enhancements For enhancements PRs or implementing features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BUG: typos in subcommand lead to confusing warnings

2 participants