From c175700dea01c9da6dcdc0c31e8ae74900b82642 Mon Sep 17 00:00:00 2001 From: Shaun Bruce Date: Sun, 12 Jul 2015 16:37:44 -0600 Subject: [PATCH] Better error message on unrecognized command Closes issue #1436 License: MIT Signed-off-by: Shaun Bruce --- Godeps/Godeps.json | 4 + .../levenshtein/levenshtein.go | 166 ++++++++++++++++++ .../levenshtein/levenshtein_test.go | 46 +++++ commands/cli/cmd_suggestion.go | 71 ++++++++ commands/cli/parse.go | 14 +- test/sharness/t0150-clisuggest.sh | 43 +++++ 6 files changed, 341 insertions(+), 3 deletions(-) create mode 100644 Godeps/_workspace/src/github.com/texttheater/golang-levenshtein/levenshtein/levenshtein.go create mode 100644 Godeps/_workspace/src/github.com/texttheater/golang-levenshtein/levenshtein/levenshtein_test.go create mode 100644 commands/cli/cmd_suggestion.go create mode 100755 test/sharness/t0150-clisuggest.sh diff --git a/Godeps/Godeps.json b/Godeps/Godeps.json index 71b0b9e50..1797c4f88 100644 --- a/Godeps/Godeps.json +++ b/Godeps/Godeps.json @@ -279,6 +279,10 @@ "ImportPath": "github.com/syndtr/gosnappy/snappy", "Rev": "156a073208e131d7d2e212cb749feae7c339e846" }, + { + "ImportPath": "github.com/texttheater/golang-levenshtein/levenshtein", + "Rev": "dfd657628c58d3eeaa26391097853b2473c8b94e" + }, { "ImportPath": "github.com/whyrusleeping/go-metrics", "Rev": "1cd8009604ec2238b5a71305a0ecd974066e0e16" diff --git a/Godeps/_workspace/src/github.com/texttheater/golang-levenshtein/levenshtein/levenshtein.go b/Godeps/_workspace/src/github.com/texttheater/golang-levenshtein/levenshtein/levenshtein.go new file mode 100644 index 000000000..42df438f8 --- /dev/null +++ b/Godeps/_workspace/src/github.com/texttheater/golang-levenshtein/levenshtein/levenshtein.go @@ -0,0 +1,166 @@ +package levenshtein + +import ( + "fmt" + "os" +) + +type EditOperation int + +const ( + Ins = iota + Del + Sub + Match +) + +type EditScript []EditOperation + +type MatchFunction func(rune, rune) bool + +type Options struct { + InsCost int + DelCost int + SubCost int + Matches MatchFunction +} + +// DefaultOptions is the default options: insertion cost is 1, deletion cost is +// 1, substitution cost is 2, and two runes match iff they are the same. +var DefaultOptions Options = Options{ + InsCost: 1, + DelCost: 1, + SubCost: 2, + Matches: func(sourceCharacter rune, targetCharacter rune) bool { + return sourceCharacter == targetCharacter + }, +} + +func (operation EditOperation) String() string { + if operation == Match { + return "match" + } else if operation == Ins { + return "ins" + } else if operation == Sub { + return "sub" + } + return "del" +} + +// DistanceForStrings returns the edit distance between source and target. +func DistanceForStrings(source []rune, target []rune, op Options) int { + return DistanceForMatrix(MatrixForStrings(source, target, op)) +} + +// DistanceForMatrix reads the edit distance off the given Levenshtein matrix. +func DistanceForMatrix(matrix [][]int) int { + return matrix[len(matrix)-1][len(matrix[0])-1] +} + +// MatrixForStrings generates a 2-D array representing the dynamic programming +// table used by the Levenshtein algorithm, as described e.g. here: +// http://www.let.rug.nl/kleiweg/lev/ +// The reason for putting the creation of the table into a separate function is +// that it cannot only be used for reading of the edit distance between two +// strings, but also e.g. to backtrace an edit script that provides an +// alignment between the characters of both strings. +func MatrixForStrings(source []rune, target []rune, op Options) [][]int { + // Make a 2-D matrix. Rows correspond to prefixes of source, columns to + // prefixes of target. Cells will contain edit distances. + // Cf. http://www.let.rug.nl/~kleiweg/lev/levenshtein.html + height := len(source) + 1 + width := len(target) + 1 + matrix := make([][]int, height) + + // Initialize trivial distances (from/to empty string). That is, fill + // the left column and the top row with row/column indices. + for i := 0; i < height; i++ { + matrix[i] = make([]int, width) + matrix[i][0] = i + } + for j := 1; j < width; j++ { + matrix[0][j] = j + } + + // Fill in the remaining cells: for each prefix pair, choose the + // (edit history, operation) pair with the lowest cost. + for i := 1; i < height; i++ { + for j := 1; j < width; j++ { + delCost := matrix[i-1][j] + op.DelCost + matchSubCost := matrix[i-1][j-1] + if !op.Matches(source[i-1], target[j-1]) { + matchSubCost += op.SubCost + } + insCost := matrix[i][j-1] + op.InsCost + matrix[i][j] = min(delCost, min(matchSubCost, + insCost)) + } + } + //LogMatrix(source, target, matrix) + return matrix +} + +// EditScriptForStrings returns an optimal edit script to turn source into +// target. +func EditScriptForStrings(source []rune, target []rune, op Options) EditScript { + return backtrace(len(source), len(target), + MatrixForStrings(source, target, op), op) +} + +// EditScriptForMatrix returns an optimal edit script based on the given +// Levenshtein matrix. +func EditScriptForMatrix(matrix [][]int, op Options) EditScript { + return backtrace(len(matrix[0])-1, len(matrix)-1, matrix, op) +} + +// LogMatrix outputs a visual representation of the given matrix for the given +// strings on os.Stderr. +func LogMatrix(source []rune, target []rune, matrix [][]int) { + fmt.Fprintf(os.Stderr, " ") + for _, targetRune := range target { + fmt.Fprintf(os.Stderr, " %c", targetRune) + } + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, " %2d", matrix[0][0]) + for j, _ := range target { + fmt.Fprintf(os.Stderr, " %2d", matrix[0][j+1]) + } + fmt.Fprintf(os.Stderr, "\n") + for i, sourceRune := range source { + fmt.Fprintf(os.Stderr, "%c %2d", sourceRune, matrix[i+1][0]) + for j, _ := range target { + fmt.Fprintf(os.Stderr, " %2d", matrix[i+1][j+1]) + } + fmt.Fprintf(os.Stderr, "\n") + } +} + +func backtrace(i int, j int, matrix [][]int, op Options) EditScript { + if i > 0 && matrix[i-1][j]+op.DelCost == matrix[i][j] { + return append(backtrace(i-1, j, matrix, op), Del) + } + if j > 0 && matrix[i][j-1]+op.InsCost == matrix[i][j] { + return append(backtrace(i, j-1, matrix, op), Ins) + } + if i > 0 && j > 0 && matrix[i-1][j-1]+op.SubCost == matrix[i][j] { + return append(backtrace(i-1, j-1, matrix, op), Sub) + } + if i > 0 && j > 0 && matrix[i-1][j-1] == matrix[i][j] { + return append(backtrace(i-1, j-1, matrix, op), Match) + } + return []EditOperation{} +} + +func min(a int, b int) int { + if b < a { + return b + } + return a +} + +func max(a int, b int) int { + if b > a { + return b + } + return a +} diff --git a/Godeps/_workspace/src/github.com/texttheater/golang-levenshtein/levenshtein/levenshtein_test.go b/Godeps/_workspace/src/github.com/texttheater/golang-levenshtein/levenshtein/levenshtein_test.go new file mode 100644 index 000000000..f0f61e2a4 --- /dev/null +++ b/Godeps/_workspace/src/github.com/texttheater/golang-levenshtein/levenshtein/levenshtein_test.go @@ -0,0 +1,46 @@ +package levenshtein_test + +import ( + "github.com/ipfs/go-ipfs/Godeps/_workspace/src/github.com/texttheater/golang-levenshtein/levenshtein" + "testing" +) + +var testCases = []struct { + source string + target string + distance int +}{ + {"", "a", 1}, + {"a", "aa", 1}, + {"a", "aaa", 2}, + {"", "", 0}, + {"a", "b", 2}, + {"aaa", "aba", 2}, + {"aaa", "ab", 3}, + {"a", "a", 0}, + {"ab", "ab", 0}, + {"a", "", 1}, + {"aa", "a", 1}, + {"aaa", "a", 2}, +} + +func TestLevenshtein(t *testing.T) { + for _, testCase := range testCases { + distance := levenshtein.DistanceForStrings( + []rune(testCase.source), + []rune(testCase.target), + levenshtein.DefaultOptions) + if distance != testCase.distance { + t.Log( + "Distance between", + testCase.source, + "and", + testCase.target, + "computed as", + distance, + ", should be", + testCase.distance) + t.Fail() + } + } +} diff --git a/commands/cli/cmd_suggestion.go b/commands/cli/cmd_suggestion.go new file mode 100644 index 000000000..fa03f6586 --- /dev/null +++ b/commands/cli/cmd_suggestion.go @@ -0,0 +1,71 @@ +package cli + +import ( + "sort" + "strings" + + levenshtein "github.com/ipfs/go-ipfs/Godeps/_workspace/src/github.com/texttheater/golang-levenshtein/levenshtein" + cmds "github.com/ipfs/go-ipfs/commands" +) + +// Make a custom slice that can be sorted by its levenshtein value +type suggestionSlice []*suggestion + +type suggestion struct { + cmd string + levenshtein int +} + +func (s suggestionSlice) Len() int { + return len(s) +} + +func (s suggestionSlice) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} + +func (s suggestionSlice) Less(i, j int) bool { + return s[i].levenshtein < s[j].levenshtein +} + +func suggestUnknownCmd(args []string, root *cmds.Command) []string { + arg := args[0] + var suggestions []string + sortableSuggestions := make(suggestionSlice, 0) + var sFinal []string + const MIN_LEVENSHTEIN = 3 + + var options levenshtein.Options = levenshtein.Options{ + InsCost: 1, + DelCost: 3, + SubCost: 2, + Matches: func(sourceCharacter rune, targetCharacter rune) bool { + return sourceCharacter == targetCharacter + }, + } + + // Start with a simple strings.Contains check + for name, _ := range root.Subcommands { + if strings.Contains(arg, name) { + suggestions = append(suggestions, name) + } + } + + // If the string compare returns a match, return + if len(suggestions) > 0 { + return suggestions + } + + for name, _ := range root.Subcommands { + lev := levenshtein.DistanceForStrings([]rune(arg), []rune(name), options) + if lev <= MIN_LEVENSHTEIN { + sortableSuggestions = append(sortableSuggestions, &suggestion{name, lev}) + } + } + sort.Sort(sortableSuggestions) + + for _, j := range sortableSuggestions { + sFinal = append(sFinal, j.cmd) + } + return sFinal +} diff --git a/commands/cli/parse.go b/commands/cli/parse.go index 193f07741..04aa6ed2a 100644 --- a/commands/cli/parse.go +++ b/commands/cli/parse.go @@ -41,7 +41,7 @@ func Parse(input []string, stdin *os.File, root *cmds.Command) (cmds.Request, *c } } - stringArgs, fileArgs, err := parseArgs(stringVals, stdin, cmd.Arguments, recursive) + stringArgs, fileArgs, err := parseArgs(stringVals, stdin, cmd.Arguments, recursive, root) if err != nil { return req, cmd, path, err } @@ -196,7 +196,7 @@ func parseOpts(args []string, root *cmds.Command) ( return } -func parseArgs(inputs []string, stdin *os.File, argDefs []cmds.Argument, recursive bool) ([]string, []files.File, error) { +func parseArgs(inputs []string, stdin *os.File, argDefs []cmds.Argument, recursive bool, root *cmds.Command) ([]string, []files.File, error) { // ignore stdin on Windows if runtime.GOOS == "windows" { stdin = nil @@ -231,7 +231,15 @@ func parseArgs(inputs []string, stdin *os.File, argDefs []cmds.Argument, recursi // and the last arg definition is not variadic (or there are no definitions), return an error notVariadic := len(argDefs) == 0 || !argDefs[len(argDefs)-1].Variadic if notVariadic && len(inputs) > len(argDefs) { - return nil, nil, fmt.Errorf("Expected %v arguments, got %v: %v", len(argDefs), len(inputs), inputs) + suggestions := suggestUnknownCmd(inputs, root) + + if len(suggestions) > 1 { + return nil, nil, fmt.Errorf("Unknown Command \"%s\"\n\nDid you mean any of these?\n\n\t%s", inputs[0], strings.Join(suggestions, "\n\t")) + } else if len(suggestions) > 0 { + return nil, nil, fmt.Errorf("Unknown Command \"%s\"\n\nDid you mean this?\n\n\t%s", inputs[0], suggestions[0]) + } else { + return nil, nil, fmt.Errorf("Unknown Command \"%s\"\n", inputs[0]) + } } stringArgs := make([]string, 0, numInputs) diff --git a/test/sharness/t0150-clisuggest.sh b/test/sharness/t0150-clisuggest.sh new file mode 100755 index 000000000..a0432ce0c --- /dev/null +++ b/test/sharness/t0150-clisuggest.sh @@ -0,0 +1,43 @@ +#!/bin/sh + +test_description="Test ipfs cli cmd suggest" + +. lib/test-lib.sh + +test_suggest() { + + + test_expect_success "test command fails" ' + test_must_fail ipfs kog 2>actual + ' + + test_expect_success "test one command is suggested" ' + grep "Did you mean this?" actual && + grep "log" actual || + test_fsh cat actual + ' + + test_expect_success "test command fails" ' + test_must_fail ipfs lis 2>actual + ' + + test_expect_success "test multiple commands are suggested" ' + grep "Did you mean any of these?" actual && + grep "ls" actual && + grep "id" actual || + test_fsh cat actual + ' + +} + +test_init_ipfs + +test_suggest + +test_launch_ipfs_daemon + +test_suggest + +test_kill_ipfs_daemon + +test_done