diff --git a/Source/cmStringAlgorithms.cxx b/Source/cmStringAlgorithms.cxx index e13c22691b..e5f303210f 100644 --- a/Source/cmStringAlgorithms.cxx +++ b/Source/cmStringAlgorithms.cxx @@ -7,6 +7,7 @@ #include // IWYU pragma: keep #include #include +#include #include "cmsys/String.h" @@ -86,6 +87,73 @@ std::string cmEscapeQuotes(cm::string_view str) return result; } +std::size_t cmLevenshteinDistance(cm::string_view a, cm::string_view b) +{ + if (a == b) { + return 0; + } + + std::size_t const aSize = a.size(); + std::size_t const bSize = b.size(); + if (aSize == 0) { + return bSize; + } + if (bSize == 0) { + return aSize; + } + + std::vector previous(bSize + 1, 0); + std::vector current(bSize + 1, 0); + + for (std::size_t j = 0; j <= bSize; ++j) { + previous[j] = j; + } + + for (std::size_t i = 1; i <= aSize; ++i) { + current[0] = i; + for (std::size_t j = 1; j <= bSize; ++j) { + std::size_t const substitutionCost = (a[i - 1] == b[j - 1]) ? 0 : 1; + current[j] = std::min({ previous[j] + 1, current[j - 1] + 1, + previous[j - 1] + substitutionCost }); + } + previous.swap(current); + } + + return previous[bSize]; +} + +std::string cmFindClosestString(cm::string_view input, + std::vector const& candidates) +{ + if (candidates.empty()) { + return std::string(); + } + + std::string best; + std::size_t bestDistance = std::numeric_limits::max(); + for (std::string const& candidate : candidates) { + std::size_t const distance = cmLevenshteinDistance(input, candidate); + if (distance < bestDistance) { + bestDistance = distance; + best = candidate; + } + } + + // Scale the acceptable edit distance with the input length (roughly one + // typo per 5 characters), but keep it within [kMinDistance, kMaxDistance] + // so very short inputs still allow one edit and very long inputs don't + // start accepting barely-related matches. + std::size_t const kMinDistance = 1; + std::size_t const kMaxDistance = 4; + std::size_t const maxDistance = + std::min(kMaxDistance, std::max(kMinDistance, input.size() / 5 + 1)); + if (bestDistance > maxDistance) { + return std::string(); + } + + return best; +} + namespace { template inline void MakeDigits(cm::string_view& view, char (&digits)[N], diff --git a/Source/cmStringAlgorithms.h b/Source/cmStringAlgorithms.h index 9e846d5137..23b6b82601 100644 --- a/Source/cmStringAlgorithms.h +++ b/Source/cmStringAlgorithms.h @@ -64,6 +64,13 @@ std::string cmRemoveQuotes(cm::string_view str); /** Escape quotes in a string. */ std::string cmEscapeQuotes(cm::string_view str); +/** Compute the Levenshtein distance between two strings. */ +std::size_t cmLevenshteinDistance(cm::string_view a, cm::string_view b); + +/** Return the closest candidate string to the given input. */ +std::string cmFindClosestString(cm::string_view input, + std::vector const& candidates); + /** Joins elements of a range with separator into a single string. */ template std::string cmJoin(Range const& rng, cm::string_view separator) diff --git a/Tests/CMakeLib/testStringAlgorithms.cxx b/Tests/CMakeLib/testStringAlgorithms.cxx index 8a3167749c..0739fa08e8 100644 --- a/Tests/CMakeLib/testStringAlgorithms.cxx +++ b/Tests/CMakeLib/testStringAlgorithms.cxx @@ -313,5 +313,36 @@ int testStringAlgorithms(int /*unused*/, char* /*unused*/[]) "cmStrLen returns length of empty literal string"); } + // ---------------------------------------------------------------------- + // Test cmLevenshteinDistance + { + assert_ok(cmLevenshteinDistance("", "") == 0, + "cmLevenshteinDistance empty strings"); + assert_ok(cmLevenshteinDistance("abc", "abc") == 0, + "cmLevenshteinDistance identical strings"); + assert_ok(cmLevenshteinDistance("", "abc") == 3, + "cmLevenshteinDistance empty vs non-empty"); + assert_ok(cmLevenshteinDistance("abc", "") == 3, + "cmLevenshteinDistance non-empty vs empty"); + assert_ok(cmLevenshteinDistance("toolchain", "tolchain") == 1, + "cmLevenshteinDistance single deletion"); + assert_ok(cmLevenshteinDistance("kitten", "sitting") == 3, + "cmLevenshteinDistance words"); + } + + // ---------------------------------------------------------------------- + // Test cmFindClosestString + { + std::vector const candidates = { "--build", "--install", + "--toolchain", + "--install-prefix" }; + assert_string(cmFindClosestString("--toolchan", candidates), "--toolchain", + "cmFindClosestString finds close match"); + assert_string(cmFindClosestString("--totally-unrelated", candidates), "", + "cmFindClosestString rejects distant strings"); + assert_string(cmFindClosestString("anything", {}), "", + "cmFindClosestString empty candidates"); + } + return failed; }