Skip to content

Commit 1868ad4

Browse files
committed
address reviews
1 parent a944b89 commit 1868ad4

File tree

15 files changed

+439
-61
lines changed

15 files changed

+439
-61
lines changed

system/CLI/AbstractCommand.php

Lines changed: 19 additions & 9 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.
@@ -816,8 +820,14 @@ private function validateOption(string $name, Option $definition, array|bool|str
816820
throw new OptionValueMismatchException(lang('Commands.nonArrayOptionWithArrayValue', [$name]));
817821
}
818822

819-
if ($definition->requiresValue && ! is_string($value) && ! is_array($value)) {
820-
throw new OptionValueMismatchException(lang('Commands.optionRequiresValue', [$name]));
823+
if ($definition->requiresValue) {
824+
$elements = is_array($value) ? $value : [$value];
825+
826+
foreach ($elements as $element) {
827+
if (! is_string($element)) {
828+
throw new OptionValueMismatchException(lang('Commands.optionRequiresValue', [$name]));
829+
}
830+
}
821831
}
822832

823833
if (! $definition->negatable || is_bool($value)) {
@@ -859,7 +869,7 @@ private function validateNegatableOption(string $name, Option $definition, array
859869
*/
860870
private function getOptionDefinitionFor(string $name): Option
861871
{
862-
if (! array_key_exists($name, $this->optionsDefinition)) {
872+
if (! $this->hasOption($name)) {
863873
throw new LogicException(sprintf('Option "%s" is not defined on this command.', $name));
864874
}
865875

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/Commands/ListCommands.php

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,11 @@ protected function execute(array $arguments, array $options): int
4343

4444
private function describeCommandsSimple(): int
4545
{
46-
$commands = [
47-
...array_keys($this->getCommandRunner()->getCommands()),
48-
...array_keys($this->getCommandRunner()->getModernCommands()),
49-
];
46+
// Legacy takes precedence on key collision so the listing reflects the
47+
// command that would actually be invoked.
48+
$commands = array_keys(
49+
$this->getCommandRunner()->getCommands() + $this->getCommandRunner()->getModernCommands(),
50+
);
5051
sort($commands);
5152

5253
foreach ($commands as $command) {
@@ -64,10 +65,11 @@ private function describeCommandsDetailed(): int
6465
$entries = [];
6566
$maxPad = 0;
6667

67-
foreach ([
68-
...$this->getCommandRunner()->getCommands(),
69-
...$this->getCommandRunner()->getModernCommands(),
70-
] as $command => $details) {
68+
// Legacy takes precedence on key collision so the listing reflects the
69+
// command that would actually be invoked.
70+
$all = $this->getCommandRunner()->getCommands() + $this->getCommandRunner()->getModernCommands();
71+
72+
foreach ($all as $command => $details) {
7173
$maxPad = max($maxPad, strlen($command) + 4);
7274

7375
$entries[] = [$details['group'], $command, $details['description']];
@@ -105,7 +107,7 @@ private function describeCommandsDetailed(): int
105107
CLI::write(sprintf(
106108
'%s%s',
107109
CLI::color($this->addPadding($command[0], 2, $maxPad), 'green'),
108-
CLI::wrap($command[1]),
110+
CLI::wrap($command[1], 0, $maxPad),
109111
));
110112
}
111113
}

system/Commands/Server/Serve.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ class Serve extends AbstractCommand
3737
protected function configure(): void
3838
{
3939
$this
40-
->addOption(new Option(name: 'php', description: 'The PHP binary to use.', acceptsValue: true, default: PHP_BINARY))
41-
->addOption(new Option(name: 'host', description: 'The host to serve on.', acceptsValue: true, default: 'localhost'))
42-
->addOption(new Option(name: 'port', description: 'The port to serve on.', acceptsValue: true, default: '8080'));
40+
->addOption(new Option(name: 'php', description: 'The PHP binary to use.', requiresValue: true, default: PHP_BINARY))
41+
->addOption(new Option(name: 'host', description: 'The host to serve on.', requiresValue: true, default: 'localhost'))
42+
->addOption(new Option(name: 'port', description: 'The port to serve on.', requiresValue: true, default: '8080'));
4343
}
4444

4545
protected function execute(array $arguments, array $options): int

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/_support/_command/DuplicateLegacy.php renamed to tests/_support/Duplicates/DuplicateLegacy.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,19 @@
1111
* the LICENSE file that was distributed with this source code.
1212
*/
1313

14-
namespace App\Commands;
14+
namespace Tests\Support\Duplicates;
1515

1616
use CodeIgniter\CLI\BaseCommand;
1717

1818
/**
19+
* Lives outside any `Commands/` directory so discovery does not pick it up
20+
* automatically. Tests inject this via a mocked FileLocator.
21+
*
1922
* @internal
2023
*/
2124
final class DuplicateLegacy extends BaseCommand
2225
{
23-
protected $group = 'Fixtures';
26+
protected $group = 'Duplicates';
2427
protected $name = 'dup:test';
2528
protected $description = 'Legacy fixture that collides with a modern command of the same name.';
2629

tests/_support/_command/DuplicateModern.php renamed to tests/_support/Duplicates/DuplicateModern.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,18 @@
1111
* the LICENSE file that was distributed with this source code.
1212
*/
1313

14-
namespace App\Commands;
14+
namespace Tests\Support\Duplicates;
1515

1616
use CodeIgniter\CLI\AbstractCommand;
1717
use CodeIgniter\CLI\Attributes\Command;
1818

1919
/**
20+
* Lives outside any `Commands/` directory so discovery does not pick it up
21+
* automatically. Tests inject this via a mocked FileLocator.
22+
*
2023
* @internal
2124
*/
22-
#[Command(name: 'dup:test', description: 'Modern fixture that collides with a legacy command of the same name.', group: 'Fixtures')]
25+
#[Command(name: 'dup:test', description: 'Modern fixture that collides with a legacy command of the same name.', group: 'Duplicates')]
2326
final class DuplicateModern extends AbstractCommand
2427
{
2528
protected function execute(array $arguments, array $options): int

tests/system/CLI/AbstractCommandTest.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
use CodeIgniter\Exceptions\LogicException;
2727
use CodeIgniter\Test\CIUnitTestCase;
2828
use CodeIgniter\Test\StreamFilterTrait;
29+
use Config\App;
30+
use Config\Services;
2931
use PHPUnit\Framework\Attributes\After;
3032
use PHPUnit\Framework\Attributes\Before;
3133
use PHPUnit\Framework\Attributes\CoversClass;
@@ -193,6 +195,14 @@ public static function provideCollectionLevelOptionRegistrationIsRejected(): ite
193195
['name' => 'test', 'negatable' => true, 'default' => false],
194196
],
195197
];
198+
199+
yield 'option name clashes with existing negation' => [
200+
'Option "--no-test" clashes with the negation of negatable option "--test".',
201+
[
202+
['name' => 'test', 'negatable' => true, 'default' => false],
203+
['name' => 'no-test'],
204+
],
205+
];
196206
}
197207

198208
public function testRenderThrowable(): void
@@ -204,6 +214,21 @@ public function testRenderThrowable(): void
204214
$this->assertStringContainsString('Invalid "background" color: "Background".', $this->getStreamFilterBuffer());
205215
}
206216

217+
public function testRenderThrowableSwapsNonCliRequestAndRestores(): void
218+
{
219+
// Seed the shared request with a non-CLI instance so renderThrowable()
220+
// exercises the swap-and-restore branch.
221+
Services::createRequest(config(App::class));
222+
$incoming = Services::get('request');
223+
224+
$command = new AppAboutCommand(new Commands());
225+
226+
$this->assertSame(EXIT_ERROR, $command->bomb());
227+
$this->assertStringContainsString('Invalid "background" color: "Background".', $this->getStreamFilterBuffer());
228+
229+
$this->assertSame($incoming, Services::get('request'));
230+
}
231+
207232
public function testCheckingOfArgumentsAndOptions(): void
208233
{
209234
$command = new Help(new Commands());
@@ -509,6 +534,24 @@ public static function provideValidationOfOptions(): iterable
509534
['foo' => null],
510535
];
511536

537+
yield 'array option requiring value passed without value [app:about a --baz]' => [
538+
OptionValueMismatchException::class,
539+
'Option "--baz" requires a value to be provided.',
540+
['baz' => null],
541+
];
542+
543+
yield 'array option requiring value passed without value multiple times [app:about a --baz --baz]' => [
544+
OptionValueMismatchException::class,
545+
'Option "--baz" requires a value to be provided.',
546+
['baz' => [null, null]],
547+
];
548+
549+
yield 'array option requiring value mixing value and no-value [app:about a --baz=v1 --baz]' => [
550+
OptionValueMismatchException::class,
551+
'Option "--baz" requires a value to be provided.',
552+
['baz' => ['v1', null]],
553+
];
554+
512555
yield 'option not accepting value passed with value [app:about a --no-header=value]' => [
513556
OptionValueMismatchException::class,
514557
'Option "--no-header" does not accept a value.',

0 commit comments

Comments
 (0)