Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F15389631
D16332.id39268.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
14 KB
Referenced Files
None
Subscribers
None
D16332.id39268.diff
View Options
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
Details
Attached
Mime Type
text/plain
Expires
Sun, Mar 16, 5:31 AM (3 w, 3 d ago)
Storage Engine
blob
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
7706037
Default Alt Text
D16332.id39268.diff (14 KB)
Attached To
Mode
D16332: Move command spelling correction to libphutil
Attached
Detach File
Event Timeline
Log In to Comment