Skip to content

Commit 1a9ba5e

Browse files
authored
feat: offer to run a suggested command when the typed name is not found (#10538)
1 parent 51375a2 commit 1a9ba5e

8 files changed

Lines changed: 238 additions & 2 deletions

File tree

‎system/CLI/Commands.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ public function verifyCommand(string $command, array $commands = [], bool $legac
362362
*
363363
* @return list<string>
364364
*/
365-
protected function getCommandAlternatives(string $name, array $collection = []): array
365+
public function getCommandAlternatives(string $name, array $collection = []): array
366366
{
367367
if ($collection !== []) {
368368
@trigger_error(sprintf('Since v4.8.0, the $collection parameter of %s() is no longer used.', __METHOD__), E_USER_DEPRECATED);

‎system/CLI/Console.php‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,24 @@ public function run(array $tokens = [])
6969

7070
$this->command = array_shift($arguments) ?? self::DEFAULT_COMMAND;
7171

72+
if (
73+
$this->isInteractive()
74+
&& ! $commands->hasLegacyCommand($this->command)
75+
&& ! $commands->hasModernCommand($this->command)
76+
) {
77+
$alternatives = $commands->getCommandAlternatives($this->command);
78+
79+
if ($alternatives !== []) {
80+
$alternative = $this->chooseAlternative($alternatives);
81+
82+
if ($alternative === null) {
83+
return EXIT_ERROR;
84+
}
85+
86+
$this->command = $alternative;
87+
}
88+
}
89+
7290
if ($commands->hasLegacyCommand($this->command)) {
7391
$legacyOptions = $this->options;
7492
unset($legacyOptions['no-header']);
@@ -114,6 +132,25 @@ public function showHeader(bool $suppress = false)
114132
CLI::newLine();
115133
}
116134

135+
/**
136+
* Asks which suggested command to run instead, returning `null` when the user declines.
137+
*
138+
* @param list<string> $alternatives
139+
*/
140+
private function chooseAlternative(array $alternatives): ?string
141+
{
142+
CLI::error(lang('CLI.commandNotFound', [$this->command]));
143+
CLI::newLine();
144+
145+
if (count($alternatives) === 1) {
146+
return CLI::prompt(lang('CLI.altCommandRun', [$alternatives[0]]), ['y', 'n']) === 'y' ? $alternatives[0] : null;
147+
}
148+
149+
$chosen = (int) CLI::promptByKey(lang('CLI.altCommandSelect'), [...$alternatives, lang('CLI.altCommandNone')]);
150+
151+
return $alternatives[$chosen] ?? null;
152+
}
153+
117154
/**
118155
* Checks whether any of the options are present in the command line.
119156
*
@@ -129,4 +166,12 @@ private function hasParameterOption(array $options): bool
129166

130167
return false;
131168
}
169+
170+
private function isInteractive(): bool
171+
{
172+
return ! $this->hasParameterOption(['no-interaction', 'N'])
173+
&& ! CLI::getInputOutput() instanceof NullInputOutput
174+
&& defined('STDIN')
175+
&& CLI::streamSupports('stream_isatty', STDIN);
176+
}
132177
}

‎system/Language/en/CLI.php‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313

1414
// CLI language settings
1515
return [
16+
'altCommandNone' => 'none of these',
1617
'altCommandPlural' => 'Did you mean one of these?',
18+
'altCommandRun' => 'Run "{0}" instead?',
19+
'altCommandSelect' => 'Select a command to run instead:',
1720
'altCommandSingular' => 'Did you mean this?',
1821
'argumentPrompt' => 'Please provide a value for the "{0}" argument',
1922
'commandAlias' => '[alias of {0}]',

‎tests/system/CLI/CommandsTest.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,7 @@ public function testGetCommandAlternativesThrowsDeprecationWhenCommandsArrayIsPa
575575
$this->expectExceptionMessage('Since v4.8.0, the $collection parameter of CodeIgniter\CLI\Commands::getCommandAlternatives() is no longer used.');
576576

577577
$commands = new Commands();
578-
self::getPrivateMethodInvoker($commands, 'getCommandAlternatives')('app:inf', $commands->getCommands());
578+
$commands->getCommandAlternatives('app:inf', $commands->getCommands());
579579
}
580580

581581
public function testDiscoveredLegacyCommandsCanBeOverridden(): void

‎tests/system/CLI/ConsoleTest.php‎

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use CodeIgniter\Test\CIUnitTestCase;
2222
use CodeIgniter\Test\Mock\MockCLIConfig;
2323
use CodeIgniter\Test\Mock\MockCodeIgniter;
24+
use CodeIgniter\Test\Mock\MockInputOutput;
2425
use CodeIgniter\Test\StreamFilterTrait;
2526
use PHPUnit\Framework\Attributes\CoversClass;
2627
use PHPUnit\Framework\Attributes\Group;
@@ -54,6 +55,25 @@ protected function tearDown(): void
5455
CLI::reset();
5556
}
5657

58+
private function getUndecoratedBuffer(): string
59+
{
60+
return preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()) ?? '';
61+
}
62+
63+
private function getUndecoratedIoOutput(MockInputOutput $io): string
64+
{
65+
return preg_replace('/\e\[[^m]+m/', '', $io->getOutput()) ?? '';
66+
}
67+
68+
private function useInputs(string ...$inputs): MockInputOutput
69+
{
70+
$io = new MockInputOutput();
71+
$io->setInputs($inputs);
72+
CLI::setInputOutput($io);
73+
74+
return $io;
75+
}
76+
5777
public function testHeaderShowsNormally(): void
5878
{
5979
$this->initializeConsole();
@@ -121,6 +141,142 @@ public function testBadCommand(): void
121141
$this->assertStringContainsString('Command "bogus" not found', $this->getStreamFilterBuffer());
122142
}
123143

144+
public function testUnknownCommandRunsConfirmedSuggestion(): void
145+
{
146+
$this->initializeConsole('app:inf', '--no-header');
147+
$io = $this->useInputs('y');
148+
149+
$console = new Console();
150+
$exitCode = $console->run();
151+
152+
$this->assertSame(EXIT_SUCCESS, $exitCode);
153+
$this->assertSame('app:info', $console->getCommand());
154+
$this->assertSame(
155+
sprintf(
156+
<<<'EOT'
157+
158+
Command "app:inf" not found.
159+
160+
Run "app:info" instead? [y, n]: y
161+
CodeIgniter Version: %s
162+
163+
EOT,
164+
CodeIgniter::CI_VERSION,
165+
),
166+
$this->getUndecoratedIoOutput($io),
167+
);
168+
}
169+
170+
public function testUnknownCommandDeclinedSuggestionExitsWithError(): void
171+
{
172+
$this->initializeConsole('app:inf', '--no-header');
173+
$io = $this->useInputs('n');
174+
175+
$console = new Console();
176+
$exitCode = $console->run();
177+
178+
$this->assertSame(EXIT_ERROR, $exitCode);
179+
$this->assertSame('app:inf', $console->getCommand());
180+
$this->assertSame(
181+
<<<'EOT'
182+
183+
Command "app:inf" not found.
184+
185+
Run "app:info" instead? [y, n]: n
186+
187+
EOT,
188+
$this->getUndecoratedIoOutput($io),
189+
);
190+
}
191+
192+
public function testUnknownCommandRunsSelectedSuggestion(): void
193+
{
194+
$this->initializeConsole('clear', '--no-header');
195+
$io = $this->useInputs('0');
196+
197+
$console = new Console();
198+
$exitCode = $console->run();
199+
200+
$this->assertSame(EXIT_SUCCESS, $exitCode);
201+
$this->assertSame('cache:clear', $console->getCommand());
202+
$this->assertSame(
203+
<<<'EOT'
204+
205+
Command "clear" not found.
206+
207+
Select a command to run instead:
208+
[0] cache:clear
209+
[1] debugbar:clear
210+
[2] logs:clear
211+
[3] none of these
212+
213+
[0, 1, 2, 3]: 0
214+
Cache cleared using the "file" driver.
215+
216+
EOT,
217+
$this->getUndecoratedIoOutput($io),
218+
);
219+
}
220+
221+
public function testUnknownCommandSelectingNoneExitsWithError(): void
222+
{
223+
$this->initializeConsole('clear', '--no-header');
224+
$io = $this->useInputs('3');
225+
226+
$console = new Console();
227+
$exitCode = $console->run();
228+
229+
$this->assertSame(EXIT_ERROR, $exitCode);
230+
$this->assertSame('clear', $console->getCommand());
231+
$this->assertSame(
232+
<<<'EOT'
233+
234+
Command "clear" not found.
235+
236+
Select a command to run instead:
237+
[0] cache:clear
238+
[1] debugbar:clear
239+
[2] logs:clear
240+
[3] none of these
241+
242+
[0, 1, 2, 3]: 3
243+
244+
EOT,
245+
$this->getUndecoratedIoOutput($io),
246+
);
247+
}
248+
249+
public function testUnknownCommandDoesNotPromptWhenNotInteractive(): void
250+
{
251+
$this->initializeConsole('lst', '--no-header', '--no-interaction');
252+
$exitCode = (new Console())->run();
253+
254+
$this->assertSame(EXIT_ERROR, $exitCode);
255+
$this->assertSame(
256+
<<<'EOT'
257+
258+
Command "lst" not found.
259+
260+
Did you mean this?
261+
list
262+
263+
EOT,
264+
$this->getUndecoratedBuffer(),
265+
);
266+
}
267+
268+
public function testUnknownCommandDoesNotPromptWithNullInputOutput(): void
269+
{
270+
$this->initializeConsole('lst', '--no-header');
271+
CLI::setInputOutput(new NullInputOutput());
272+
273+
$console = new Console();
274+
$exitCode = $console->run();
275+
276+
$this->assertSame(EXIT_ERROR, $exitCode);
277+
$this->assertSame('lst', $console->getCommand());
278+
}
279+
124280
public function testHelpCommandDetails(): void
125281
{
126282
$this->initializeConsole('help', 'make:migration');

‎user_guide_src/source/changelogs/v4.8.0.rst‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ Property Scope Changes
9494
Method Scope Changes
9595
====================
9696

97+
- **CLI:** ``CodeIgniter\CLI\Commands::getCommandAlternatives()`` is now public (previously protected).
9798
- **HTTP:** The following methods have changed their scope (visibility):
9899
- ``CodeIgniter\HTTP\URI::setUri()`` is now private (previously public).
99100
- ``CodeIgniter\HTTP\URI::refreshPath()`` is now protected (previously public).
@@ -240,6 +241,8 @@ Commands
240241
``--restful`` value on non-interactive runs instead of prompting.
241242
- The ``make:model`` command now accepts ``-t``, ``-g``, and ``-r`` as shortcuts for ``--table``, ``--dbgroup``, and ``--return``, and rejects an
242243
invalid ``--return`` value on non-interactive runs instead of prompting.
244+
- **spark** now offers to run the closest matching command when a command name is mistyped on an interactive run.
245+
See :ref:`correcting-a-mistyped-command`.
243246
- Modern commands can now opt in to prompting for missing required arguments on interactive runs by implementing the new
244247
``PromptsForMissingInputInterface`` marker interface. Prompt labels can be customized via ``getArgumentPromptLabels()``, and an
245248
``afterPrompting()`` hook runs when prompting occurred. Non-interactive runs keep failing fast with the missing-arguments error.
@@ -389,6 +392,7 @@ Message Changes
389392
- Added new language keys:
390393
- ``Cache.unsupportedLockStore`` (``CacheException::forUnsupportedLockStore()``)
391394
- ``CLI.commandAlias`` and ``CLI.helpAliases`` (command alias rendering in ``list`` and ``help``)
395+
- ``CLI.altCommandNone``, ``CLI.altCommandRun``, and ``CLI.altCommandSelect`` (correcting a mistyped command)
392396
- ``CLI.generator.invalidReturnType`` (``make:model`` rejecting an invalid ``--return``)
393397
- ``Commands.invalidCommandAlias``, ``Commands.commandAliasSameAsName``, ``Commands.duplicateCommandAlias``, ``Commands.aliasClashesWithCommandName``, and ``Commands.aliasClashesWithAlias`` (command alias validation)
394398

‎user_guide_src/source/cli/spark_commands.rst‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,28 @@ You may always pass ``--no-header`` to suppress the header output, helpful for p
106106
107107
Your environment is currently set as development.
108108
109+
.. _correcting-a-mistyped-command:
110+
111+
Correcting a Mistyped Command
112+
-----------------------------
113+
114+
.. versionadded:: 4.8.0
115+
116+
When the command name is not found, **spark** lists the closest matches. On an interactive
117+
run it also offers to run one of them: a single match asks for a ``y``/``n`` confirmation, and
118+
several matches present a numbered list with a "none of these" entry. Pressing Enter picks the
119+
highlighted default. Non-interactive runs (``--no-interaction`` / ``-N``, or piped input) only
120+
print the suggestions and exit with an error, as before.
121+
122+
.. code-block:: console
123+
124+
php spark cache:clea
125+
126+
Command "cache:clea" not found.
127+
128+
Run "cache:clear" instead? [y, n]: y
129+
Cache cleared using the "file" driver.
130+
109131
Calling Commands
110132
================
111133

‎user_guide_src/source/installation/upgrade_480.rst‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ Previously, returning a non-integer value from a command run through ``spark`` w
2727
Starting with v4.8.0, this behavior is still supported but will trigger a deprecation notice. Commands should now return an integer exit code
2828
to ensure proper behavior across all platforms.
2929

30+
Command Alternatives Visibility
31+
===============================
32+
33+
``CodeIgniter\CLI\Commands::getCommandAlternatives()`` is now ``public``. If you extend ``Commands`` and override this method,
34+
change the override's visibility from ``protected`` to ``public``.
35+
3036
Uploaded File Move Return Type
3137
==============================
3238

0 commit comments

Comments
 (0)