aboutsummaryrefslogtreecommitdiff
path: root/src/libutil
diff options
context:
space:
mode:
authorEelco Dolstra <edolstra@gmail.com>2022-03-07 19:47:45 +0100
committerEelco Dolstra <edolstra@gmail.com>2022-03-07 19:47:45 +0100
commit30ddd37873a7ce0e6a8c884e03c6ebc52ea816a6 (patch)
tree28bd8f3fe7ef3d17d5cb38a9c0c64ac1b81891d0 /src/libutil
parent60a68a4fee05f0dd510afcd393463e847e3c4488 (diff)
parentdcf4f77fac8bc214afd4851b367ce386f0a34658 (diff)
Merge branch 'cli-suggestions' of https://github.com/thufschmitt/nix
Diffstat (limited to 'src/libutil')
-rw-r--r--src/libutil/args.cc9
-rw-r--r--src/libutil/error.cc7
-rw-r--r--src/libutil/error.hh8
-rw-r--r--src/libutil/suggestions.cc114
-rw-r--r--src/libutil/suggestions.hh102
-rw-r--r--src/libutil/tests/suggestions.cc43
6 files changed, 281 insertions, 2 deletions
diff --git a/src/libutil/args.cc b/src/libutil/args.cc
index f970c0e9e..69aa0d094 100644
--- a/src/libutil/args.cc
+++ b/src/libutil/args.cc
@@ -328,8 +328,13 @@ MultiCommand::MultiCommand(const Commands & commands_)
completions->add(name);
}
auto i = commands.find(s);
- if (i == commands.end())
- throw UsageError("'%s' is not a recognised command", s);
+ if (i == commands.end()) {
+ std::set<std::string> commandNames;
+ for (auto & [name, _] : commands)
+ commandNames.insert(name);
+ auto suggestions = Suggestions::bestMatches(commandNames, s);
+ throw UsageError(suggestions, "'%s' is not a recognised command", s);
+ }
command = {s, i->second()};
command->second->parent = this;
}}
diff --git a/src/libutil/error.cc b/src/libutil/error.cc
index dcd2f82a5..b2dfb35b2 100644
--- a/src/libutil/error.cc
+++ b/src/libutil/error.cc
@@ -282,6 +282,13 @@ std::ostream & showErrorInfo(std::ostream & out, const ErrorInfo & einfo, bool s
}
}
+ auto suggestions = einfo.suggestions.trim();
+ if (! suggestions.suggestions.empty()){
+ oss << "Did you mean " <<
+ suggestions.trim() <<
+ "?" << std::endl;
+ }
+
// traces
if (showTrace && !einfo.traces.empty()) {
for (auto iter = einfo.traces.rbegin(); iter != einfo.traces.rend(); ++iter) {
diff --git a/src/libutil/error.hh b/src/libutil/error.hh
index d55e1d701..600e94888 100644
--- a/src/libutil/error.hh
+++ b/src/libutil/error.hh
@@ -1,5 +1,6 @@
#pragma once
+#include "suggestions.hh"
#include "ref.hh"
#include "types.hh"
#include "fmt.hh"
@@ -112,6 +113,8 @@ struct ErrorInfo {
std::optional<ErrPos> errPos;
std::list<Trace> traces;
+ Suggestions suggestions;
+
static std::optional<std::string> programName;
};
@@ -141,6 +144,11 @@ public:
: err { .level = lvlError, .msg = hintfmt(fs, args...) }
{ }
+ template<typename... Args>
+ BaseError(const Suggestions & sug, const Args & ... args)
+ : err { .level = lvlError, .msg = hintfmt(args...), .suggestions = sug }
+ { }
+
BaseError(hintformat hint)
: err { .level = lvlError, .msg = hint }
{ }
diff --git a/src/libutil/suggestions.cc b/src/libutil/suggestions.cc
new file mode 100644
index 000000000..9510a5f0c
--- /dev/null
+++ b/src/libutil/suggestions.cc
@@ -0,0 +1,114 @@
+#include "suggestions.hh"
+#include "ansicolor.hh"
+#include "util.hh"
+#include <algorithm>
+
+namespace nix {
+
+int levenshteinDistance(std::string_view first, std::string_view second)
+{
+ // Implementation borrowed from
+ // https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows
+
+ int m = first.size();
+ int n = second.size();
+
+ auto v0 = std::vector<int>(n+1);
+ auto v1 = std::vector<int>(n+1);
+
+ for (auto i = 0; i <= n; i++)
+ v0[i] = i;
+
+ for (auto i = 0; i < m; i++) {
+ v1[0] = i+1;
+
+ for (auto j = 0; j < n; j++) {
+ auto deletionCost = v0[j+1] + 1;
+ auto insertionCost = v1[j] + 1;
+ auto substitutionCost = first[i] == second[j] ? v0[j] : v0[j] + 1;
+ v1[j+1] = std::min({deletionCost, insertionCost, substitutionCost});
+ }
+
+ std::swap(v0, v1);
+ }
+
+ return v0[n];
+}
+
+Suggestions Suggestions::bestMatches (
+ std::set<std::string> allMatches,
+ std::string query)
+{
+ std::set<Suggestion> res;
+ for (const auto & possibleMatch : allMatches) {
+ res.insert(Suggestion {
+ .distance = levenshteinDistance(query, possibleMatch),
+ .suggestion = possibleMatch,
+ });
+ }
+ return Suggestions { res };
+}
+
+Suggestions Suggestions::trim(int limit, int maxDistance) const
+{
+ std::set<Suggestion> res;
+
+ int count = 0;
+
+ for (auto & elt : suggestions) {
+ if (count >= limit || elt.distance > maxDistance)
+ break;
+ count++;
+ res.insert(elt);
+ }
+
+ return Suggestions{res};
+}
+
+std::string Suggestion::to_string() const
+{
+ return ANSI_WARNING + filterANSIEscapes(suggestion) + ANSI_NORMAL;
+}
+
+std::string Suggestions::to_string() const
+{
+ switch (suggestions.size()) {
+ case 0:
+ return "";
+ case 1:
+ return suggestions.begin()->to_string();
+ default: {
+ std::string res = "one of ";
+ auto iter = suggestions.begin();
+ res += iter->to_string(); // Iter can’t be end() because the container isn’t null
+ iter++;
+ auto last = suggestions.end(); last--;
+ for ( ; iter != suggestions.end() ; iter++) {
+ res += (iter == last) ? " or " : ", ";
+ res += iter->to_string();
+ }
+ return res;
+ }
+ }
+}
+
+Suggestions & Suggestions::operator+=(const Suggestions & other)
+{
+ suggestions.insert(
+ other.suggestions.begin(),
+ other.suggestions.end()
+ );
+ return *this;
+}
+
+std::ostream & operator<<(std::ostream & str, const Suggestion & suggestion)
+{
+ return str << suggestion.to_string();
+}
+
+std::ostream & operator<<(std::ostream & str, const Suggestions & suggestions)
+{
+ return str << suggestions.to_string();
+}
+
+}
diff --git a/src/libutil/suggestions.hh b/src/libutil/suggestions.hh
new file mode 100644
index 000000000..d54dd8e31
--- /dev/null
+++ b/src/libutil/suggestions.hh
@@ -0,0 +1,102 @@
+#pragma once
+
+#include "comparator.hh"
+#include "types.hh"
+#include <set>
+
+namespace nix {
+
+int levenshteinDistance(std::string_view first, std::string_view second);
+
+/**
+ * A potential suggestion for the cli interface.
+ */
+class Suggestion {
+public:
+ int distance; // The smaller the better
+ std::string suggestion;
+
+ std::string to_string() const;
+
+ GENERATE_CMP(Suggestion, me->distance, me->suggestion)
+};
+
+class Suggestions {
+public:
+ std::set<Suggestion> suggestions;
+
+ std::string to_string() const;
+
+ Suggestions trim(
+ int limit = 5,
+ int maxDistance = 2
+ ) const;
+
+ static Suggestions bestMatches (
+ std::set<std::string> allMatches,
+ std::string query
+ );
+
+ Suggestions& operator+=(const Suggestions & other);
+};
+
+std::ostream & operator<<(std::ostream & str, const Suggestion &);
+std::ostream & operator<<(std::ostream & str, const Suggestions &);
+
+// Either a value of type `T`, or some suggestions
+template<typename T>
+class OrSuggestions {
+public:
+ using Raw = std::variant<T, Suggestions>;
+
+ Raw raw;
+
+ T* operator ->()
+ {
+ return &**this;
+ }
+
+ T& operator *()
+ {
+ return std::get<T>(raw);
+ }
+
+ operator bool() const noexcept
+ {
+ return std::holds_alternative<T>(raw);
+ }
+
+ OrSuggestions(T t)
+ : raw(t)
+ {
+ }
+
+ OrSuggestions()
+ : raw(Suggestions{})
+ {
+ }
+
+ static OrSuggestions<T> failed(const Suggestions & s)
+ {
+ auto res = OrSuggestions<T>();
+ res.raw = s;
+ return res;
+ }
+
+ static OrSuggestions<T> failed()
+ {
+ return OrSuggestions<T>::failed(Suggestions{});
+ }
+
+ const Suggestions & getSuggestions()
+ {
+ static Suggestions noSuggestions;
+ if (const auto & suggestions = std::get_if<Suggestions>(&raw))
+ return *suggestions;
+ else
+ return noSuggestions;
+ }
+
+};
+
+}
diff --git a/src/libutil/tests/suggestions.cc b/src/libutil/tests/suggestions.cc
new file mode 100644
index 000000000..279994abc
--- /dev/null
+++ b/src/libutil/tests/suggestions.cc
@@ -0,0 +1,43 @@
+#include "suggestions.hh"
+#include <gtest/gtest.h>
+
+namespace nix {
+
+ struct LevenshteinDistanceParam {
+ std::string s1, s2;
+ int distance;
+ };
+
+ class LevenshteinDistanceTest :
+ public testing::TestWithParam<LevenshteinDistanceParam> {
+ };
+
+ TEST_P(LevenshteinDistanceTest, CorrectlyComputed) {
+ auto params = GetParam();
+
+ ASSERT_EQ(levenshteinDistance(params.s1, params.s2), params.distance);
+ ASSERT_EQ(levenshteinDistance(params.s2, params.s1), params.distance);
+ }
+
+ INSTANTIATE_TEST_SUITE_P(LevenshteinDistance, LevenshteinDistanceTest,
+ testing::Values(
+ LevenshteinDistanceParam{"foo", "foo", 0},
+ LevenshteinDistanceParam{"foo", "", 3},
+ LevenshteinDistanceParam{"", "", 0},
+ LevenshteinDistanceParam{"foo", "fo", 1},
+ LevenshteinDistanceParam{"foo", "oo", 1},
+ LevenshteinDistanceParam{"foo", "fao", 1},
+ LevenshteinDistanceParam{"foo", "abc", 3}
+ )
+ );
+
+ TEST(Suggestions, Trim) {
+ auto suggestions = Suggestions::bestMatches({"foooo", "bar", "fo", "gao"}, "foo");
+ auto onlyOne = suggestions.trim(1);
+ ASSERT_EQ(onlyOne.suggestions.size(), 1);
+ ASSERT_TRUE(onlyOne.suggestions.begin()->suggestion == "fo");
+
+ auto closest = suggestions.trim(999, 2);
+ ASSERT_EQ(closest.suggestions.size(), 3);
+ }
+}