Skip to content

Commit 31bdbf2

Browse files
committed
feat: offer to run a suggested command when the typed name is not found
1 parent ccf5fe2 commit 31bdbf2

7 files changed

Lines changed: 231 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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ Property Scope Changes
9292
Method Scope Changes
9393
====================
9494

95+
- **CLI:** ``CodeIgniter\CLI\Commands::getCommandAlternatives()`` is now public (previously protected).
9596
- **HTTP:** The following methods have changed their scope (visibility):
9697
- ``CodeIgniter\HTTP\URI::setUri()`` is now private (previously public).
9798
- ``CodeIgniter\HTTP\URI::refreshPath()`` is now protected (previously public).
@@ -234,6 +235,8 @@ Commands
234235
and ``-f`` shortcuts for ``--namespace``, ``--suffix``, and ``--force``. See :doc:`../cli/cli_modern_generators`.
235236
- Legacy ``BaseCommand::call()`` can now invoke modern commands: integer-keyed params are passed as arguments and string-keyed params as
236237
options, which the target command validates like any other input.
238+
- **spark** now offers to run the closest matching command when a command name is mistyped on an interactive run.
239+
See :ref:`correcting-a-mistyped-command`.
237240
- Modern commands can now opt in to prompting for missing required arguments on interactive runs by implementing the new
238241
``PromptsForMissingInputInterface`` marker interface. Prompt labels can be customized via ``getArgumentPromptLabels()``, and an
239242
``afterPrompting()`` hook runs when prompting occurred. Non-interactive runs keep failing fast with the missing-arguments error.

‎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

0 commit comments

Comments
 (0)