Skip to content

Commit 5a9adf3

Browse files
committed
address reviews
1 parent a944b89 commit 5a9adf3

10 files changed

Lines changed: 275 additions & 27 deletions

File tree

system/CLI/AbstractCommand.php

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ public function addArgument(Argument $argument): static
217217
{
218218
$name = $argument->name;
219219

220-
if (array_key_exists($name, $this->argumentsDefinition)) {
220+
if ($this->hasArgument($name)) {
221221
throw new InvalidArgumentDefinitionException(lang('Commands.duplicateArgument', [$name]));
222222
}
223223

@@ -253,15 +253,19 @@ public function addOption(Option $option): static
253253
{
254254
$name = $option->name;
255255

256-
if (array_key_exists($name, $this->optionsDefinition)) {
256+
if ($this->hasOption($name)) {
257257
throw new InvalidOptionDefinitionException(lang('Commands.duplicateOption', [$name]));
258258
}
259259

260-
if ($option->shortcut !== null && array_key_exists($option->shortcut, $this->shortcuts)) {
260+
if ($this->hasNegation($name)) {
261+
throw new InvalidOptionDefinitionException(lang('Commands.optionClashesWithExistingNegation', [$name, $this->negations[$name]]));
262+
}
263+
264+
if ($option->shortcut !== null && $this->hasShortcut($option->shortcut)) {
261265
throw new InvalidOptionDefinitionException(lang('Commands.duplicateShortcut', [$option->shortcut, $name, $this->shortcuts[$option->shortcut]]));
262266
}
263267

264-
if ($option->negation !== null && array_key_exists($option->negation, $this->optionsDefinition)) {
268+
if ($option->negation !== null && $this->hasOption($option->negation)) {
265269
throw new InvalidOptionDefinitionException(lang('Commands.negatableOptionNegationExists', [$name]));
266270
}
267271

@@ -692,7 +696,7 @@ private function bind(array $arguments, array $options): array
692696

693697
// 4. If there are still options left that are not defined, we will mark them as extraneous.
694698
foreach ($options as $name => $value) {
695-
if (array_key_exists($name, $this->shortcuts)) {
699+
if ($this->hasShortcut($name)) {
696700
// This scenario can happen when the command has an array option with a shortcut,
697701
// and the shortcut is used alongside the long name, causing it to be not bound
698702
// in the previous loop.
@@ -707,7 +711,7 @@ private function bind(array $arguments, array $options): array
707711
continue;
708712
}
709713

710-
if (array_key_exists($name, $this->negations)) {
714+
if ($this->hasNegation($name)) {
711715
// This scenario can happen when the command has a negatable option,
712716
// and both the option and its negation are used, causing the negation
713717
// to be not bound in the previous loop.
@@ -859,7 +863,7 @@ private function validateNegatableOption(string $name, Option $definition, array
859863
*/
860864
private function getOptionDefinitionFor(string $name): Option
861865
{
862-
if (! array_key_exists($name, $this->optionsDefinition)) {
866+
if (! $this->hasOption($name)) {
863867
throw new LogicException(sprintf('Option "%s" is not defined on this command.', $name));
864868
}
865869

system/CLI/Commands.php

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -217,11 +217,13 @@ public function discoverCommands()
217217

218218
foreach (array_keys(array_intersect_key($this->commands, $this->modernCommands)) as $name) {
219219
CLI::write(
220-
lang('Commands.duplicateCommandName', [
221-
$name,
222-
$this->commands[$name]['class'],
223-
$this->modernCommands[$name]['class'],
224-
]),
220+
CLI::wrap(
221+
lang('Commands.duplicateCommandName', [
222+
$name,
223+
$this->commands[$name]['class'],
224+
$this->modernCommands[$name]['class'],
225+
]),
226+
),
225227
'yellow',
226228
);
227229
}
@@ -248,7 +250,7 @@ public function verifyCommand(string $command, array $commands = [], bool $legac
248250

249251
$message = lang('CLI.commandNotFound', [$command]);
250252

251-
$alternatives = $this->getCommandAlternatives($command, legacy: $legacy);
253+
$alternatives = $this->getCommandAlternatives($command);
252254

253255
if ($alternatives !== []) {
254256
$message = sprintf(
@@ -265,24 +267,22 @@ public function verifyCommand(string $command, array $commands = [], bool $legac
265267
}
266268

267269
/**
268-
* Finds alternative of `$name` among collection of commands.
270+
* Finds alternative of `$name` across both legacy and modern commands.
269271
*
270272
* @param legacy_commands $collection (no longer used)
271273
*
272274
* @return list<string>
273275
*/
274-
protected function getCommandAlternatives(string $name, array $collection = [], bool $legacy = true): array
276+
protected function getCommandAlternatives(string $name, array $collection = []): array
275277
{
276278
if ($collection !== []) {
277279
@trigger_error(sprintf('Since v4.8.0, the $collection parameter of %s() is no longer used.', __METHOD__), E_USER_DEPRECATED);
278280
}
279281

280-
$commandCollection = $legacy ? $this->commands : $this->modernCommands;
281-
282282
/** @var array<string, int> */
283283
$alternatives = [];
284284

285-
foreach (array_keys($commandCollection) as $commandName) {
285+
foreach (array_keys($this->commands + $this->modernCommands) as $commandName) {
286286
$lev = levenshtein($name, $commandName);
287287

288288
if ($lev <= strlen($commandName) / 3 || str_contains($commandName, $name)) {

system/Language/en/Commands.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
'arrayOptionEmptyArrayDefault' => 'Array option "--{0}" cannot have an empty array as the default value.',
2121
'argumentAfterArrayArgument' => 'Argument "{0}" cannot be defined after array argument "{1}".',
2222
'duplicateArgument' => 'An argument with the name "{0}" is already defined.',
23-
'duplicateCommandName' => 'Warning: command "{0}" is defined as both legacy ({1}) and modern ({2}); the legacy command will execute. Please rename or remove one.',
23+
'duplicateCommandName' => 'Warning: The "{0}" command is defined as both legacy ({1}) and modern ({2}). The legacy command will be executed. Please rename or remove one.',
2424
'duplicateOption' => 'An option with the name "--{0}" is already defined.',
2525
'duplicateShortcut' => 'Shortcut "-{0}" cannot be used for option "--{1}"; it is already assigned to option "--{2}".',
2626
'emptyCommandName' => 'Command name cannot be empty.',
@@ -47,6 +47,7 @@
4747
'noArgumentsExpected' => 'No arguments expected for "{0}" command. Received: "{1}".',
4848
'nonArrayArgumentWithArrayDefault' => 'Argument "{0}" does not accept an array default value.',
4949
'nonArrayOptionWithArrayValue' => 'Option "--{0}" does not accept an array value.',
50+
'optionClashesWithExistingNegation' => 'Option "--{0}" clashes with the negation of negatable option "--{1}".',
5051
'optionNoValueAndNoDefault' => 'Option "--{0}" does not accept a value and cannot have a default value.',
5152
'optionNotAcceptingValue' => 'Option "--{0}" does not accept a value.',
5253
'optionalArgumentNoDefault' => 'Argument "{0}" is optional and must have a default value.',

tests/system/CLI/AbstractCommandTest.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,14 @@ public static function provideCollectionLevelOptionRegistrationIsRejected(): ite
193193
['name' => 'test', 'negatable' => true, 'default' => false],
194194
],
195195
];
196+
197+
yield 'option name clashes with existing negation' => [
198+
'Option "--no-test" clashes with the negation of negatable option "--test".',
199+
[
200+
['name' => 'test', 'negatable' => true, 'default' => false],
201+
['name' => 'no-test'],
202+
],
203+
];
196204
}
197205

198206
public function testRenderThrowable(): void

tests/system/CLI/CommandsTest.php

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,14 @@ public function testRunOnUnknownCommand(): void
7979

8080
$this->assertSame(EXIT_ERROR, $commands->runLegacy('app:unknown', []));
8181
$this->assertArrayNotHasKey('app:unknown', $commands->getCommands());
82-
$this->assertStringContainsString('Command "app:unknown" not found', $this->getStreamFilterBuffer());
82+
$this->assertSame("\nCommand \"app:unknown\" not found.\n", $this->getUndecoratedBuffer());
8383

8484
$this->resetStreamFilterBuffer();
85+
CLI::resetLastWrite();
8586

8687
$this->assertSame(EXIT_ERROR, $commands->runCommand('app:unknown', [], []));
8788
$this->assertArrayNotHasKey('app:unknown', $commands->getModernCommands());
88-
$this->assertStringContainsString('Command "app:unknown" not found', $this->getStreamFilterBuffer());
89+
$this->assertSame("\nCommand \"app:unknown\" not found.\n", $this->getUndecoratedBuffer());
8990
}
9091

9192
public function testRunOnUnknownLegacyCommandButWithOneAlternative(): void
@@ -135,6 +136,7 @@ public function testRunOnUnknownLegacyCommandButWithMultipleAlternatives(): void
135136
Command "app:" not found.
136137
137138
Did you mean one of these?
139+
app:about
138140
app:destructive
139141
app:info
140142

@@ -143,6 +145,42 @@ public function testRunOnUnknownLegacyCommandButWithMultipleAlternatives(): void
143145
);
144146
}
145147

148+
public function testRunOnUnknownLegacyCommandAlsoSuggestsModernAlternatives(): void
149+
{
150+
$commands = new Commands();
151+
152+
$this->assertSame(EXIT_ERROR, $commands->runLegacy('app:ab', []));
153+
$this->assertSame(
154+
<<<'EOT'
155+
156+
Command "app:ab" not found.
157+
158+
Did you mean this?
159+
app:about
160+
161+
EOT,
162+
$this->getUndecoratedBuffer(),
163+
);
164+
}
165+
166+
public function testRunOnUnknownModernCommandAlsoSuggestsLegacyAlternatives(): void
167+
{
168+
$commands = new Commands();
169+
170+
$this->assertSame(EXIT_ERROR, $commands->runCommand('app:inf', [], []));
171+
$this->assertSame(
172+
<<<'EOT'
173+
174+
Command "app:inf" not found.
175+
176+
Did you mean this?
177+
app:info
178+
179+
EOT,
180+
$this->getUndecoratedBuffer(),
181+
);
182+
}
183+
146184
public function testRunOnUnknownModernCommandButWithMultipleAlternatives(): void
147185
{
148186
$commands = new Commands();
@@ -169,7 +207,7 @@ public function testRunOnAbstractLegacyCommandCannotBeRun(): void
169207

170208
$this->assertSame(EXIT_ERROR, $commands->runLegacy('app:pablo', []));
171209
$this->assertArrayNotHasKey('app:pablo', $commands->getCommands());
172-
$this->assertStringContainsString('Command "app:pablo" not found', $this->getStreamFilterBuffer());
210+
$this->assertSame("\nCommand \"app:pablo\" not found.\n", $this->getUndecoratedBuffer());
173211
}
174212

175213
public function testRunOnKnownLegacyCommand(): void
@@ -178,7 +216,10 @@ public function testRunOnKnownLegacyCommand(): void
178216

179217
$this->assertSame(EXIT_SUCCESS, $commands->runLegacy('app:info', []));
180218
$this->assertArrayHasKey('app:info', $commands->getCommands());
181-
$this->assertStringContainsString('CodeIgniter Version', $this->getStreamFilterBuffer());
219+
$this->assertSame(
220+
sprintf("\nCodeIgniter Version: %s\n", CodeIgniter::CI_VERSION),
221+
$this->getUndecoratedBuffer(),
222+
);
182223
}
183224

184225
public function testRunOnKnownModernCommand(): void
@@ -218,12 +259,14 @@ public function testDiscoveryWarnsWhenSameCommandNameExistsInBothRegistries(): v
218259
$this->copyCommand($modernFixture);
219260

220261
try {
262+
$message = wordwrap(
263+
'Warning: The "dup:test" command is defined as both legacy (App\\Commands\\DuplicateLegacy) and modern (App\\Commands\\DuplicateModern). The legacy command will be executed. Please rename or remove one.',
264+
CLI::getWidth(),
265+
);
266+
221267
$commands = new Commands();
222268

223-
$this->assertStringContainsString(
224-
'Warning: command "dup:test" is defined as both legacy (App\\Commands\\DuplicateLegacy) and modern (App\\Commands\\DuplicateModern)',
225-
$this->getUndecoratedBuffer(),
226-
);
269+
$this->assertSame("\n{$message}\n", $this->getUndecoratedBuffer());
227270
$this->assertArrayHasKey('dup:test', $commands->getCommands());
228271
$this->assertArrayHasKey('dup:test', $commands->getModernCommands());
229272
} finally {

tests/system/Commands/HelpCommandTest.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,23 @@ public function testDescribeInexistentCommandButWithAlternatives(): void
173173
);
174174
}
175175

176+
public function testDescribeInexistentCommandSuggestsLegacyAlternatives(): void
177+
{
178+
command('help app:inf');
179+
180+
$this->assertSame(
181+
<<<'EOT'
182+
183+
Command "app:inf" not found.
184+
185+
Did you mean this?
186+
app:info
187+
188+
EOT,
189+
$this->getUndecoratedBuffer(),
190+
);
191+
}
192+
176193
public function testDescribeUsingHelpOption(): void
177194
{
178195
command('cache:clear --help');

user_guide_src/source/changelogs/v4.8.0.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ Behavior Changes
2828
- **Commands:** The ``-h`` option to ``routes`` command which was mapped previously to the ``--handler`` option is now removed.
2929
Use ``--handler`` instead to sort the routes by handler when running the ``routes`` command.
3030
- **Commands:** The ``filter:check`` command now requires the HTTP method argument to be uppercase (e.g., ``spark filter:check GET /`` instead of ``spark filter:check get /``).
31+
- **Commands:** Several built-in commands have been migrated from ``BaseCommand`` to the modern ``AbstractCommand`` style. Applications that extend a built-in command to override
32+
behaviour may need to re-implement against the modern API (``configure()`` + ``execute()`` and the ``#[Command]`` attribute) once the class it extends is migrated, or, preferably, compose instead of extending. Invocations on the command line are unaffected.
3133
- **Database:** The Postgre driver's ``$db->error()['code']`` previously always returned ``''``. It now returns the 5-character SQLSTATE string for query and transaction failures (e.g., ``'42P01'``), or ``'08006'`` for connection-level failures. Code that relied on ``$db->error()['code'] === ''`` will need updating.
3234
- **Filters:** HTTP method matching for method-based filters is now case-sensitive. The keys in ``Config\Filters::$methods`` must exactly match the request method
3335
(e.g., ``GET``, ``POST``). Lowercase method names (e.g., ``post``) will no longer match.

user_guide_src/source/cli/cli_modern_commands.rst

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,17 @@ A few quirks are worth knowing:
141141

142142
Configuration-time violations raise ``InvalidOptionDefinitionException``.
143143

144+
.. note::
145+
146+
The command-line parser does **not** understand bundled shortcuts or
147+
shortcuts with a glued value:
148+
149+
- ``-abc`` is read as one option named ``abc``, *not* as ``-a -b -c``.
150+
- ``-fvalue`` is read as one option named ``fvalue``; it is not split into
151+
shortcut ``-f`` with value ``value``.
152+
153+
Pass shortcut values with ``-f=value`` or ``-f value`` instead.
154+
144155
*************************
145156
Interacting With the User
146157
*************************
@@ -235,6 +246,79 @@ formatted output the framework uses for uncaught exceptions, call
235246
``$this->renderThrowable($exception)``. The helper is safe to call from any
236247
command, and it will not disturb the currently shared request.
237248

249+
**********************************
250+
Migrating From ``BaseCommand``
251+
**********************************
252+
253+
The modern command system is a superset of the legacy ``BaseCommand`` API — the
254+
same capabilities are there, just expressed through an attribute and explicit
255+
definitions rather than class properties and ad-hoc lookups.
256+
257+
**Identity**
258+
259+
``protected $name``
260+
Moves to ``name:`` on the ``#[Command]`` attribute.
261+
262+
``protected $description``
263+
Moves to ``description:`` on the ``#[Command]`` attribute.
264+
265+
``protected $group``
266+
Moves to ``group:`` on the ``#[Command]`` attribute. An empty group skips the command at discovery.
267+
268+
**Input surface (declare inside** ``configure()`` **)**
269+
270+
``protected $usage``
271+
The default usage line is generated from the declared arguments. Register extras with ``addUsage()``.
272+
273+
``protected $arguments``
274+
One ``addArgument(new Argument(...))`` call per argument.
275+
276+
``protected $options``
277+
One ``addOption(new Option(...))`` call per option. A long name is required; a legacy ``-x``-style
278+
option becomes ``new Option(name: 'something', shortcut: 'x')``.
279+
280+
**Runtime**
281+
282+
``run(array $params)``
283+
No longer the extension point — ``run()`` is ``final`` on ``AbstractCommand`` and drives the lifecycle itself.
284+
Move the body into ``execute(array $arguments, array $options): int``, which must return an ``EXIT_*`` status.
285+
286+
``$params[0]``
287+
Use ``$arguments['name']`` or ``$this->getValidatedArgument('name')``.
288+
289+
``$params['name']`` / ``CLI::getOption('name')``
290+
Use ``$options['name']`` or ``$this->getValidatedOption('name')``.
291+
Call ``$this->hasUnboundOption('name')`` when you need to know whether the flag was actually passed.
292+
293+
``$this->call('other', $params)``
294+
Becomes ``$this->call('other', $arguments, $options)``; only from inside ``execute()``.
295+
To forward the caller's own raw input, pass ``$this->getUnboundArguments()`` and ``$this->getUnboundOptions()``.
296+
297+
``$this->showError($e)``
298+
Becomes ``$this->renderThrowable($e)``.
299+
300+
``showHelp()`` override
301+
Gone. The built-in ``help`` command builds the help output itself from the declared arguments, options, and usages.
302+
303+
Prompting the user mid-run stays with ``CLI::prompt()``, but the idiomatic spot moves from ``run()``
304+
to ``interact()`` so validation can see whatever the user provides interactively.
305+
306+
A typical ``BaseCommand`` implementation:
307+
308+
.. literalinclude:: cli_modern_commands/009.php
309+
310+
…becomes, as a modern command:
311+
312+
.. literalinclude:: cli_modern_commands/010.php
313+
314+
Two behavioural changes are worth calling out explicitly:
315+
316+
- **Validated, not raw.** Arguments and options are parsed, defaulted, and validated before ``execute()`` runs.
317+
If a required argument is missing or a ``requiresValue`` option was passed without a value, the framework
318+
raises a typed exception and your command is never entered.
319+
- **Exit codes are mandatory.** Legacy ``run()`` could return ``null``. The modern ``execute()`` must return an
320+
integer; the framework emits a deprecation notice for any legacy command that still returns ``null``.
321+
238322
********************************
239323
Coexistence With Legacy Commands
240324
********************************

0 commit comments

Comments
 (0)