diff --git a/system/CLI/Commands.php b/system/CLI/Commands.php index 960caeaf4c87..bf00eec174c9 100644 --- a/system/CLI/Commands.php +++ b/system/CLI/Commands.php @@ -362,7 +362,7 @@ public function verifyCommand(string $command, array $commands = [], bool $legac * * @return list */ - protected function getCommandAlternatives(string $name, array $collection = []): array + public function getCommandAlternatives(string $name, array $collection = []): array { if ($collection !== []) { @trigger_error(sprintf('Since v4.8.0, the $collection parameter of %s() is no longer used.', __METHOD__), E_USER_DEPRECATED); diff --git a/system/CLI/Console.php b/system/CLI/Console.php index 16feed451ebb..67ea91ce0b66 100644 --- a/system/CLI/Console.php +++ b/system/CLI/Console.php @@ -69,6 +69,24 @@ public function run(array $tokens = []) $this->command = array_shift($arguments) ?? self::DEFAULT_COMMAND; + if ( + $this->isInteractive() + && ! $commands->hasLegacyCommand($this->command) + && ! $commands->hasModernCommand($this->command) + ) { + $alternatives = $commands->getCommandAlternatives($this->command); + + if ($alternatives !== []) { + $alternative = $this->chooseAlternative($alternatives); + + if ($alternative === null) { + return EXIT_ERROR; + } + + $this->command = $alternative; + } + } + if ($commands->hasLegacyCommand($this->command)) { $legacyOptions = $this->options; unset($legacyOptions['no-header']); @@ -114,6 +132,25 @@ public function showHeader(bool $suppress = false) CLI::newLine(); } + /** + * Asks which suggested command to run instead, returning `null` when the user declines. + * + * @param list $alternatives + */ + private function chooseAlternative(array $alternatives): ?string + { + CLI::error(lang('CLI.commandNotFound', [$this->command])); + CLI::newLine(); + + if (count($alternatives) === 1) { + return CLI::prompt(lang('CLI.altCommandRun', [$alternatives[0]]), ['y', 'n']) === 'y' ? $alternatives[0] : null; + } + + $chosen = (int) CLI::promptByKey(lang('CLI.altCommandSelect'), [...$alternatives, lang('CLI.altCommandNone')]); + + return $alternatives[$chosen] ?? null; + } + /** * Checks whether any of the options are present in the command line. * @@ -129,4 +166,12 @@ private function hasParameterOption(array $options): bool return false; } + + private function isInteractive(): bool + { + return ! $this->hasParameterOption(['no-interaction', 'N']) + && ! CLI::getInputOutput() instanceof NullInputOutput + && defined('STDIN') + && CLI::streamSupports('stream_isatty', STDIN); + } } diff --git a/system/Language/en/CLI.php b/system/Language/en/CLI.php index 3d44dec060da..d28ff1848bfa 100644 --- a/system/Language/en/CLI.php +++ b/system/Language/en/CLI.php @@ -13,7 +13,10 @@ // CLI language settings return [ + 'altCommandNone' => 'none of these', 'altCommandPlural' => 'Did you mean one of these?', + 'altCommandRun' => 'Run "{0}" instead?', + 'altCommandSelect' => 'Select a command to run instead:', 'altCommandSingular' => 'Did you mean this?', 'argumentPrompt' => 'Please provide a value for the "{0}" argument', 'commandAlias' => '[alias of {0}]', diff --git a/tests/system/CLI/CommandsTest.php b/tests/system/CLI/CommandsTest.php index e79522173269..ca93d9a3fb57 100644 --- a/tests/system/CLI/CommandsTest.php +++ b/tests/system/CLI/CommandsTest.php @@ -575,7 +575,7 @@ public function testGetCommandAlternativesThrowsDeprecationWhenCommandsArrayIsPa $this->expectExceptionMessage('Since v4.8.0, the $collection parameter of CodeIgniter\CLI\Commands::getCommandAlternatives() is no longer used.'); $commands = new Commands(); - self::getPrivateMethodInvoker($commands, 'getCommandAlternatives')('app:inf', $commands->getCommands()); + $commands->getCommandAlternatives('app:inf', $commands->getCommands()); } public function testDiscoveredLegacyCommandsCanBeOverridden(): void diff --git a/tests/system/CLI/ConsoleTest.php b/tests/system/CLI/ConsoleTest.php index 7f9c451fba31..4eea3a7f3183 100644 --- a/tests/system/CLI/ConsoleTest.php +++ b/tests/system/CLI/ConsoleTest.php @@ -21,6 +21,7 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockCLIConfig; use CodeIgniter\Test\Mock\MockCodeIgniter; +use CodeIgniter\Test\Mock\MockInputOutput; use CodeIgniter\Test\StreamFilterTrait; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; @@ -54,6 +55,25 @@ protected function tearDown(): void CLI::reset(); } + private function getUndecoratedBuffer(): string + { + return preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()) ?? ''; + } + + private function getUndecoratedIoOutput(MockInputOutput $io): string + { + return preg_replace('/\e\[[^m]+m/', '', $io->getOutput()) ?? ''; + } + + private function useInputs(string ...$inputs): MockInputOutput + { + $io = new MockInputOutput(); + $io->setInputs($inputs); + CLI::setInputOutput($io); + + return $io; + } + public function testHeaderShowsNormally(): void { $this->initializeConsole(); @@ -121,6 +141,142 @@ public function testBadCommand(): void $this->assertStringContainsString('Command "bogus" not found', $this->getStreamFilterBuffer()); } + public function testUnknownCommandRunsConfirmedSuggestion(): void + { + $this->initializeConsole('app:inf', '--no-header'); + $io = $this->useInputs('y'); + + $console = new Console(); + $exitCode = $console->run(); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertSame('app:info', $console->getCommand()); + $this->assertSame( + sprintf( + <<<'EOT' + + Command "app:inf" not found. + + Run "app:info" instead? [y, n]: y + CodeIgniter Version: %s + + EOT, + CodeIgniter::CI_VERSION, + ), + $this->getUndecoratedIoOutput($io), + ); + } + + public function testUnknownCommandDeclinedSuggestionExitsWithError(): void + { + $this->initializeConsole('app:inf', '--no-header'); + $io = $this->useInputs('n'); + + $console = new Console(); + $exitCode = $console->run(); + + $this->assertSame(EXIT_ERROR, $exitCode); + $this->assertSame('app:inf', $console->getCommand()); + $this->assertSame( + <<<'EOT' + + Command "app:inf" not found. + + Run "app:info" instead? [y, n]: n + + EOT, + $this->getUndecoratedIoOutput($io), + ); + } + + public function testUnknownCommandRunsSelectedSuggestion(): void + { + $this->initializeConsole('clear', '--no-header'); + $io = $this->useInputs('0'); + + $console = new Console(); + $exitCode = $console->run(); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertSame('cache:clear', $console->getCommand()); + $this->assertSame( + <<<'EOT' + + Command "clear" not found. + + Select a command to run instead: + [0] cache:clear + [1] debugbar:clear + [2] logs:clear + [3] none of these + + [0, 1, 2, 3]: 0 + Cache cleared using the "file" driver. + + EOT, + $this->getUndecoratedIoOutput($io), + ); + } + + public function testUnknownCommandSelectingNoneExitsWithError(): void + { + $this->initializeConsole('clear', '--no-header'); + $io = $this->useInputs('3'); + + $console = new Console(); + $exitCode = $console->run(); + + $this->assertSame(EXIT_ERROR, $exitCode); + $this->assertSame('clear', $console->getCommand()); + $this->assertSame( + <<<'EOT' + + Command "clear" not found. + + Select a command to run instead: + [0] cache:clear + [1] debugbar:clear + [2] logs:clear + [3] none of these + + [0, 1, 2, 3]: 3 + + EOT, + $this->getUndecoratedIoOutput($io), + ); + } + + public function testUnknownCommandDoesNotPromptWhenNotInteractive(): void + { + $this->initializeConsole('lst', '--no-header', '--no-interaction'); + $exitCode = (new Console())->run(); + + $this->assertSame(EXIT_ERROR, $exitCode); + $this->assertSame( + <<<'EOT' + + Command "lst" not found. + + Did you mean this? + list + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testUnknownCommandDoesNotPromptWithNullInputOutput(): void + { + $this->initializeConsole('lst', '--no-header'); + CLI::setInputOutput(new NullInputOutput()); + + $console = new Console(); + $exitCode = $console->run(); + + $this->assertSame(EXIT_ERROR, $exitCode); + $this->assertSame('lst', $console->getCommand()); + } + public function testHelpCommandDetails(): void { $this->initializeConsole('help', 'make:migration'); diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index d296985cca1d..ec167ed55999 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -92,6 +92,7 @@ Property Scope Changes Method Scope Changes ==================== +- **CLI:** ``CodeIgniter\CLI\Commands::getCommandAlternatives()`` is now public (previously protected). - **HTTP:** The following methods have changed their scope (visibility): - ``CodeIgniter\HTTP\URI::setUri()`` is now private (previously public). - ``CodeIgniter\HTTP\URI::refreshPath()`` is now protected (previously public). @@ -234,6 +235,8 @@ Commands and ``-f`` shortcuts for ``--namespace``, ``--suffix``, and ``--force``. See :doc:`../cli/cli_modern_generators`. - Legacy ``BaseCommand::call()`` can now invoke modern commands: integer-keyed params are passed as arguments and string-keyed params as options, which the target command validates like any other input. +- **spark** now offers to run the closest matching command when a command name is mistyped on an interactive run. + See :ref:`correcting-a-mistyped-command`. - Modern commands can now opt in to prompting for missing required arguments on interactive runs by implementing the new ``PromptsForMissingInputInterface`` marker interface. Prompt labels can be customized via ``getArgumentPromptLabels()``, and an ``afterPrompting()`` hook runs when prompting occurred. Non-interactive runs keep failing fast with the missing-arguments error. diff --git a/user_guide_src/source/cli/spark_commands.rst b/user_guide_src/source/cli/spark_commands.rst index 86c0af9d877f..6d279458e486 100644 --- a/user_guide_src/source/cli/spark_commands.rst +++ b/user_guide_src/source/cli/spark_commands.rst @@ -106,6 +106,28 @@ You may always pass ``--no-header`` to suppress the header output, helpful for p Your environment is currently set as development. +.. _correcting-a-mistyped-command: + +Correcting a Mistyped Command +----------------------------- + +.. versionadded:: 4.8.0 + +When the command name is not found, **spark** lists the closest matches. On an interactive +run it also offers to run one of them: a single match asks for a ``y``/``n`` confirmation, and +several matches present a numbered list with a "none of these" entry. Pressing Enter picks the +highlighted default. Non-interactive runs (``--no-interaction`` / ``-N``, or piped input) only +print the suggestions and exit with an error, as before. + +.. code-block:: console + + php spark cache:clea + + Command "cache:clea" not found. + + Run "cache:clear" instead? [y, n]: y + Cache cleared using the "file" driver. + Calling Commands ================