Page MenuHomePhabricator

D16332.id39268.diff
No OneTemporary

D16332.id39268.diff

diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php
--- a/src/__phutil_library_map__.php
+++ b/src/__phutil_library_map__.php
@@ -105,6 +105,8 @@
'PhutilArgumentSpecification' => 'parser/argument/PhutilArgumentSpecification.php',
'PhutilArgumentSpecificationException' => 'parser/argument/exception/PhutilArgumentSpecificationException.php',
'PhutilArgumentSpecificationTestCase' => 'parser/argument/__tests__/PhutilArgumentSpecificationTestCase.php',
+ 'PhutilArgumentSpellingCorrector' => 'parser/argument/PhutilArgumentSpellingCorrector.php',
+ 'PhutilArgumentSpellingCorrectorTestCase' => 'parser/argument/__tests__/PhutilArgumentSpellingCorrectorTestCase.php',
'PhutilArgumentUsageException' => 'parser/argument/exception/PhutilArgumentUsageException.php',
'PhutilArgumentWorkflow' => 'parser/argument/workflow/PhutilArgumentWorkflow.php',
'PhutilArray' => 'utils/PhutilArray.php',
@@ -656,6 +658,8 @@
'PhutilArgumentSpecification' => 'Phobject',
'PhutilArgumentSpecificationException' => 'PhutilArgumentParserException',
'PhutilArgumentSpecificationTestCase' => 'PhutilTestCase',
+ 'PhutilArgumentSpellingCorrector' => 'Phobject',
+ 'PhutilArgumentSpellingCorrectorTestCase' => 'PhutilTestCase',
'PhutilArgumentUsageException' => 'PhutilArgumentParserException',
'PhutilArgumentWorkflow' => 'Phobject',
'PhutilArray' => array(
diff --git a/src/console/view/PhutilConsoleList.php b/src/console/view/PhutilConsoleList.php
--- a/src/console/view/PhutilConsoleList.php
+++ b/src/console/view/PhutilConsoleList.php
@@ -4,6 +4,7 @@
private $items = array();
private $wrap = true;
+ private $bullet = '-';
public function addItem($item) {
$this->items[] = $item;
@@ -21,19 +22,38 @@
return $this->items;
}
+ public function setBullet($bullet) {
+ $this->bullet = $bullet;
+ return $this;
+ }
+
+ public function getBullet() {
+ return $this->bullet;
+ }
+
public function setWrap($wrap) {
$this->wrap = $wrap;
return $this;
}
protected function drawView() {
+ $indent_depth = 6;
+ $indent_string = str_repeat(' ', $indent_depth);
+
+ if ($this->bullet !== null) {
+ $bullet = $this->bullet.' ';
+ $indent_depth = $indent_depth + phutil_utf8_console_strlen($bullet);
+ } else {
+ $bullet = '';
+ }
+
$output = array();
foreach ($this->getItems() as $item) {
if ($this->wrap) {
- $item = phutil_console_wrap($item, 8);
+ $item = phutil_console_wrap($item, $indent_depth);
}
- $item = ' - '.$item;
- $output[] = $item;
+
+ $output[] = $indent_string.$bullet.$item;
}
return $this->drawLines($output);
diff --git a/src/parser/argument/PhutilArgumentParser.php b/src/parser/argument/PhutilArgumentParser.php
--- a/src/parser/argument/PhutilArgumentParser.php
+++ b/src/parser/argument/PhutilArgumentParser.php
@@ -114,6 +114,10 @@
* @task parse
*/
public function parsePartial(array $specs) {
+ return $this->parseInternal($specs, false);
+ }
+
+ private function parseInternal(array $specs, $correct_spelling) {
$specs = PhutilArgumentSpecification::newSpecsFromList($specs);
$this->mergeSpecs($specs);
@@ -126,6 +130,7 @@
for ($ii = 0; $ii < $len; $ii++) {
$arg = $argv[$ii];
$map = null;
+ $options = null;
if (!is_string($arg)) {
// Non-string argument; pass it through as-is.
} else if ($arg == '--') {
@@ -138,6 +143,7 @@
$pre = '--';
$arg = substr($arg, 2);
$map = $specs_by_name;
+ $options = array_keys($specs_by_name);
} else if (!strncmp('-', $arg, 1) && strlen($arg) > 1) {
$pre = '-';
$arg = substr($arg, 1);
@@ -151,6 +157,27 @@
list($arg, $val) = $parts;
}
+ // Try to correct flag spelling for full flags, to allow users to make
+ // minor mistakes.
+ if ($correct_spelling && $options && !isset($map[$arg])) {
+ $corrections = PhutilArgumentSpellingCorrector::newFlagCorrector()
+ ->correctSpelling($arg, $options);
+
+ if (count($corrections) == 1) {
+ $corrected = head($corrections);
+
+ $this->logMessage(
+ tsprintf(
+ "%s\n",
+ pht(
+ '(Assuming "%s" is the British spelling of "%s".)',
+ $pre.$arg,
+ $corrected)));
+
+ $arg = $corrected;
+ }
+ }
+
if (isset($map[$arg])) {
$spec = $map[$arg];
unset($argv[$ii]);
@@ -252,7 +279,7 @@
* @task parse
*/
public function parseFull(array $specs) {
- $this->parsePartial($specs);
+ $this->parseInternal($specs, true);
if (count($this->argv)) {
$arg = head($this->argv);
@@ -357,25 +384,26 @@
}
$flow = array_shift($argv);
- $flow = strtolower($flow);
if (empty($this->workflows[$flow])) {
- $workflow_names = array();
- foreach ($this->workflows as $wf) {
- $workflow_names[] = $wf->getName();
- }
- sort($workflow_names);
- $command_list = implode(', ', $workflow_names);
- $ex_msg = pht(
- "Invalid command '%s'. Valid commands are: %s.",
- $flow,
- $command_list);
- if (in_array('help', $workflow_names)) {
- $bin = basename($this->bin);
- $ex_msg .= "\n".pht(
- 'For more details on available commands, run `%s`.', "{$bin} help");
+ $corrected = PhutilArgumentSpellingCorrector::newCommandCorrector()
+ ->correctSpelling($flow, array_keys($this->workflows));
+
+ if (count($corrected) == 1) {
+ $corrected = head($corrected);
+
+ $this->logMessage(
+ tsprintf(
+ "%s\n",
+ pht(
+ '(Assuming "%s" is the British spelling of "%s".)',
+ $flow,
+ $corrected)));
+
+ $flow = $corrected;
+ } else {
+ $this->raiseUnknownWorkflow($flow, $corrected);
}
- throw new PhutilArgumentUsageException($ex_msg);
}
$workflow = $this->workflows[$flow];
@@ -669,9 +697,17 @@
}
public function printUsageException(PhutilArgumentUsageException $ex) {
- fwrite(
- STDERR,
- $this->format("**%s** %s\n", pht('Usage Exception:'), $ex->getMessage()));
+ $message = tsprintf(
+ "**%s** %B\n",
+ pht('Usage Exception:'),
+ $ex->getMessage());
+
+ $this->logMessage($message);
+ }
+
+
+ private function logMessage($message) {
+ fwrite(STDERR, $message);
}
@@ -831,4 +867,45 @@
return self::$traceModeEnabled;
}
+ private function raiseUnknownWorkflow($flow, array $maybe) {
+ if ($maybe) {
+ sort($maybe);
+
+ $maybe_list = id(new PhutilConsoleList())
+ ->setWrap(false)
+ ->setBullet(null)
+ ->addItems($maybe)
+ ->drawConsoleString();
+
+ $message = tsprintf(
+ "%B\n%B",
+ pht(
+ 'Invalid command "%s". Did you mean:',
+ $flow),
+ $maybe_list);
+ } else {
+ $names = mpull($this->workflows, 'getName');
+ sort($names);
+
+ $message = tsprintf(
+ '%B',
+ pht(
+ 'Invalid command "%s". Valid commands are: %s.',
+ $flow,
+ implode(', ', $names)));
+ }
+
+ if (isset($this->workflows['help'])) {
+ $binary = basename($this->bin);
+ $message = tsprintf(
+ "%B\n%s",
+ $message,
+ pht(
+ 'For details on available commands, run `%s`.',
+ "{$binary} help"));
+ }
+
+ throw new PhutilArgumentUsageException($message);
+ }
+
}
diff --git a/src/parser/argument/PhutilArgumentSpellingCorrector.php b/src/parser/argument/PhutilArgumentSpellingCorrector.php
new file mode 100644
--- /dev/null
+++ b/src/parser/argument/PhutilArgumentSpellingCorrector.php
@@ -0,0 +1,132 @@
+<?php
+
+final class PhutilArgumentSpellingCorrector extends Phobject {
+
+ private $editDistanceMatrix;
+ private $maximumDistance;
+
+ /**
+ * Build a new corrector with parameters for correcting commands, like
+ * fixing "dfif" into "diff" in "arc diff".
+ *
+ * @return PhutilArgumentSpellingCorrector Configured corrector.
+ */
+ public static function newCommandCorrector() {
+ // When correcting argument spelling, we're relatively liberal about
+ // selecting alternatives and allow multiple mistakes.
+ $max_distance = 2;
+
+ // Adjust to the scaled edit costs we use below, so "2" roughly means
+ // "2 edits".
+ $max_distance = $max_distance * 3;
+
+ // These costs are somewhat made up, but the theory is that it is far more
+ // likely you will mis-strike a key ("lans" for "land") or press two keys
+ // out of order ("alnd" for "land") than omit keys or press extra keys.
+ $matrix = id(new PhutilEditDistanceMatrix())
+ ->setInsertCost(4)
+ ->setDeleteCost(4)
+ ->setReplaceCost(3)
+ ->setTransposeCost(2);
+
+ return id(new self())
+ ->setEditDistanceMatrix($matrix)
+ ->setMaximumDistance($max_distance);
+ }
+
+
+ /**
+ * Build a new corrector with parameters for correcting flags, like
+ * fixing "--nolint" into "--no-lint".
+ *
+ * @return PhutilArgumentSpellingCorrector Configured corrector.
+ */
+ public static function newFlagCorrector() {
+ // When correcting flag spelling, we're stricter than we are when
+ // correcting command spelling: we allow only one inserted or deleted
+ // character. It is mainly to handle cases like "--no-lint" versus
+ // "--nolint" or "--reviewer" versus "--reviewers".
+ $max_distance = 1;
+
+ $matrix = id(new PhutilEditDistanceMatrix())
+ ->setInsertCost(1)
+ ->setDeleteCost(1)
+ ->setReplaceCost(10);
+
+ return id(new self())
+ ->setEditDistanceMatrix($matrix)
+ ->setMaximumDistance($max_distance);
+ }
+
+ public function setEditDistanceMatrix(PhutilEditDistanceMatrix $matrix) {
+ $this->editDistanceMatrix = $matrix;
+ return $this;
+ }
+
+ public function getEditDistanceMatrix() {
+ return $this->editDistanceMatrix;
+ }
+
+ public function setMaximumDistance($maximum_distance) {
+ $this->maximumDistance = $maximum_distance;
+ return $this;
+ }
+
+ public function getMaximumDistance() {
+ return $this->maximumDistance;
+ }
+
+ public function correctSpelling($input, array $options) {
+ $matrix = $this->getEditDistanceMatrix();
+ if (!$matrix) {
+ throw new PhutilInvalidStateException('setEditDistanceMatrix');
+ }
+
+ $max_distance = $this->getMaximumDistance();
+ if (!$max_distance) {
+ throw new PhutilInvalidStateException('setMaximumDistance');
+ }
+
+ $input = $this->normalizeString($input);
+ foreach ($options as $key => $option) {
+ $options[$key] = $this->normalizeString($option);
+ }
+
+ $distances = array();
+ $inputv = phutil_utf8v($input);
+ foreach ($options as $option) {
+ $optionv = phutil_utf8v($option);
+ $matrix->setSequences($optionv, $inputv);
+ $distances[$option] = $matrix->getEditDistance();
+ }
+
+ asort($distances);
+ $best = min($max_distance, head($distances));
+ foreach ($distances as $option => $distance) {
+ if ($distance > $best) {
+ unset($distances[$option]);
+ }
+ }
+
+ // Before filtering, check if we have multiple equidistant matches and
+ // return them if we do. This prevents us from, e.g., matching "alnd" with
+ // both "land" and "amend", then dropping "land" for being too short, and
+ // incorrectly completing to "amend".
+ if (count($distances) > 1) {
+ return array_keys($distances);
+ }
+
+ foreach ($distances as $option => $distance) {
+ if (phutil_utf8_strlen($option) < $distance) {
+ unset($distances[$option]);
+ }
+ }
+
+ return array_keys($distances);
+ }
+
+ private function normalizeString($string) {
+ return phutil_utf8_strtolower($string);
+ }
+
+}
diff --git a/src/parser/argument/__tests__/PhutilArgumentSpellingCorrectorTestCase.php b/src/parser/argument/__tests__/PhutilArgumentSpellingCorrectorTestCase.php
new file mode 100644
--- /dev/null
+++ b/src/parser/argument/__tests__/PhutilArgumentSpellingCorrectorTestCase.php
@@ -0,0 +1,94 @@
+<?php
+
+final class PhutilArgumentSpellingCorrectorTestCase extends PhutilTestCase {
+
+ public function testCommandCorrection() {
+ $this->assertCommandCorrection(
+ array('land'),
+ 'alnd',
+ array('land', 'amend'));
+
+ $this->assertCommandCorrection(
+ array('branch'),
+ 'brnach',
+ array('branch', 'browse'));
+
+ $this->assertCommandCorrection(
+ array(),
+ 'test',
+ array('list', 'unit'));
+
+ $this->assertCommandCorrection(
+ array('list'),
+ 'lists',
+ array('list'));
+
+ $this->assertCommandCorrection(
+ array('diff'),
+ 'dfif',
+ array('diff'));
+
+ $this->assertCommandCorrection(
+ array('unit'),
+ 'uint',
+ array('unit', 'lint', 'list'));
+
+ $this->assertCommandCorrection(
+ array('list', 'lint'),
+ 'nilt',
+ array('unit', 'lint', 'list'));
+ }
+
+ private function assertCommandCorrection($expect, $input, $commands) {
+ $result = PhutilArgumentSpellingCorrector::newCommandCorrector()
+ ->correctSpelling($input, $commands);
+
+ sort($result);
+ sort($expect);
+
+ $commands = implode(', ', $commands);
+
+ $this->assertEqual(
+ $expect,
+ $result,
+ pht('Correction of %s against: %s', $input, $commands));
+ }
+
+ public function testFlagCorrection() {
+ $this->assertFlagCorrection(
+ array('nolint'),
+ 'no-lint',
+ array('nolint', 'nounit'));
+
+ $this->assertFlagCorrection(
+ array('reviewers'),
+ 'reviewer',
+ array('reviewers', 'cc'));
+
+ $this->assertFlagCorrection(
+ array(),
+ 'onlint',
+ array('nolint'));
+
+ $this->assertFlagCorrection(
+ array(),
+ 'nolind',
+ array('nolint'));
+ }
+
+ private function assertFlagCorrection($expect, $input, $flags) {
+ $result = PhutilArgumentSpellingCorrector::newFlagCorrector()
+ ->correctSpelling($input, $flags);
+
+ sort($result);
+ sort($expect);
+
+ $flags = implode(', ', $flags);
+
+ $this->assertEqual(
+ $expect,
+ $result,
+ pht('Correction of %s against: %s', $input, $flags));
+ }
+
+}

File Metadata

Mime Type
text/plain
Expires
Fri, Mar 28, 4:44 PM (1 w, 4 d ago)
Storage Engine
blob
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
7706037
Default Alt Text
D16332.id39268.diff (14 KB)

Event Timeline