Permalink
Browse files

Added fuzzy command matching into buck.

Summary:
Buck acceepts a few commands including 'build','audit', and 'test' etc.
These commands now are allowed to be entered "fuzzily" e.g.,
'bin/buck biuld' will be interpreted to be 'bin/buck build'.

Added a StringUtil.java file which has a method to compute the levenshtein
distance. This method will be called from Command.java to map a given
fuzzy command to the closest one. However, if the given command is too
far away from the closest one, no matching will be found e.g.,
'bin/buck zzzzzzz' will not matched to any of the commands.

Test Plan:
Junit test test/com/facebook/buck/util/StringUtilTest.java is added to
test that levenshtein distance was computed correctly.

Junit test test/com/facebook/buck/cli/FuzzyCommandMatchTest.java is
added to test that fuzzy commans are mapped to the expected ones.
  • Loading branch information...
1 parent b86053a commit 9a6f673fcac1094b4388368d15c19b509d9b64a8 Der-Nien Lee committed with bolinfest Aug 8, 2013
@@ -16,10 +16,14 @@
package com.facebook.buck.cli;
+import com.facebook.buck.util.Console;
+import com.facebook.buck.util.MoreStrings;
import com.google.common.base.Optional;
+import com.google.common.base.Preconditions;
import com.google.common.base.Throwables;
import java.io.IOException;
+import java.io.PrintStream;
import java.lang.reflect.InvocationTargetException;
public enum Command {
@@ -50,6 +54,14 @@
UninstallCommand.class),
;
+ /**
+ * Defines the maximum possible fuzziness of a input command. If the
+ * levenshtein distance between the fuzzy input and the closest command is
+ * larger than MAX_ERROR_RATIO * length_of_closest_command, rejects the input.
+ * The value is chosen empirically so that minor typos can be corrected.
+ */
+ public static final double MAX_ERROR_RATIO = 0.5;
+
private final String shortDescription;
private final Class<? extends CommandRunner> commandRunnerClass;
@@ -84,18 +96,55 @@ public int execute(String[] args,
}
/**
- * @return a non-empty {@link Optional} if {@code name} corresponds to a command; otherwise,
- * an empty {@link Optional}. This will return the latter if the user tries to run something
- * like {@code buck --help}.
+ * @return a non-empty {@link Optional} if {@code name} corresponds to a
+ * command or its levenshtein distance to the closest command isn't larger
+ * than {@link #MAX_ERROR_RATIO} * length_of_closest_command; otherwise, an
+ * empty {@link Optional}. This will return the latter if the user tries
+ * to run something like {@code buck --help}.
*/
- public static Optional<Command> getCommandForName(String name) {
+ public static Optional<Command> getCommandForName(String name, Console console) {
+ Preconditions.checkNotNull(name);
+ Preconditions.checkNotNull(console);
+
Command command;
try {
command = valueOf(name.toUpperCase());
} catch (IllegalArgumentException e) {
- return Optional.absent();
+ Optional<Command> fuzzyCommand = fuzzyMatch(name.toUpperCase());
+
+ if (fuzzyCommand.isPresent()) {
+ PrintStream stdErr = console.getStdErr();
+ stdErr.printf("(Cannot find command '%s', assuming command '%s'.)\n",
+ name,
+ fuzzyCommand.get().name().toLowerCase());
+ }
+
+ return fuzzyCommand;
}
+
return Optional.of(command);
}
+ private static Optional<Command> fuzzyMatch(String name) {
+ Preconditions.checkNotNull(name);
+ name = name.toUpperCase();
+
+ int minDist = Integer.MAX_VALUE;
+ Command closestCommand = null;
+
+ for (Command command : values()) {
+ int levenshteinDist = MoreStrings.getLevenshteinDistance(name, command.name());
+ if (levenshteinDist < minDist) {
+ minDist = levenshteinDist;
+ closestCommand = command;
+ }
+ }
+
+ if (closestCommand != null &&
+ ((double)minDist) / closestCommand.name().length() <= MAX_ERROR_RATIO) {
+ return Optional.of(closestCommand);
+ }
+
+ return Optional.absent();
+ }
}
@@ -231,7 +231,7 @@ public int runMainWithExitCode(File projectRoot, String... args) throws IOExcept
BuckEventBus buildEventBus = new BuckEventBus(clock);
// Find and execute command.
- Optional<Command> command = Command.getCommandForName(args[0]);
+ Optional<Command> command = Command.getCommandForName(args[0], console);
if (command.isPresent()) {
ImmutableList<BuckEventListener> eventListeners =
addEventListeners(buildEventBus,
@@ -40,4 +40,35 @@ public static String capitalize(String str) {
return "";
}
}
+
+ public static int getLevenshteinDistance(String str1, String str2) {
+ Preconditions.checkNotNull(str1);
+ Preconditions.checkNotNull(str2);
+
+ char[] arr1 = str1.toCharArray();
+ char[] arr2 = str2.toCharArray();
+ int[][] levenshteinDist = new int [arr1.length + 1][arr2.length + 1];
+
+ for (int i = 0; i <= arr1.length; i++) {
+ levenshteinDist[i][0] = i;
+ }
+
+ for (int j = 1; j <= arr2.length; j++) {
+ levenshteinDist[0][j] = j;
+ }
+
+ for (int i = 1; i <= arr1.length; i++) {
+ for (int j = 1; j <= arr2.length; j++) {
+ if (arr1[i - 1] == arr2[j - 1]) {
+ levenshteinDist[i][j] = levenshteinDist[i - 1][j - 1];
+ } else {
+ levenshteinDist[i][j] =
+ Math.min(levenshteinDist[i - 1][j] + 1,
+ Math.min(levenshteinDist[i][j - 1] + 1, levenshteinDist[i - 1][j - 1] + 1));
+ }
+ }
+ }
+
+ return levenshteinDist[arr1.length][arr2.length];
+ }
}
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2013-present Facebook, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License. You may obtain
+ * a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+package com.facebook.buck.cli;
+
+import com.facebook.buck.testutil.TestConsole;
+import com.google.common.base.Optional;
+
+import static org.junit.Assert.assertEquals;
+import org.junit.Test;
+
+/**
+ * Unit test for {@link Command}.
+ */
+public class CommandTest {
+
+ /**
+ * Testing the fuzzy command matching feature in {@link Command#getCommandForName}.
+ */
+ @Test
+ public void testFuzzyCommandMatch() {
+ TestConsole console;
+ console = new TestConsole();
+
+ assertEquals(Optional.of(Command.BUILD), Command.getCommandForName("buil", console));
+ assertEquals(Optional.of(Command.BUILD), Command.getCommandForName("biuld", console));
+ assertEquals(Optional.of(Command.AUDIT), Command.getCommandForName("audir", console));
+ assertEquals(Optional.of(Command.AUDIT), Command.getCommandForName("auditt", console));
+ assertEquals(Optional.of(Command.TEST), Command.getCommandForName("test", console));
+ assertEquals(Optional.of(Command.TEST), Command.getCommandForName("twst", console));
+ assertEquals(Optional.of(Command.TEST), Command.getCommandForName("tset", console));
+ assertEquals(Optional.of(Command.INSTALL), Command.getCommandForName("imstall", console));
+ assertEquals(Optional.of(Command.INSTALL), Command.getCommandForName("sintall", console));
+ assertEquals(Optional.of(Command.INSTALL), Command.getCommandForName("sintalle", console));
+ assertEquals(Optional.of(Command.TARGETS), Command.getCommandForName("tragets", console));
+ assertEquals(Optional.of(Command.TARGETS), Command.getCommandForName("taegers", console));
+ assertEquals(
+ "'yyyyyyy' shouldn't match any current command.",
+ Optional.absent(),
+ Command.getCommandForName("yyyyyyy", console));
+
+ // Boundary cases
+ assertEquals(
+ "'unsintakk' is of distance 4 to the closest command 'uninstall' since\n" +
+ "4 / length('uninstall') = 4 / 9 is smaller than Coomand.MAX_ERROR_RATIO (0.5),\n" +
+ "we expect it matches uninstall.\n",
+ Optional.of(Command.UNINSTALL),
+ Command.getCommandForName("unsintakk", console));
+ assertEquals(
+ "'insatkk' is of distance 4 to the closest command 'install' since\n" +
+ "4 / length('install') = 4 / 7 is larger than Coomand.MAX_ERROR_RATIO (0.5),\n" +
+ "we expect Optional.absent() gets returned.\n",
+ Optional.absent(),
+ Command.getCommandForName("insatkk", console));
+ assertEquals(
+ "'atrgest' is of distance 4 to the closest command 'targets' since\n" +
+ "4 / length('targets') = 4 / 7 is larger than Coomand.MAX_ERROR_RATIO (0.5),\n" +
+ "we expect Optional.absent() gets returned.\n",
+ Optional.absent(),
+ Command.getCommandForName("atrgest", console));
+ assertEquals(
+ "'unsintskk' is of distance 5 to the closest command 'uninstall' since\n" +
+ "5 / length('uninstall') = 5 / 9 is larger than Coomand.MAX_ERROR_RATIO (0.5),\n" +
+ "we expect Optional.absent() gets returned.\n",
+ Optional.absent(),
+ Command.getCommandForName("unsintskk", console));
+ }
+}
@@ -54,4 +54,36 @@ public void testCapitalize() {
public void testCapitalizeRejectsNull() {
MoreStrings.capitalize(null);
}
+
+ @Test
+ public void testGetLevenshteinDistance() {
+ assertEquals(
+ "The distance between '' and 'BUILD' should be 5 (e.g., 5 insertions).",
+ 5,
+ MoreStrings.getLevenshteinDistance("", "BUILD"));
+ assertEquals(
+ "'BUILD' and 'BUILD' should be identical.",
+ 0,
+ MoreStrings.getLevenshteinDistance("BUILD", "BUILD"));
+ assertEquals(
+ "The distance between 'BIULD' and 'BUILD' should be 2 (e.g., 1 deletion + 1 insertion).",
+ 2,
+ MoreStrings.getLevenshteinDistance("BIULD", "BUILD"));
+ assertEquals(
+ "The distance between 'INSTALL' and 'AUDIT' should be 7 (e.g., 5 substitutions + 2 deletions).",
+ 7,
+ MoreStrings.getLevenshteinDistance("INSTALL", "AUDIT"));
+ assertEquals(
+ "The distance between 'aaa' and 'bbbbbb' should be 6 (e.g., 3 substitutions + 3 insertions).",
+ 6,
+ MoreStrings.getLevenshteinDistance("aaa", "bbbbbb"));
+ assertEquals(
+ "The distance between 'build' and 'biuldd' should be 3 (e.g., 1 deletion + 2 insertions).",
+ 3,
+ MoreStrings.getLevenshteinDistance("build", "biuldd"));
+ assertEquals(
+ "The distance between 'test' and 'tset' should be 2 (e.g., 1 deletion + 1 insertion).",
+ 2,
+ MoreStrings.getLevenshteinDistance("test", "tset"));
+ }
}

0 comments on commit 9a6f673

Please sign in to comment.