From ee9c39b3748ae1318b8bbd250fc3cf4891f12b43 Mon Sep 17 00:00:00 2001 From: faze-geek Date: Thu, 30 Jul 2026 16:01:50 +0530 Subject: [PATCH] Suggest closest command on typo --- _typos.toml | 15 +++ libmamba/include/mamba/util/string.hpp | 26 ++++ libmamba/src/util/string.cpp | 121 +++++++++++++++++ libmamba/tests/src/util/test_string.cpp | 165 +++++++++++++++++++++++ micromamba/src/umamba.cpp | 90 +++++++++++++ micromamba/tests/test_cli_suggestions.py | 101 ++++++++++++++ 6 files changed, 518 insertions(+) create mode 100644 micromamba/tests/test_cli_suggestions.py diff --git a/_typos.toml b/_typos.toml index c8948a0839..83635cd484 100644 --- a/_typos.toml +++ b/_typos.toml @@ -6,3 +6,18 @@ Ome = "Ome" haa = "haa" "fo" = "fo" "ba" = "ba" + +# Intentional command typos +lsit = "lsit" +instal = "instal" +inof = "inof" +ifno = "ifno" +rnu = "rnu" +reqoquery = "reqoquery" +reqoqury = "reqoqury" +whoneds = "whoneds" +uninstal = "uninstal" +creat = "creat" +remov = "remov" +confog = "confog" +activ = "activ" diff --git a/libmamba/include/mamba/util/string.hpp b/libmamba/include/mamba/util/string.hpp index 9368b80262..59661083e0 100644 --- a/libmamba/include/mamba/util/string.hpp +++ b/libmamba/include/mamba/util/string.hpp @@ -375,6 +375,32 @@ namespace mamba::util ) -> typename Range::value_type; ; + /** + * Compute the similarity ratio between two strings, as Python difflib does. + * + * This reproduces ``difflib.SequenceMatcher(None, a, b).ratio()``. + * The Ratcliff/Obershelp measure ``2 * M / T`` is computed, + * where ``T`` is the total number of elements in both sequences, + * and ``M`` is the number of matches. The result lies in ``[0, 1]``. Identical + * sequences have a ratio of 1.0, and sequences with no common elements have a ratio of 0.0. + */ + [[nodiscard]] auto similarity_ratio(std::string_view a, std::string_view b) -> double; + + /** + * Return the candidate strings that are "closest" to @p input, best match first. + * + * This mirrors ``difflib.get_close_matches`` : a candidate is considered when its + * @ref similarity_ratio with @p input is greater than or equal to @p cutoff. Matches are + * sorted by decreasing similarity ratio, breaking ties by the larger candidate string first. + * At most @p max_results are returned. + */ + [[nodiscard]] auto closest_matches( + std::string_view input, + const std::vector& candidates, + double cutoff = 0.6, + std::size_t max_results = 3 + ) -> std::vector; + /************************ * Implementation misc * ************************/ diff --git a/libmamba/src/util/string.cpp b/libmamba/src/util/string.cpp index 91dfd5bb3d..42430f83d9 100644 --- a/libmamba/src/util/string.cpp +++ b/libmamba/src/util/string.cpp @@ -989,4 +989,125 @@ namespace mamba::util } } + + /************************************** + * Implementation of match / suggestion functions * + **************************************/ + + namespace + { + // Total number of matched characters between strings `a` and `b`. + // Following Python's difflib.SequenceMatcher algorithm : + // Find the longest common contiguous block, then recurse on the left and right of the + // block. + auto ratcliff_obershelp_matches(std::string_view a, std::string_view b) -> std::size_t + { + if (a.empty() || b.empty()) + { + return 0; + } + + // Longest matching block via difflib DP. + std::size_t best_a = 0; + std::size_t best_b = 0; + std::size_t best_size = 0; + std::vector j2len(b.size(), 0); + for (std::size_t i = 0; i < a.size(); ++i) + { + std::vector new_j2len(b.size(), 0); + for (std::size_t j = 0; j < b.size(); ++j) + { + if (a[i] != b[j]) + { + continue; + } + const std::size_t k = (j == 0 ? 0 : j2len[j - 1]) + 1; + new_j2len[j] = k; + if (k > best_size) + { + best_a = i - k + 1; + best_b = j - k + 1; + best_size = k; + } + } + j2len = std::move(new_j2len); + } + + if (best_size == 0) + { + return 0; + } + + const std::size_t left = ratcliff_obershelp_matches( + a.substr(0, best_a), + b.substr(0, best_b) + ); + const std::size_t right = ratcliff_obershelp_matches( + a.substr(best_a + best_size), + b.substr(best_b + best_size) + ); + return best_size + left + right; + } + } + + auto similarity_ratio(std::string_view a, std::string_view b) -> double + { + const std::size_t total = a.size() + b.size(); + if (total == 0) + { + return 1.0; + } + const std::size_t matches = ratcliff_obershelp_matches(a, b); + return (2.0 * static_cast(matches)) / static_cast(total); + } + + auto closest_matches( + std::string_view input, + const std::vector& candidates, + double cutoff, + std::size_t max_results + ) -> std::vector + { + struct Scored + { + double ratio; + const std::string* value; + }; + + // Mirror difflib.get_close_matches() implementation. + std::vector scored; + scored.reserve(candidates.size()); + for (const auto& candidate : candidates) + { + const double ratio = similarity_ratio(input, candidate); + if (ratio >= cutoff) + { + scored.push_back({ ratio, &candidate }); + } + } + + // difflib returns the n largest (ratio, candidate) pairs, so ties + // are broken by the larger candidate string. + std::stable_sort( + scored.begin(), + scored.end(), + [](const Scored& a, const Scored& b) + { + if (a.ratio != b.ratio) + { + return a.ratio > b.ratio; + } + return *a.value > *b.value; + } + ); + + std::vector 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; + } } diff --git a/libmamba/tests/src/util/test_string.cpp b/libmamba/tests/src/util/test_string.cpp index 5da857b217..bf6bb4f50e 100644 --- a/libmamba/tests/src/util/test_string.cpp +++ b/libmamba/tests/src/util/test_string.cpp @@ -605,5 +605,170 @@ namespace REQUIRE(concat_dedup_splits("test/chan", "chan/foo", "//") == "test/chan//chan/foo"); REQUIRE(concat_dedup_splits("test/chan", "chan/foo", '/') == "test/chan/foo"); } + + TEST_CASE("similarity_ratio") + { + SECTION("Identical and empty strings have ratio one") + { + REQUIRE(similarity_ratio("", "") == Catch::Approx(1.0)); + REQUIRE(similarity_ratio("install", "install") == Catch::Approx(1.0)); + } + + SECTION("No common characters have ratio zero") + { + REQUIRE(similarity_ratio("abc", "xyz") == Catch::Approx(0.0)); + REQUIRE(similarity_ratio("install", "remove") == Catch::Approx(0.0)); + } + + SECTION("Symmetry: ratio is the same regardless of order") + { + REQUIRE( + similarity_ratio("repoquery", "reqoquery") + == Catch::Approx(similarity_ratio("reqoquery", "repoquery")) + ); + } + + SECTION("Known difflib reference values") + { + REQUIRE(similarity_ratio("instal", "install") == Catch::Approx(0.923077).epsilon(1e-4)); + REQUIRE(similarity_ratio("lsit", "list") == Catch::Approx(0.75)); + REQUIRE(similarity_ratio("inof", "info") == Catch::Approx(0.75)); + REQUIRE(similarity_ratio("ifno", "info") == Catch::Approx(0.75)); + REQUIRE(similarity_ratio("rnu", "run") == Catch::Approx(0.666667).epsilon(1e-4)); + REQUIRE( + similarity_ratio("activ", "activate") == Catch::Approx(0.769231).epsilon(1e-4) + ); + REQUIRE( + similarity_ratio("reqoquery", "repoquery") == Catch::Approx(0.888889).epsilon(1e-4) + ); + REQUIRE( + similarity_ratio("whoneds", "whoneeds") == Catch::Approx(0.933333).epsilon(1e-4) + ); + REQUIRE(similarity_ratio("kitten", "sitting") == Catch::Approx(0.615385).epsilon(1e-4)); + REQUIRE(similarity_ratio("ab", "abcdef") == Catch::Approx(0.5)); + } + + SECTION("Case Sensitivity: comparison is byte-exact") + { + REQUIRE(similarity_ratio("List", "list") == Catch::Approx(0.75)); + } + + SECTION("Ratio is bounded between 0 and 1") + { + const char* words[] = { "install", "lst", "repoquery", "", "x", "activate" }; + for (const auto* w1 : words) + { + for (const auto* w2 : words) + { + const double ratio = similarity_ratio(w1, w2); + REQUIRE(ratio >= 0.0); + REQUIRE(ratio <= 1.0); + } + } + } + } + + TEST_CASE("closest_matches") + { + const std::vector commands = { + "activate", "auth", "clean", "config", "create", "env", + "info", "install", "list", "package", "ps", "remove", + "repoquery", "run", "search", "shell", "update", + }; + + SECTION("A close typo returns the intended command first") + { + // difflib.get_close_matches("instal", commands) == ["install", "list"]. + const auto matches = closest_matches("instal", commands); + REQUIRE(matches.size() == 2); + REQUIRE(matches.front() == "install"); + REQUIRE(matches[1] == "list"); + } + + SECTION("Transpositions are matched (parity with conda/difflib)") + { + REQUIRE(closest_matches("lsit", commands).front() == "list"); + REQUIRE(closest_matches("lnfo", commands).front() == "info"); + REQUIRE(closest_matches("ifno", commands).front() == "info"); + REQUIRE(closest_matches("rnu", commands).front() == "run"); + } + + SECTION("Truncated prefixes are matched") + { + // "active" -> "activate" has ratio 0.77>= 0.6. + REQUIRE(closest_matches("active", commands).front() == "activate"); + } + + SECTION("repoquery -> repoquery") + { + const auto matches = closest_matches("Repoquery", commands); + REQUIRE_FALSE(matches.empty()); + REQUIRE(matches.front() == "repoquery"); + } + + SECTION("An exact match returns itself first") + { + REQUIRE(closest_matches("list", commands).front() == "list"); + } + + SECTION("Nonsense below the cutoff yields no suggestion") + { + REQUIRE(closest_matches("zzzzzzzz", commands).empty()); + + // Short, dissimilar tokens that Levenshtein would wrongly match are rejected. + REQUIRE(closest_matches("xq", commands).empty()); + REQUIRE(closest_matches("qwerty", commands).empty()); + } + + SECTION("Results are ordered by decreasing ratio") + { + const auto matches = closest_matches("instal", commands, 0.6, 5); + REQUIRE(matches.size() >= 2); + + double previous = 1.0; + for (const auto& m : matches) + { + const double ratio = similarity_ratio(m, "instal"); + REQUIRE(ratio <= previous + 1e-9); + previous = ratio; + } + } + + SECTION("max_results caps the number of suggestions") + { + // A permissive cutoff matches several commands; cap to 2. + const auto matches = closest_matches("in", commands, 0.1, 2); + REQUIRE(matches.size() <= 2); + } + + SECTION("The cutoff controls how permissive matching is") + { + // With the default cutoff, "in" only matches "info" (difflib parity). + const auto strict = closest_matches("in", commands); + REQUIRE(strict.size() == 1); + REQUIRE(strict.front() == "info"); + + // A very low cutoff lets many more candidates through. + const auto loose = closest_matches("in", commands, 0.1, 100); + REQUIRE(loose.size() > strict.size()); + } + + SECTION("Ties are broken by the larger candidate string (difflib nlargest parity)") + { + // "ybc" has ratio 2/3 with both "abc" and "xbc"; difflib's nlargest breaks the + // tie in favour of the lexicographically larger candidate. + const std::vector tie_cands = { "abc", "xbc" }; + + const auto matches = closest_matches("ybc", tie_cands, 0.6, 2); + REQUIRE(matches.size() == 2); + REQUIRE(matches[0] == "xbc"); + REQUIRE(matches[1] == "abc"); + } + + SECTION("Empty candidate list yields no matches") + { + REQUIRE(closest_matches("list", {}).empty()); + } + } } } // namespace mamba diff --git a/micromamba/src/umamba.cpp b/micromamba/src/umamba.cpp index 97693b211f..6caf18d64d 100644 --- a/micromamba/src/umamba.cpp +++ b/micromamba/src/umamba.cpp @@ -7,6 +7,7 @@ #include "mamba/api/configuration.hpp" #include "mamba/core/channel_context.hpp" #include "mamba/core/context.hpp" +#include "mamba/util/string.hpp" #include "mamba/version.hpp" #include "common_options.hpp" @@ -15,6 +16,93 @@ using namespace mamba; // NOLINT(build/namespaces) +namespace +{ + // Collect names and aliases of all subcommands registered in the CLI app. + // Used as candidates for suggesting correction for a mistyped subcommand. + auto subcommand_names(const CLI::App* app) -> std::vector + { + std::vector names; + for (const auto& subcom : app->get_subcommands(nullptr)) + { + names.push_back(subcom->get_name()); + for (const auto& alias : subcom->get_aliases()) + { + names.push_back(alias); + } + } + return names; + } + + // Descend from CLI app through the chain of parsed subcommands to the deepest one. + auto deepest_parsed_app(const CLI::App* app) -> const CLI::App* + { + const auto parsed = app->get_subcommands(); + if (parsed.empty()) + { + return app; + } + return deepest_parsed_app(parsed.back()); + } + + // Mirror Conda's behavior of single best suggestion for a subcommand when the user mistypes it. + auto command_suggestion(const CLI::App* app, const CLI::Error& e) -> std::string + { + if (dynamic_cast(&e) == nullptr) + { + return {}; + } + + const CLI::App* parsed_app = deepest_parsed_app(app); + + // The first leftover argument that is not an option is the token we treat as a mistyped + // command. + std::string offending; + for (const auto& arg : parsed_app->remaining(false)) + { + if (!util::starts_with(arg, "-")) + { + offending = arg; + break; + } + } + if (offending.empty()) + { + return {}; + } + + // Rank the candidate commands like Conda does and return the best match if it is above the + // cutoff. + const auto matches = util::closest_matches(offending, subcommand_names(parsed_app), 0.6, 1); + if (matches.empty()) + { + return {}; + } + + return "Did you mean '" + matches.front() + "'?"; + } + + // CLI11 failure message that appends hint for mistyped subcommand. Falls back to default CLI11 + // message otherwise. + auto failure_message_with_suggestion(const CLI::App* app, const CLI::Error& e) -> std::string + { + std::string base = CLI::FailureMessage::simple(app, e); + const std::string suggestion = command_suggestion(app, e); + if (suggestion.empty()) + { + return base; + } + + const std::string what = e.what(); + if (base.rfind(what, 0) == 0) + { + return base.substr(0, what.size()) + "\n" + suggestion + base.substr(what.size()); + } + + return base + "\n" + suggestion; + } +} + void init_umamba_options(CLI::App* subcom, Configuration& config) { @@ -120,4 +208,6 @@ set_umamba_command(CLI::App* com, mamba::Configuration& config) set_repoquery_search_command(search_subcom, config); com->require_subcommand(/* min */ 0, /* max */ 1); + + com->failure_message(&failure_message_with_suggestion); } diff --git a/micromamba/tests/test_cli_suggestions.py b/micromamba/tests/test_cli_suggestions.py new file mode 100644 index 0000000000..80acced59e --- /dev/null +++ b/micromamba/tests/test_cli_suggestions.py @@ -0,0 +1,101 @@ +import subprocess + +import pytest + +from . import helpers + + +def run_umamba(*args): + """Run micromamba with the given args and return (returncode, stdout, stderr). + + Uses subprocess directly (not helpers.subprocess_run) because these commands + are expected to fail; we want to inspect the error message on stderr without + raising. + """ + umamba = helpers.get_umamba() + p = subprocess.run( + [umamba, *args], + capture_output=True, + text=True, + ) + return p.returncode, p.stdout, p.stderr + + +# Typos that should produce a "Did you mean ''?" suggestion. +SUGGESTION_CASES = [ + # Missing / extra / substituted character + ("instal", "install"), + ("installl", "install"), + ("lst", "list"), + ("uninstal", "uninstall"), + ("creat", "create"), + ("remov", "remove"), + ("confog", "config"), + # Transposed characters + ("lsit", "list"), + ("inof", "info"), + ("ifno", "info"), + ("rnu", "run"), + ("actiavte", "activate"), + ("reqoquery", "repoquery"), + # Truncated command + ("activ", "activate"), +] + + +@pytest.mark.parametrize("typo,expected", SUGGESTION_CASES) +def test_typo_suggests_closest_command(typo, expected): + rc, out, err = run_umamba(typo) + + assert rc != 0 + + # The suggestion is emitted and names the closest command. + assert f"Did you mean '{expected}'?" in err + + # The original CLI11 error is preserved. + assert "not expected" in err + + # Ordering is conda-like: suggestion appears before the generic help hint. + assert err.index(f"Did you mean '{expected}'?") < err.index("Run with --help") + + +def test_alias_is_matched(): + # 'uninstall' is registered with the alias 'remove'; + # a typo close to an alias should still be suggested. + + rc, out, err = run_umamba("uninstal", "xtensor") + assert rc != 0 + assert "Did you mean 'uninstall'?" in err + + +def test_sub_subcommand_typo_is_matched(): + # Suggestions should work for nested subcommands too. 'whoneds' is a typo of + # the 'repoquery whoneeds' subcommand. + rc, out, err = run_umamba("repoquery", "whoneds", "openmp") + assert rc != 0 + assert "Did you mean 'whoneeds'?" in err + + +# Inputs that must NOT produce a suggestion. +NO_SUGGESTION_CASES = [ + # A flag is not a subcommand typo; it must be ignored. + (["list", "--badflag"],), + # Nonsense below the difflib similarity cutoff (0.6) gets no suggestion. + (["zzzzzzzz"],), + (["xq"],), + (["qwerty"],), +] + + +@pytest.mark.parametrize("args", [c[0] for c in NO_SUGGESTION_CASES]) +def test_no_false_suggestion(args): + rc, out, err = run_umamba(*args) + assert "Did you mean" not in err + + +def test_valid_command_has_no_suggestion(): + # A correctly spelled command must never trigger the suggestion path. + rc, out, err = run_umamba("--help") + assert rc == 0 + assert "Did you mean" not in err + assert "Did you mean" not in out