Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion system/CLI/Commands.php
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ public function verifyCommand(string $command, array $commands = [], bool $legac
*
* @return list<string>
*/
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);
Expand Down
45 changes: 45 additions & 0 deletions system/CLI/Console.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -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<string> $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')]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to specify Y/n by default?

Run "cache:clear" instead? [Y, n]:  (click "Enter" as "Yes")
    Cache cleared using the "file" driver.


return $alternatives[$chosen] ?? null;
}

/**
* Checks whether any of the options are present in the command line.
*
Expand All @@ -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);
}
}
3 changes: 3 additions & 0 deletions system/Language/en/CLI.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}]',
Expand Down
2 changes: 1 addition & 1 deletion tests/system/CLI/CommandsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
156 changes: 156 additions & 0 deletions tests/system/CLI/ConsoleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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');
Expand Down
3 changes: 3 additions & 0 deletions user_guide_src/source/changelogs/v4.8.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions user_guide_src/source/cli/spark_commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
================

Expand Down
Loading