Browse Source

Merge pull request #10974 from HavokInspiration/3.next-shell-unknown-tokens

Shell : improve unknown tokens error messages
Mark Story 8 years ago
parent
commit
4ab9aa9114

+ 136 - 11
src/Console/ConsoleOptionParser.php

@@ -731,6 +731,18 @@ class ConsoleOptionParser
      */
     public function help($subcommand = null, $format = 'text', $width = 72)
     {
+        if ($subcommand === null) {
+            $formatter = new HelpFormatter($this);
+            $formatter->setAlias($this->rootName);
+
+            if ($format === 'text') {
+                return $formatter->text($width);
+            }
+            if ($format === 'xml') {
+                return $formatter->xml();
+            }
+        }
+
         if (isset($this->_subcommands[$subcommand])) {
             $command = $this->_subcommands[$subcommand];
             $subparser = $command->parser();
@@ -746,15 +758,7 @@ class ConsoleOptionParser
             return $subparser->help(null, $format, $width);
         }
 
-        $formatter = new HelpFormatter($this);
-        $formatter->setAlias($this->rootName);
-
-        if ($format === 'text') {
-            return $formatter->text($width);
-        }
-        if ($format === 'xml') {
-            return $formatter->xml();
-        }
+        return $this->getCommandError($subcommand);
     }
 
     /**
@@ -783,6 +787,127 @@ class ConsoleOptionParser
     }
 
     /**
+     * Get the message output in the console stating that the command can not be found and tries to guess what the user
+     * wanted to say. Output a list of available subcommands as well.
+     *
+     * @param string $command Unknown command name trying to be dispatched.
+     * @return string The message to be displayed in the console.
+     */
+    protected function getCommandError($command)
+    {
+        $rootCommand = $this->getCommand();
+        $subcommands = array_keys((array)$this->subcommands());
+        $bestGuess = $this->findClosestItem($command, $subcommands);
+
+        $out = [
+            sprintf(
+                'Unable to find the `%s %s` subcommand. See `bin/%s %s --help`.',
+                $rootCommand,
+                $command,
+                $this->rootName,
+                $rootCommand
+            ),
+            ''
+        ];
+
+        if ($bestGuess !== null) {
+            $out[] = sprintf('Did you mean : `%s %s` ?', $rootCommand, $bestGuess);
+            $out[] = '';
+        }
+        $out[] = sprintf('Available subcommands for the `%s` command are : ', $rootCommand);
+        $out[] = '';
+        foreach ($subcommands as $subcommand) {
+            $out[] = ' - ' . $subcommand;
+        }
+
+        return implode("\n", $out);
+    }
+
+    /**
+     * Get the message output in the console stating that the option can not be found and tries to guess what the user
+     * wanted to say. Output a list of available options as well.
+     *
+     * @param string $option Unknown option name trying to be used.
+     * @return string The message to be displayed in the console.
+     */
+    protected function getOptionError($option)
+    {
+        $availableOptions = array_keys($this->_options);
+        $bestGuess = $this->findClosestItem($option, $availableOptions);
+        $out = [
+            sprintf('Unknown option `%s`.', $option),
+            ''
+        ];
+
+        if ($bestGuess !== null) {
+            $out[] = sprintf('Did you mean `%s` ?', $bestGuess);
+            $out[] = '';
+        }
+
+        $out[] = 'Available options are :';
+        $out[] = '';
+        foreach ($availableOptions as $availableOption) {
+            $out[] = ' - ' . $availableOption;
+        }
+
+        return implode("\n", $out);
+    }
+
+    /**
+     * Get the message output in the console stating that the short option can not be found. Output a list of available
+     * short options and what option they refer to as well.
+     *
+     * @param string $option Unknown short option name trying to be used.
+     * @return string The message to be displayed in the console.
+     */
+    protected function getShortOptionError($option)
+    {
+        $out = [sprintf('Unknown short option `%s`', $option)];
+        $out[] = '';
+        $out[] = 'Available short options are :';
+        $out[] = '';
+
+        foreach ($this->_shortOptions as $short => $long) {
+            $out[] = sprintf(' - `%s` (short for `--%s`)', $short, $long);
+        }
+
+        return implode("\n", $out);
+    }
+
+    /**
+     * Tries to guess the item name the user originally wanted using the some regex pattern and the levenshtein
+     * algorithm.
+     *
+     * @param string $needle Unknown item (either a subcommand name or an option for instance) trying to be used.
+     * @param array $haystack List of items available for the type $needle belongs to.
+     * @return string|null The closest name to the item submitted by the user.
+     */
+    protected function findClosestItem($needle, $haystack)
+    {
+        $bestGuess = null;
+        foreach ($haystack as $item) {
+            if (preg_match('/^' . $needle . '/', $item)) {
+                return $item;
+            }
+        }
+
+        foreach ($haystack as $item) {
+            if (preg_match('/' . $needle . '/', $item)) {
+                return $item;
+            }
+
+            $score = levenshtein($needle, $item);
+
+            if (!isset($bestScore) || $score < $bestScore) {
+                $bestScore = $score;
+                $bestGuess = $item;
+            }
+        }
+
+        return $bestGuess;
+    }
+
+    /**
      * Parse the value for a long option out of $this->_tokens. Will handle
      * options with an `=` in them.
      *
@@ -822,7 +947,7 @@ class ConsoleOptionParser
             }
         }
         if (!isset($this->_shortOptions[$key])) {
-            throw new ConsoleException(sprintf('Unknown short option `%s`', $key));
+            throw new ConsoleException($this->getShortOptionError($key));
         }
         $name = $this->_shortOptions[$key];
 
@@ -840,7 +965,7 @@ class ConsoleOptionParser
     protected function _parseOption($name, $params)
     {
         if (!isset($this->_options[$name])) {
-            throw new ConsoleException(sprintf('Unknown option `%s`', $name));
+            throw new ConsoleException($this->getOptionError($name));
         }
         $option = $this->_options[$name];
         $isBoolean = $option->isBoolean();

+ 4 - 2
src/Console/Shell.php

@@ -459,7 +459,6 @@ class Shell
             list($this->params, $this->args) = $this->OptionParser->parse($argv);
         } catch (ConsoleException $e) {
             $this->err('Error: ' . $e->getMessage());
-            $this->out($this->OptionParser->help($command));
 
             return false;
         }
@@ -507,7 +506,7 @@ class Shell
             return $this->main(...$this->args);
         }
 
-        $this->out($this->OptionParser->help($command));
+        $this->err($this->OptionParser->help($command));
 
         return false;
     }
@@ -549,6 +548,9 @@ class Shell
             $this->_welcome();
         }
 
+        $subcommands = $this->OptionParser->subcommands();
+        $command = isset($subcommands[$command]) ? $command : null;
+
         return $this->out($this->OptionParser->help($command, $format));
     }
 

+ 47 - 1
tests/TestCase/Console/ConsoleOptionParserTest.php

@@ -359,6 +359,8 @@ class ConsoleOptionParserTest extends TestCase
      * test parsing options that do not exist.
      *
      * @expectedException \Cake\Console\Exception\ConsoleException
+     * @expectedExceptionMessageRegexp /Unknown option `fail`.\n\nDid you mean `help` \?\n\nAvailable options are :\n\n
+     * - help\n - no-commit/
      * @return void
      */
     public function testOptionThatDoesNotExist()
