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, + $pre.$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 @@ +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 @@ +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)); + } + +}