@@ -373,12 +375,16 @@ class ConsoleOptionParserTest extends TestCase
      * test parsing short options that do not exist.
      *
      * @expectedException \Cake\Console\Exception\ConsoleException
+     * @expectedExceptionMessageRegexp /Unknown short option `f`.\n\nAvailable short options are :\n\n
+     * - `n` (short for `--no-commit`)\n - `c` (short for `--clear`)/
      * @return void
      */
     public function testShortOptionThatDoesNotExist()
     {
         $parser = new ConsoleOptionParser('test', false);
-        $parser->addOption('no-commit', ['boolean' => true]);
+        $parser->addOption('no-commit', ['boolean' => true, 'short' => 'n']);
+        $parser->addOption('construct', ['boolean' => true]);
+        $parser->addOption('clear', ['boolean' => true, 'short' => 'c']);
 
         $parser->parse(['-f']);
     }
@@ -800,6 +806,46 @@ TEXT;
     }
 
     /**
+     * test that getCommandError() with an unknown subcommand param shows a helpful message
+     *
+     * @return void
+     */
+    public function testHelpUnknownSubcommand()
+    {
+        $subParser = [
+            'options' => [
+                'foo' => [
+                    'short' => 'f',
+                    'help' => 'Foo.',
+                    'boolean' => true,
+                ]
+            ],
+        ];
+
+        $parser = new ConsoleOptionParser('mycommand', false);
+        $parser
+            ->addSubcommand('method', [
+                'help' => 'This is a subcommand',
+                'parser' => $subParser
+            ])
+            ->addOption('test', ['help' => 'A test option.'])
+            ->addSubcommand('unstash');
+
+        $result = $parser->help('unknown');
+        $expected = <<<TEXT
+Unable to find the `mycommand unknown` subcommand. See `bin/cake mycommand --help`.
+
+Did you mean : `mycommand unstash` ?
+
+Available subcommands for the `mycommand` command are : 
+
+ - method
+ - unstash
+TEXT;
+        $this->assertTextEquals($expected, $result, 'Help is not correct.');
+    }
+
+    /**
      * test building a parser from an array.
      *
      * @return void

+ 6 - 6
tests/TestCase/Console/ShellTest.php

@@ -1049,7 +1049,7 @@ TEXT;
     public function testRunCommandBaseClassMethod()
     {
         $shell = $this->getMockBuilder('Cake\Console\Shell')
-            ->setMethods(['startup', 'getOptionParser', 'out', 'hr'])
+            ->setMethods(['startup', 'getOptionParser', 'err', 'hr'])
             ->disableOriginalConstructor()
             ->getMock();
 
@@ -1062,7 +1062,7 @@ TEXT;
         $shell->expects($this->once())->method('getOptionParser')
             ->will($this->returnValue($parser));
         $shell->expects($this->never())->method('hr');
-        $shell->expects($this->once())->method('out');
+        $shell->expects($this->once())->method('err');
 
         $shell->runCommand(['hr']);
     }
@@ -1075,7 +1075,7 @@ TEXT;
     public function testRunCommandMissingMethod()
     {
         $shell = $this->getMockBuilder('Cake\Console\Shell')
-            ->setMethods(['startup', 'getOptionParser', 'out', 'hr'])
+            ->setMethods(['startup', 'getOptionParser', 'err', 'hr'])
             ->disableOriginalConstructor()
             ->getMock();
         $shell->io($this->getMockBuilder('Cake\Console\ConsoleIo')->getMock());
@@ -1086,7 +1086,7 @@ TEXT;
         $parser->expects($this->once())->method('help');
         $shell->expects($this->once())->method('getOptionParser')
             ->will($this->returnValue($parser));
-        $shell->expects($this->once())->method('out');
+        $shell->expects($this->once())->method('err');
 
         $result = $shell->runCommand(['idontexist']);
         $this->assertFalse($result);
@@ -1127,7 +1127,7 @@ TEXT;
     public function testRunCommandNotCallUnexposedTask()
     {
         $shell = $this->getMockBuilder('Cake\Console\Shell')
-            ->setMethods(['startup', 'hasTask', 'out'])
+            ->setMethods(['startup', 'hasTask', 'err'])
             ->disableOriginalConstructor()
             ->getMock();
         $shell->io($this->getMockBuilder('Cake\Console\ConsoleIo')->getMock());
@@ -1143,7 +1143,7 @@ TEXT;
             ->method('hasTask')
             ->will($this->returnValue(true));
         $shell->expects($this->never())->method('startup');
-        $shell->expects($this->once())->method('out');
+        $shell->expects($this->once())->method('err');
         $shell->RunCommand = $task;
 
         $result = $shell->runCommand(['run_command', 'one']);