From 988d80fa8208e73b6208a3c4c80f674bd12ef458 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 18 Apr 2026 21:28:57 +0800 Subject: [PATCH 01/11] add the Command attribute --- system/CLI/Attributes/Command.php | 48 +++++++++++ tests/system/CLI/Attributes/CommandTest.php | 88 +++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 system/CLI/Attributes/Command.php create mode 100644 tests/system/CLI/Attributes/CommandTest.php diff --git a/system/CLI/Attributes/Command.php b/system/CLI/Attributes/Command.php new file mode 100644 index 000000000000..b05a8c34e887 --- /dev/null +++ b/system/CLI/Attributes/Command.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Attributes; + +use Attribute; +use CodeIgniter\Exceptions\LogicException; + +/** + * Attribute to mark a class as a CLI command. + */ +#[Attribute(Attribute::TARGET_CLASS)] +final readonly class Command +{ + /** + * @var non-empty-string + */ + public string $name; + + /** + * @throws LogicException + */ + public function __construct( + string $name, + public string $description = '', + public string $group = '', + ) { + if ($name === '') { + throw new LogicException(lang('Commands.emptyCommandName')); + } + + if (preg_match('/^[^\s\:]++(\:[^\s\:]++)*$/', $name) !== 1) { + throw new LogicException(lang('Commands.invalidCommandName', [$name])); + } + + $this->name = $name; + } +} diff --git a/tests/system/CLI/Attributes/CommandTest.php b/tests/system/CLI/Attributes/CommandTest.php new file mode 100644 index 000000000000..426275e5b3a0 --- /dev/null +++ b/tests/system/CLI/Attributes/CommandTest.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Attributes; + +use CodeIgniter\Exceptions\LogicException; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[CoversClass(Command::class)] +#[Group('Others')] +final class CommandTest extends CIUnitTestCase +{ + public function testAttributeExposesProperties(): void + { + $command = new Command(name: 'app:about', description: 'Displays basic info.', group: 'App'); + + $this->assertSame('app:about', $command->name); + $this->assertSame('Displays basic info.', $command->description); + $this->assertSame('App', $command->group); + } + + public function testAttributeAllowsOmittedDescriptionAndGroup(): void + { + $command = new Command(name: 'app:about'); + + $this->assertSame('', $command->description); + $this->assertSame('', $command->group); + } + + /** + * @param array $parameters + */ + #[DataProvider('provideInvalidDefinitionsAreRejected')] + public function testInvalidDefinitionsAreRejected(string $message, array $parameters): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage($message); + + new Command(...$parameters); + } + + /** + * @return iterable}> + */ + public static function provideInvalidDefinitionsAreRejected(): iterable + { + yield 'empty name' => [ + 'Command name cannot be empty.', + ['name' => ''], + ]; + + yield 'name with whitespace' => [ + 'Command name "invalid name" is not valid.', + ['name' => 'invalid name'], + ]; + + yield 'name starting with colon' => [ + 'Command name ":invalid" is not valid.', + ['name' => ':invalid'], + ]; + + yield 'name ending with colon' => [ + 'Command name "invalid:" is not valid.', + ['name' => 'invalid:'], + ]; + + yield 'name with consecutive colons' => [ + 'Command name "app::about" is not valid.', + ['name' => 'app::about'], + ]; + } +} From 6557d1caf6017df9235df2684d554dc6f03b3fdb Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 18 Apr 2026 21:29:49 +0800 Subject: [PATCH 02/11] add the CLI exceptions --- .../ArgumentCountMismatchException.php | 23 ++++++++++++++++ .../Exceptions/CommandNotFoundException.php | 27 +++++++++++++++++++ .../InvalidArgumentDefinitionException.php | 23 ++++++++++++++++ .../InvalidOptionDefinitionException.php | 23 ++++++++++++++++ .../OptionValueMismatchException.php | 23 ++++++++++++++++ .../CLI/Exceptions/UnknownOptionException.php | 23 ++++++++++++++++ 6 files changed, 142 insertions(+) create mode 100644 system/CLI/Exceptions/ArgumentCountMismatchException.php create mode 100644 system/CLI/Exceptions/CommandNotFoundException.php create mode 100644 system/CLI/Exceptions/InvalidArgumentDefinitionException.php create mode 100644 system/CLI/Exceptions/InvalidOptionDefinitionException.php create mode 100644 system/CLI/Exceptions/OptionValueMismatchException.php create mode 100644 system/CLI/Exceptions/UnknownOptionException.php diff --git a/system/CLI/Exceptions/ArgumentCountMismatchException.php b/system/CLI/Exceptions/ArgumentCountMismatchException.php new file mode 100644 index 000000000000..8772dbdede18 --- /dev/null +++ b/system/CLI/Exceptions/ArgumentCountMismatchException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Exceptions; + +use CodeIgniter\Exceptions\RuntimeException; + +/** + * Exception thrown when the number of arguments passed to a command does not match the expected count. + */ +final class ArgumentCountMismatchException extends RuntimeException +{ +} diff --git a/system/CLI/Exceptions/CommandNotFoundException.php b/system/CLI/Exceptions/CommandNotFoundException.php new file mode 100644 index 000000000000..2b8d32e2835d --- /dev/null +++ b/system/CLI/Exceptions/CommandNotFoundException.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Exceptions; + +use CodeIgniter\Exceptions\RuntimeException; + +/** + * Exception thrown when an unknown command is attempted to be executed. + */ +final class CommandNotFoundException extends RuntimeException +{ + public function __construct(string $command) + { + parent::__construct(lang('CLI.commandNotFound', [$command])); + } +} diff --git a/system/CLI/Exceptions/InvalidArgumentDefinitionException.php b/system/CLI/Exceptions/InvalidArgumentDefinitionException.php new file mode 100644 index 000000000000..8c00d1bafac1 --- /dev/null +++ b/system/CLI/Exceptions/InvalidArgumentDefinitionException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Exceptions; + +use CodeIgniter\Exceptions\InvalidArgumentException; + +/** + * Exception thrown when an invalid argument definition is provided for a spark command. + */ +final class InvalidArgumentDefinitionException extends InvalidArgumentException +{ +} diff --git a/system/CLI/Exceptions/InvalidOptionDefinitionException.php b/system/CLI/Exceptions/InvalidOptionDefinitionException.php new file mode 100644 index 000000000000..1418f21999f8 --- /dev/null +++ b/system/CLI/Exceptions/InvalidOptionDefinitionException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Exceptions; + +use CodeIgniter\Exceptions\InvalidArgumentException; + +/** + * Exception thrown when an invalid option definition is provided for a spark command. + */ +final class InvalidOptionDefinitionException extends InvalidArgumentException +{ +} diff --git a/system/CLI/Exceptions/OptionValueMismatchException.php b/system/CLI/Exceptions/OptionValueMismatchException.php new file mode 100644 index 000000000000..140eea39ff56 --- /dev/null +++ b/system/CLI/Exceptions/OptionValueMismatchException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Exceptions; + +use CodeIgniter\Exceptions\RuntimeException; + +/** + * Exception thrown when a provided option value does not match its definition. + */ +final class OptionValueMismatchException extends RuntimeException +{ +} diff --git a/system/CLI/Exceptions/UnknownOptionException.php b/system/CLI/Exceptions/UnknownOptionException.php new file mode 100644 index 000000000000..4d07626b258f --- /dev/null +++ b/system/CLI/Exceptions/UnknownOptionException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Exceptions; + +use CodeIgniter\Exceptions\RuntimeException; + +/** + * Exception thrown when unknown options are provided to a CLI command. + */ +final class UnknownOptionException extends RuntimeException +{ +} From 4c19dec32004f67ef6022cbba6c25edc92d38672 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 18 Apr 2026 21:31:26 +0800 Subject: [PATCH 03/11] add the `Argument` and `Option` value objects --- system/CLI/Input/Argument.php | 85 ++++++++++ system/CLI/Input/Option.php | 149 +++++++++++++++++ tests/system/CLI/Input/ArgumentTest.php | 125 ++++++++++++++ tests/system/CLI/Input/OptionTest.php | 209 ++++++++++++++++++++++++ 4 files changed, 568 insertions(+) create mode 100644 system/CLI/Input/Argument.php create mode 100644 system/CLI/Input/Option.php create mode 100644 tests/system/CLI/Input/ArgumentTest.php create mode 100644 tests/system/CLI/Input/OptionTest.php diff --git a/system/CLI/Input/Argument.php b/system/CLI/Input/Argument.php new file mode 100644 index 000000000000..7e53261fc9dd --- /dev/null +++ b/system/CLI/Input/Argument.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Input; + +use CodeIgniter\CLI\Exceptions\InvalidArgumentDefinitionException; + +/** + * Value object describing a single positional argument declared by a spark command. + */ +final readonly class Argument +{ + /** + * @var non-empty-string + */ + public string $name; + + /** + * @var list|string|null + */ + public array|string|null $default; + + /** + * @param list|string|null $default + * + * @throws InvalidArgumentDefinitionException + */ + public function __construct( + string $name, + public string $description = '', + public bool $required = false, + public bool $isArray = false, + array|string|null $default = null, + ) { + if ($name === '') { + throw new InvalidArgumentDefinitionException(lang('Commands.emptyArgumentName')); + } + + if (preg_match('/[^a-zA-Z0-9_-]/', $name) !== 0) { + throw new InvalidArgumentDefinitionException(lang('Commands.invalidArgumentName', [$name])); + } + + if ($name === 'extra_arguments') { + throw new InvalidArgumentDefinitionException(lang('Commands.reservedArgumentName')); + } + + $this->name = $name; + + if ($this->isArray && $this->required) { + throw new InvalidArgumentDefinitionException(lang('Commands.arrayArgumentCannotBeRequired', [$this->name])); + } + + if ($this->required && $default !== null) { + throw new InvalidArgumentDefinitionException(lang('Commands.requiredArgumentNoDefault', [$this->name])); + } + + if ($this->isArray) { + if ($default !== null && ! is_array($default)) { + throw new InvalidArgumentDefinitionException(lang('Commands.arrayArgumentInvalidDefault', [$this->name])); + } + + $default ??= []; + } elseif (! $this->required) { + if ($default === null) { + throw new InvalidArgumentDefinitionException(lang('Commands.optionalArgumentNoDefault', [$this->name])); + } + + if (is_array($default)) { + throw new InvalidArgumentDefinitionException(lang('Commands.nonArrayArgumentWithArrayDefault', [$this->name])); + } + } + + $this->default = $default; + } +} diff --git a/system/CLI/Input/Option.php b/system/CLI/Input/Option.php new file mode 100644 index 000000000000..d31bddb68e68 --- /dev/null +++ b/system/CLI/Input/Option.php @@ -0,0 +1,149 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Input; + +use CodeIgniter\CLI\Exceptions\InvalidOptionDefinitionException; + +/** + * Value object describing a single option declared by a command. + */ +final readonly class Option +{ + /** + * @var non-empty-string + */ + public string $name; + + /** + * @var non-empty-string|null + */ + public ?string $shortcut; + + public bool $acceptsValue; + + /** + * @var non-empty-string|null + */ + public ?string $valueLabel; + + /** + * @var non-empty-string|null + */ + public ?string $negation; + + /** + * @var bool|list|string|null + */ + public array|bool|string|null $default; + + /** + * @param bool|list|string|null $default + * + * @throws InvalidOptionDefinitionException + */ + public function __construct( + string $name, + ?string $shortcut = null, + public string $description = '', + bool $acceptsValue = false, + public bool $requiresValue = false, + ?string $valueLabel = null, + public bool $isArray = false, + public bool $negatable = false, + array|bool|string|null $default = null, + ) { + if (str_starts_with($name, '--')) { + $name = substr($name, 2); + } + + if ($name === '') { + throw new InvalidOptionDefinitionException(lang('Commands.emptyOptionName')); + } + + if (preg_match('/^-|[^a-zA-Z0-9_-]/', $name) !== 0) { + throw new InvalidOptionDefinitionException(lang('Commands.invalidOptionName', [$name])); + } + + if ($name === 'extra_options') { + throw new InvalidOptionDefinitionException(lang('Commands.reservedOptionName')); + } + + $this->name = $name; + + if ($shortcut !== null) { + if (str_starts_with($shortcut, '-')) { + $shortcut = substr($shortcut, 1); + } + + if ($shortcut === '') { + throw new InvalidOptionDefinitionException(lang('Commands.emptyShortcutName')); + } + + if (preg_match('/[^a-zA-Z0-9]/', $shortcut) !== 0) { + throw new InvalidOptionDefinitionException(lang('Commands.invalidShortcutName', [$shortcut])); + } + + if (strlen($shortcut) > 1) { + throw new InvalidOptionDefinitionException(lang('Commands.invalidShortcutNameLength', [$shortcut])); + } + } + + $this->shortcut = $shortcut; + + // A "requires value" or "is array" option implicitly accepts a value. + $acceptsValue = $acceptsValue || $requiresValue || $isArray; + + $this->acceptsValue = $acceptsValue; + + if ($isArray && $negatable) { + throw new InvalidOptionDefinitionException(lang('Commands.negatableOptionCannotBeArray', [$name])); + } + + if ($acceptsValue && $negatable) { + throw new InvalidOptionDefinitionException(lang('Commands.negatableOptionMustNotAcceptValue', [$name])); + } + + if ($isArray && ! $requiresValue) { + throw new InvalidOptionDefinitionException(lang('Commands.arrayOptionMustRequireValue', [$name])); + } + + if (! $acceptsValue && ! $negatable && $default !== null) { + throw new InvalidOptionDefinitionException(lang('Commands.optionNoValueAndNoDefault', [$name])); + } + + if ($requiresValue && ! $isArray && ! is_string($default)) { + throw new InvalidOptionDefinitionException(lang('Commands.optionRequiresStringDefaultValue', [$name])); + } + + if ($negatable && ! is_bool($default)) { + throw new InvalidOptionDefinitionException(lang('Commands.negatableOptionInvalidDefault', [$name])); + } + + if ($isArray) { + if ($default !== null && ! is_array($default)) { + throw new InvalidOptionDefinitionException(lang('Commands.arrayOptionInvalidDefault', [$name])); + } + + if ($default === []) { + throw new InvalidOptionDefinitionException(lang('Commands.arrayOptionEmptyArrayDefault', [$name])); + } + + $default ??= []; + } + + $this->valueLabel = $acceptsValue ? ($valueLabel ?? $name) : null; + $this->negation = $negatable ? sprintf('no-%s', $name) : null; + $this->default = $acceptsValue || $negatable ? $default : false; + } +} diff --git a/tests/system/CLI/Input/ArgumentTest.php b/tests/system/CLI/Input/ArgumentTest.php new file mode 100644 index 000000000000..088851dcbf88 --- /dev/null +++ b/tests/system/CLI/Input/ArgumentTest.php @@ -0,0 +1,125 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Input; + +use CodeIgniter\CLI\Exceptions\InvalidArgumentDefinitionException; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[CoversClass(Argument::class)] +#[Group('Others')] +final class ArgumentTest extends CIUnitTestCase +{ + public function testBasicArgumentExposesProperties(): void + { + $argument = new Argument( + name: 'path', + description: 'The path to operate on.', + required: true, + ); + + $this->assertSame('path', $argument->name); + $this->assertSame('The path to operate on.', $argument->description); + $this->assertTrue($argument->required); + $this->assertFalse($argument->isArray); + $this->assertNull($argument->default); + } + + public function testArrayArgumentDefaultsToEmptyArrayWhenOmitted(): void + { + $argument = new Argument(name: 'tags', isArray: true); + + $this->assertTrue($argument->isArray); + $this->assertFalse($argument->required); + $this->assertSame([], $argument->default); + } + + public function testArrayArgumentRetainsExplicitDefault(): void + { + $argument = new Argument(name: 'tags', isArray: true, default: ['a', 'b']); + + $this->assertSame(['a', 'b'], $argument->default); + } + + public function testOptionalArgumentRetainsStringDefault(): void + { + $argument = new Argument(name: 'driver', default: 'file'); + + $this->assertFalse($argument->required); + $this->assertSame('file', $argument->default); + } + + /** + * @param array $parameters + */ + #[DataProvider('provideInvalidDefinitionsAreRejected')] + public function testInvalidDefinitionsAreRejected(string $message, array $parameters): void + { + $this->expectException(InvalidArgumentDefinitionException::class); + $this->expectExceptionMessage($message); + + new Argument(...$parameters); + } + + /** + * @return iterable}> + */ + public static function provideInvalidDefinitionsAreRejected(): iterable + { + yield 'empty name' => [ + 'Argument name cannot be empty.', + ['name' => ''], + ]; + + yield 'invalid name' => [ + 'Argument name "invalid name" is not valid.', + ['name' => 'invalid name'], + ]; + + yield 'reserved name' => [ + 'Argument name "extra_arguments" is reserved and cannot be used.', + ['name' => 'extra_arguments'], + ]; + + yield 'required array argument' => [ + 'Array argument "test" cannot be required.', + ['name' => 'test', 'required' => true, 'isArray' => true], + ]; + + yield 'required argument with default value' => [ + 'Argument "test" is required and must not have a default value.', + ['name' => 'test', 'required' => true, 'default' => 'value'], + ]; + + yield 'optional argument with null default value' => [ + 'Argument "test" is optional and must have a default value.', + ['name' => 'test'], + ]; + + yield 'array argument with non-array default value' => [ + 'Array argument "test" must have an array default value or null.', + ['name' => 'test', 'isArray' => true, 'default' => 'value'], + ]; + + yield 'non-array argument with array default value' => [ + 'Argument "test" does not accept an array default value.', + ['name' => 'test', 'default' => ['value']], + ]; + } +} diff --git a/tests/system/CLI/Input/OptionTest.php b/tests/system/CLI/Input/OptionTest.php new file mode 100644 index 000000000000..97d4ef5799c9 --- /dev/null +++ b/tests/system/CLI/Input/OptionTest.php @@ -0,0 +1,209 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI\Input; + +use CodeIgniter\CLI\Exceptions\InvalidOptionDefinitionException; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[CoversClass(Option::class)] +#[Group('Others')] +final class OptionTest extends CIUnitTestCase +{ + public function testFlagOptionExposesDefaults(): void + { + $option = new Option(name: 'verbose', shortcut: 'v', description: 'Verbose output.'); + + $this->assertSame('verbose', $option->name); + $this->assertSame('v', $option->shortcut); + $this->assertSame('Verbose output.', $option->description); + $this->assertFalse($option->acceptsValue); + $this->assertFalse($option->requiresValue); + $this->assertFalse($option->isArray); + $this->assertFalse($option->negatable); + $this->assertNull($option->valueLabel); + $this->assertNull($option->negation); + $this->assertFalse($option->default); + } + + public function testLeadingDoubleDashIsStrippedFromName(): void + { + $option = new Option(name: '--force'); + + $this->assertSame('force', $option->name); + } + + public function testLeadingDashIsStrippedFromShortcut(): void + { + $option = new Option(name: 'force', shortcut: '-f'); + + $this->assertSame('f', $option->shortcut); + } + + public function testRequiresValueImpliesAcceptsValue(): void + { + $option = new Option(name: 'path', requiresValue: true, default: '/tmp'); + + $this->assertTrue($option->acceptsValue); + $this->assertTrue($option->requiresValue); + $this->assertSame('/tmp', $option->default); + } + + public function testIsArrayImpliesAcceptsValue(): void + { + $option = new Option(name: 'tags', requiresValue: true, isArray: true, default: ['a']); + + $this->assertTrue($option->acceptsValue); + $this->assertTrue($option->isArray); + $this->assertSame(['a'], $option->default); + } + + public function testValueLabelDefaultsToName(): void + { + $option = new Option(name: 'path', acceptsValue: true); + + $this->assertSame('path', $option->valueLabel); + } + + public function testValueLabelCanBeCustomized(): void + { + $option = new Option(name: 'path', acceptsValue: true, valueLabel: 'file'); + + $this->assertSame('file', $option->valueLabel); + } + + public function testNegatableOptionComputesNegation(): void + { + $option = new Option(name: 'force', negatable: true, default: false); + + $this->assertTrue($option->negatable); + $this->assertSame('no-force', $option->negation); + $this->assertFalse($option->default); + } + + public function testNegatableOptionAcceptsBooleanDefault(): void + { + $option = new Option(name: 'force', negatable: true, default: true); + + $this->assertTrue($option->default); + } + + /** + * @param array $parameters + */ + #[DataProvider('provideInvalidDefinitionsAreRejected')] + public function testInvalidDefinitionsAreRejected(string $message, array $parameters): void + { + $this->expectException(InvalidOptionDefinitionException::class); + $this->expectExceptionMessage($message); + + new Option(...$parameters); + } + + /** + * @return iterable}> + */ + public static function provideInvalidDefinitionsAreRejected(): iterable + { + yield 'empty name' => [ + 'Option name cannot be empty.', + ['name' => ''], + ]; + + yield 'double dash only' => [ + 'Option name cannot be empty.', + ['name' => '--'], + ]; + + yield 'single dash only' => [ + 'Option name "---" is not valid.', + ['name' => '-'], + ]; + + yield 'reserved name' => [ + 'Option name "--extra_options" is reserved and cannot be used.', + ['name' => 'extra_options'], + ]; + + yield 'empty shortcut name' => [ + 'Shortcut name cannot be empty.', + ['name' => 'test', 'shortcut' => ''], + ]; + + yield 'single dash only shortcut' => [ + 'Shortcut name cannot be empty.', + ['name' => 'test', 'shortcut' => '-'], + ]; + + yield 'invalid shortcut name' => [ + 'Shortcut name "-:" is not valid.', + ['name' => 'test', 'shortcut' => '-:'], + ]; + + yield 'shortcut name with more than one character' => [ + 'Shortcut name "-ab" must be a single character.', + ['name' => 'test', 'shortcut' => '-ab'], + ]; + + yield 'negatable option accepting value' => [ + 'Negatable option "--test" cannot be defined to accept a value.', + ['name' => 'test', 'acceptsValue' => true, 'negatable' => true], + ]; + + yield 'array option not requiring value' => [ + 'Array option "--test" must require a value.', + ['name' => 'test', 'isArray' => true], + ]; + + yield 'option requiring value but has no default value' => [ + 'Option "--test" requires a string default value.', + ['name' => 'test', 'requiresValue' => true], + ]; + + yield 'negatable option cannot be array' => [ + 'Negatable option "--test" cannot be defined as an array.', + ['name' => 'test', 'isArray' => true, 'negatable' => true], + ]; + + yield 'option not accepting value but has default value' => [ + 'Option "--test" does not accept a value and cannot have a default value.', + ['name' => 'test', 'default' => 'value'], + ]; + + yield 'negatable option with non-boolean default value' => [ + 'Negatable option "--test" must have a boolean default value.', + ['name' => 'test', 'negatable' => true, 'default' => 'value'], + ]; + + yield 'negatable option with no default value' => [ + 'Negatable option "--test" must have a boolean default value.', + ['name' => 'test', 'negatable' => true], + ]; + + yield 'array option with non-array default value' => [ + 'Array option "--test" must have an array default value or null.', + ['name' => 'test', 'requiresValue' => true, 'isArray' => true, 'default' => 'value'], + ]; + + yield 'array option with empty array default value' => [ + 'Array option "--test" cannot have an empty array as the default value.', + ['name' => 'test', 'requiresValue' => true, 'isArray' => true, 'default' => []], + ]; + } +} From 4a991edd66b2b036eb24b19d53e90f68ac151f3e Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 18 Apr 2026 21:32:58 +0800 Subject: [PATCH 04/11] add `AbstractCommand` class --- system/CLI/AbstractCommand.php | 917 ++++++++++++++++++++++++++++++++ system/Language/en/Commands.php | 61 +++ 2 files changed, 978 insertions(+) create mode 100644 system/CLI/AbstractCommand.php create mode 100644 system/Language/en/Commands.php diff --git a/system/CLI/AbstractCommand.php b/system/CLI/AbstractCommand.php new file mode 100644 index 000000000000..3e0950f29cbf --- /dev/null +++ b/system/CLI/AbstractCommand.php @@ -0,0 +1,917 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\Exceptions\ArgumentCountMismatchException; +use CodeIgniter\CLI\Exceptions\InvalidArgumentDefinitionException; +use CodeIgniter\CLI\Exceptions\InvalidOptionDefinitionException; +use CodeIgniter\CLI\Exceptions\OptionValueMismatchException; +use CodeIgniter\CLI\Exceptions\UnknownOptionException; +use CodeIgniter\CLI\Input\Argument; +use CodeIgniter\CLI\Input\Option; +use CodeIgniter\Exceptions\LogicException; +use CodeIgniter\HTTP\CLIRequest; +use Config\App; +use Config\Services; +use ReflectionClass; +use Throwable; + +/** + * Base class for all modern spark commands. + * + * Each command should extend this class and implement the `execute()` method. + */ +abstract class AbstractCommand +{ + private readonly string $name; + private readonly string $description; + private readonly string $group; + + /** + * @var list + */ + private array $usages = []; + + /** + * @var array + */ + private array $argumentsDefinition = []; + + /** + * @var array + */ + private array $optionsDefinition = []; + + /** + * Map of shortcut character to the option name that declared it. + * + * @var array + */ + private array $shortcuts = []; + + /** + * Map of negated name to the option name it negates. + * + * @var array + */ + private array $negations = []; + + /** + * Cached list of required argument names, populated as definitions are added. + * + * @var list + */ + private array $requiredArguments = []; + + /** + * Cache of resolved `Command` attributes keyed by class name. + * + * @var array, Command> + */ + private static array $commandAttributeCache = []; + + /** + * The unbound arguments that can be passed to other commands when called via the `call()` method. + * + * @var list + */ + private array $unboundArguments = []; + + /** + * The unbound options that can be passed to child commands when called via the `call()` method. + * + * @var array|string|null> + */ + private array $unboundOptions = []; + + /** + * The validated arguments after binding, which will be passed to the `execute()` method. + * + * @var array|string> + */ + private array $validatedArguments = []; + + /** + * The validated options after binding, which will be passed to the `execute()` method. + * + * @var array|string|null> + */ + private array $validatedOptions = []; + + private ?string $lastOptionalArgument = null; + private ?string $lastArrayArgument = null; + + /** + * @throws InvalidArgumentDefinitionException + * @throws InvalidOptionDefinitionException + * @throws LogicException + */ + public function __construct(private readonly Commands $commands) + { + $attribute = $this->getCommandAttribute(); + + $this->name = $attribute->name; + $this->description = $attribute->description; + $this->group = $attribute->group; + + $this->configure(); + $this->provideDefaultOptions(); + + $this->createDefaultUsage(); + } + + public function getCommandRunner(): Commands + { + return $this->commands; + } + + public function getName(): string + { + return $this->name; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getGroup(): string + { + return $this->group; + } + + /** + * @return list + */ + public function getUsages(): array + { + return $this->usages; + } + + /** + * @return array + */ + public function getArgumentsDefinition(): array + { + return $this->argumentsDefinition; + } + + /** + * @return array + */ + public function getOptionsDefinition(): array + { + return $this->optionsDefinition; + } + + /** + * Returns the map of shortcut character to its owning option name. + * + * @return array + */ + public function getShortcuts(): array + { + return $this->shortcuts; + } + + /** + * Returns the map of negated name to the option name it negates. + * + * @return array + */ + public function getNegations(): array + { + return $this->negations; + } + + /** + * Appends a usage example aside from the default usage. + * + * @param non-empty-string $usage + */ + public function addUsage(string $usage): static + { + $this->usages[] = $usage; + + return $this; + } + + /** + * Adds an argument definition to the command. + * + * @throws InvalidArgumentDefinitionException + */ + public function addArgument(Argument $argument): static + { + $name = $argument->name; + + if (array_key_exists($name, $this->argumentsDefinition)) { + throw new InvalidArgumentDefinitionException(lang('Commands.duplicateArgument', [$name])); + } + + if ($this->lastArrayArgument !== null) { + throw new InvalidArgumentDefinitionException(lang('Commands.argumentAfterArrayArgument', [$name, $this->lastArrayArgument])); + } + + if ($argument->required && $this->lastOptionalArgument !== null) { + throw new InvalidArgumentDefinitionException(lang('Commands.requiredArgumentAfterOptionalArgument', [$name, $this->lastOptionalArgument])); + } + + if ($argument->isArray) { + $this->lastArrayArgument = $name; + } + + if ($argument->required) { + $this->requiredArguments[] = $name; + } else { + $this->lastOptionalArgument = $name; + } + + $this->argumentsDefinition[$name] = $argument; + + return $this; + } + + /** + * Adds an option definition to the command. + * + * @throws InvalidOptionDefinitionException + */ + public function addOption(Option $option): static + { + $name = $option->name; + + if (array_key_exists($name, $this->optionsDefinition)) { + throw new InvalidOptionDefinitionException(lang('Commands.duplicateOption', [$name])); + } + + if ($option->shortcut !== null && array_key_exists($option->shortcut, $this->shortcuts)) { + throw new InvalidOptionDefinitionException(lang('Commands.duplicateShortcut', [$option->shortcut, $name, $this->shortcuts[$option->shortcut]])); + } + + if ($option->negation !== null && array_key_exists($option->negation, $this->optionsDefinition)) { + throw new InvalidOptionDefinitionException(lang('Commands.negatableOptionNegationExists', [$name])); + } + + if ($option->shortcut !== null) { + $this->shortcuts[$option->shortcut] = $name; + } + + if ($option->negation !== null) { + $this->negations[$option->negation] = $name; + } + + $this->optionsDefinition[$name] = $option; + + return $this; + } + + /** + * Renders the given `Throwable`. + * + * This is usually not needed to be called directly as the `Throwable` will be automatically rendered by the framework when it is thrown, + * but it can be useful to call this method directly when you want to render a `Throwable` that is caught by the command itself. + */ + public function renderThrowable(Throwable $e): void + { + // The exception handler picks a renderer based on the shared request + // instance. Ensure it is a CLIRequest; if the current shared request is + // not, swap it temporarily and restore it afterwards so other code paths + // do not observe our mutation. + $previous = Services::get('request'); + $swapped = false; + + if (! $previous instanceof CLIRequest) { + Services::createRequest(config(App::class), true); + $swapped = true; + } + + try { + service('exceptions')->exceptionHandler($e); + } finally { + if ($swapped) { + Services::override('request', $previous); + } + } + } + + /** + * Checks if the command has an argument defined with the given name. + */ + public function hasArgument(string $name): bool + { + return array_key_exists($name, $this->argumentsDefinition); + } + + /** + * Checks if the command has an option defined with the given name. + */ + public function hasOption(string $name): bool + { + return array_key_exists($name, $this->optionsDefinition); + } + + /** + * Checks if the command has a shortcut defined with the given name. + */ + public function hasShortcut(string $shortcut): bool + { + return array_key_exists($shortcut, $this->shortcuts); + } + + /** + * Checks if the command has a negation defined with the given name. + */ + public function hasNegation(string $name): bool + { + return array_key_exists($name, $this->negations); + } + + /** + * Runs the command. + * + * The lifecycle is: + * + * 1. {@see initialize()} and {@see interact()} are handed the raw parsed + * input by reference, in that order. Both can mutate the tokens before + * the framework interprets them against the declared definitions. + * 2. The resulting raw input is snapshotted into `$unboundArguments` and + * `$unboundOptions` so the unbound accessors can report what the user + * actually typed (as opposed to what defaults resolved to). + * 3. {@see bind()} maps the raw tokens onto the declared arguments and + * options, applying defaults and coercing flag/negation values. + * 4. {@see validate()} rejects the bound result if it violates any of the + * declarations — missing required argument, unknown option, value/flag + * mismatches, and so on. + * 5. The bound-and-validated values are snapshotted into + * `$validatedArguments` / `$validatedOptions` and then passed to + * {@see execute()}, whose integer return is the command's exit code. + * + * @param list $arguments Parsed arguments from command line. + * @param array|string|null> $options Parsed options from command line. + * + * @throws ArgumentCountMismatchException + * @throws LogicException + * @throws OptionValueMismatchException + * @throws UnknownOptionException + */ + final public function run(array $arguments, array $options): int + { + $this->initialize($arguments, $options); + + // @todo add interactive mode check + $this->interact($arguments, $options); + + $this->unboundArguments = $arguments; + $this->unboundOptions = $options; + + [$boundArguments, $boundOptions] = $this->bind($arguments, $options); + + $this->validate($boundArguments, $boundOptions); + + $this->validatedArguments = $boundArguments; + $this->validatedOptions = $boundOptions; + + return $this->execute($boundArguments, $boundOptions); + } + + /** + * Configures the command's arguments and options definitions. + * + * This method is called from the constructor of the command. + * + * @throws InvalidArgumentDefinitionException + * @throws InvalidOptionDefinitionException + */ + protected function configure(): void + { + } + + /** + * Initializes a command before the arguments and options are bound to their definitions. + * + * This is especially useful for commands that calls another commands, and needs to adjust the + * arguments and options before calling the other command. + * + * @param list $arguments Parsed arguments from command line. + * @param array|string|null> $options Parsed options from command line. + */ + protected function initialize(array &$arguments, array &$options): void + { + } + + /** + * Interacts with the user before executing the command. This should only be called if the command is being run + * in interactive mode, which is the default when running commands from the command line. + * + * This is especially useful for commands that needs to ask the user for confirmation before executing the command. + * It can also be used to ask the user for additional information that is not provided in the command line arguments and options. + * + * @param list $arguments Parsed arguments from command line. + * @param array|string|null> $options Parsed options from command line. + */ + protected function interact(array &$arguments, array &$options): void + { + } + + /** + * Executes the command with the bound arguments and options. + * + * Validation of the bound arguments and options is done before this method is called. + * As such, this method should not throw any exceptions. All exceptions should be rendered + * with a non-zero exit code. + * + * @param array|string> $arguments Bound arguments using the command's arguments definition. + * @param array|string|null> $options Bound options using the command's options definition. + */ + abstract protected function execute(array $arguments, array $options): int; + + /** + * Calls another command from the current command. + * + * @param list $arguments Parsed arguments from command line. + * @param array|string|null> $options Parsed options from command line. + */ + protected function call(string $command, array $arguments = [], array $options = []): int + { + return $this->commands->runCommand($command, $arguments, $options); + } + + /** + * Gets the unbound arguments that can be passed to other commands when called via the `call()` method. + * + * @return list + */ + protected function getUnboundArguments(): array + { + return $this->unboundArguments; + } + + /** + * Gets the unbound argument at the given index. + * + * @throws LogicException + */ + protected function getUnboundArgument(int $index): string + { + if (! array_key_exists($index, $this->unboundArguments)) { + throw new LogicException(sprintf('Unbound argument at index "%d" does not exist.', $index)); + } + + return $this->unboundArguments[$index]; + } + + /** + * Gets the unbound options that can be passed to other commands when called via the `call()` method. + * + * @return array|string|null> + */ + protected function getUnboundOptions(): array + { + return $this->unboundOptions; + } + + /** + * Reads the raw (unbound) value of the option with the given declared name, + * resolving through its shortcut and negation. Returns `$default` when the + * option was not provided under any of those aliases. + * + * Inside {@see interact()}, pass the `$options` parameter explicitly because + * the instance state is not yet populated at that point. Elsewhere, omit + * `$options` to read from the instance state. + * + * @param array|string|null>|null $options + * @param list|string|null $default + * + * @return list|string|null + * + * @throws LogicException + */ + protected function getUnboundOption(string $name, ?array $options = null, array|string|null $default = null): array|string|null + { + $definition = $this->getOptionDefinitionFor($name); + + $options ??= $this->unboundOptions; + + if (array_key_exists($name, $options)) { + return $options[$name]; + } + + if ($definition->shortcut !== null && array_key_exists($definition->shortcut, $options)) { + return $options[$definition->shortcut]; + } + + if ($definition->negation !== null && array_key_exists($definition->negation, $options)) { + return $options[$definition->negation]; + } + + return $default; + } + + /** + * Returns whether the option with the given declared name was provided in + * the raw (unbound) input — under its long name, shortcut, or negation. + * + * Inside {@see interact()}, pass the `$options` parameter explicitly; elsewhere + * omit it to read from instance state. + * + * @param array|string|null>|null $options + * + * @throws LogicException + */ + protected function hasUnboundOption(string $name, ?array $options = null): bool + { + $definition = $this->getOptionDefinitionFor($name); + + $options ??= $this->unboundOptions; + + if (array_key_exists($name, $options)) { + return true; + } + + if ($definition->shortcut !== null && array_key_exists($definition->shortcut, $options)) { + return true; + } + + return $definition->negation !== null && array_key_exists($definition->negation, $options); + } + + /** + * Gets the validated arguments after binding and validation. + * + * @return array|string> + */ + protected function getValidatedArguments(): array + { + return $this->validatedArguments; + } + + /** + * Gets the validated argument with the given name. + * + * @return list|string + * + * @throws LogicException + */ + protected function getValidatedArgument(string $name): array|string + { + if (! array_key_exists($name, $this->validatedArguments)) { + throw new LogicException(sprintf('Validated argument with name "%s" does not exist.', $name)); + } + + return $this->validatedArguments[$name]; + } + + /** + * Gets the validated options after binding and validation. + * + * @return array|string|null> + */ + protected function getValidatedOptions(): array + { + return $this->validatedOptions; + } + + /** + * Gets the validated option with the given name. + * + * @return bool|list|string|null + * + * @throws LogicException + */ + protected function getValidatedOption(string $name): array|bool|string|null + { + if (! array_key_exists($name, $this->validatedOptions)) { + throw new LogicException(sprintf('Validated option with name "%s" does not exist.', $name)); + } + + return $this->validatedOptions[$name]; + } + + protected function provideDefaultOptions(): void + { + $this + ->addOption(new Option(name: 'help', shortcut: 'h', description: 'Display help for the given command.')) + ->addOption(new Option(name: 'no-header', description: 'Do not display the banner when running the command.')); + } + + /** + * Binds the given raw arguments and options to the command's arguments and options + * definitions, and returns the bound arguments and options. + * + * @param list $arguments Parsed arguments from command line. + * @param array|string|null> $options Parsed options from command line. + * + * @return array{ + * 0: array|string>, + * 1: array|string|null>, + * } + */ + private function bind(array $arguments, array $options): array + { + $boundArguments = []; + $boundOptions = []; + + // 1. Arguments are position-based, so we will bind them in the order they are defined + // as well as the order they are given in the command line. + foreach ($this->argumentsDefinition as $name => $definition) { + if ($definition->isArray) { + if ($arguments !== []) { + $boundArguments[$name] = array_values($arguments); + + $arguments = []; + } elseif (! $definition->required) { + $boundArguments[$name] = $definition->default; + } + } elseif ($definition->required) { + $argument = array_shift($arguments); + + if ($argument === null) { + continue; // Missing required argument. To skip for validation to catch later. + } + + $boundArguments[$name] = $argument; + } else { + $boundArguments[$name] = array_shift($arguments) ?? $definition->default; + } + } + + // 2. If there are still arguments left that are not defined, we will mark them as extraneous. + if ($arguments !== []) { + $boundArguments['extra_arguments'] = array_values($arguments); + } + + // 3. Options are name-based, so we will bind them by their names, shortcuts, and negations. + // Passed flag options will be set to `true`, otherwise, they will be set to `false`. + // Options that accept values will be set to the value passed or their default value if not passed. + // Negatable options will be set to `false` if the negation is passed. + foreach ($this->optionsDefinition as $name => $definition) { + if (array_key_exists($name, $options)) { + $boundOptions[$name] = $options[$name]; + unset($options[$name]); + } elseif ($definition->shortcut !== null && array_key_exists($definition->shortcut, $options)) { + $boundOptions[$name] = $options[$definition->shortcut]; + unset($options[$definition->shortcut]); + } elseif ($definition->negation !== null && array_key_exists($definition->negation, $options)) { + $boundOptions[$name] = $options[$definition->negation] ?? false; + + if (is_array($boundOptions[$name])) { + // Edge case: passing a negated option multiple times should normalize to false + $boundOptions[$name] = array_map(static fn (mixed $v): mixed => $v ?? false, $boundOptions[$name]); + } + + unset($options[$definition->negation]); + } else { + $boundOptions[$name] = $definition->default; + } + + if ($definition->isArray && ! is_array($boundOptions[$name])) { + $boundOptions[$name] = [$boundOptions[$name]]; + } elseif (! $definition->acceptsValue && ! $definition->negatable) { + $boundOptions[$name] ??= true; + } elseif ($definition->negatable) { + if (is_array($boundOptions[$name])) { + $boundOptions[$name] = array_map(static fn (mixed $v): mixed => $v ?? true, $boundOptions[$name]); + } else { + $boundOptions[$name] ??= true; + } + } + } + + // 4. If there are still options left that are not defined, we will mark them as extraneous. + foreach ($options as $name => $value) { + if (array_key_exists($name, $this->shortcuts)) { + // This scenario can happen when the command has an array option with a shortcut, + // and the shortcut is used alongside the long name, causing it to be not bound + // in the previous loop. + $option = $this->shortcuts[$name]; + + if (array_key_exists($option, $boundOptions) && is_array($boundOptions[$option])) { + $boundOptions[$option][] = $value; + } else { + $boundOptions[$option] = [$boundOptions[$option], $value]; + } + + continue; + } + + if (array_key_exists($name, $this->negations)) { + // This scenario can happen when the command has a negatable option, + // and both the option and its negation are used, causing the negation + // to be not bound in the previous loop. + $option = $this->negations[$name]; + $value = array_map(static fn (mixed $v): mixed => $v ?? false, $value ?? [null]); + + if (! is_array($boundOptions[$option])) { + $boundOptions[$option] = [$boundOptions[$option]]; + } + + $boundOptions[$option] = [...$boundOptions[$option], ...$value]; + + continue; + } + + $boundOptions['extra_options'] ??= []; + $boundOptions['extra_options'][$name] = $value; + } + + return [$boundArguments, $boundOptions]; + } + + /** + * Validates the bound arguments and options. + * + * @param array|string> $arguments Bound arguments using the command's arguments definition. + * @param array|string|null> $options Bound options using the command's options definition. + * + * @throws ArgumentCountMismatchException + * @throws LogicException + * @throws OptionValueMismatchException + * @throws UnknownOptionException + */ + private function validate(array $arguments, array $options): void + { + $this->validateArguments($arguments); + + foreach ($this->optionsDefinition as $name => $definition) { + $this->validateOption($name, $definition, $options[$name]); + } + + if (array_key_exists('extra_options', $options)) { + throw new UnknownOptionException(lang('Commands.unknownOptions', [ + count($options['extra_options']), + $this->name, + implode(', ', array_map( + static fn (string $key): string => strlen($key) === 1 ? sprintf('-%s', $key) : sprintf('--%s', $key), + array_keys($options['extra_options']), + )), + ])); + } + } + + /** + * @param array|string> $arguments + * + * @throws ArgumentCountMismatchException + */ + private function validateArguments(array $arguments): void + { + if ($this->argumentsDefinition === [] && $arguments !== []) { + assert(array_key_exists('extra_arguments', $arguments)); + + throw new ArgumentCountMismatchException(lang('Commands.noArgumentsExpected', [ + $this->name, + implode('", "', $arguments['extra_arguments']), + ])); + } + + if (array_diff($this->requiredArguments, array_keys($arguments)) !== []) { + throw new ArgumentCountMismatchException(lang('Commands.missingRequiredArguments', [ + $this->name, + count($this->requiredArguments), + implode(', ', $this->requiredArguments), + ])); + } + + if (array_key_exists('extra_arguments', $arguments)) { + throw new ArgumentCountMismatchException(lang('Commands.tooManyArguments', [ + $this->name, + count($arguments['extra_arguments']), + implode('", "', $arguments['extra_arguments']), + ])); + } + } + + /** + * @param bool|list|string|null $value + * + * @throws LogicException + * @throws OptionValueMismatchException + */ + private function validateOption(string $name, Option $definition, array|bool|string|null $value): void + { + if (! $definition->acceptsValue && ! $definition->negatable) { + if (is_array($value) && ! $definition->isArray) { + throw new LogicException(lang('Commands.flagOptionPassedMultipleTimes', [$name])); + } + + if (! is_bool($value)) { + throw new OptionValueMismatchException(lang('Commands.optionNotAcceptingValue', [$name])); + } + } + + if ($definition->acceptsValue && ! $definition->isArray && is_array($value)) { + throw new OptionValueMismatchException(lang('Commands.nonArrayOptionWithArrayValue', [$name])); + } + + if ($definition->requiresValue && ! is_string($value) && ! is_array($value)) { + throw new OptionValueMismatchException(lang('Commands.optionRequiresValue', [$name])); + } + + if (! $definition->negatable || is_bool($value)) { + return; + } + + $this->validateNegatableOption($name, $definition, $value); + } + + /** + * @param list|string|null $value + * + * @throws LogicException + * @throws OptionValueMismatchException + */ + private function validateNegatableOption(string $name, Option $definition, array|string|null $value): void + { + if (! is_array($value)) { + if (array_key_exists($name, $this->unboundOptions)) { + throw new OptionValueMismatchException(lang('Commands.negatableOptionNoValue', [$name])); + } + + throw new OptionValueMismatchException(lang('Commands.negatedOptionNoValue', [$definition->negation])); + } + + if (array_values(array_intersect(array_unique($value), [true, false])) === [true, false]) { + throw new LogicException(lang('Commands.negatableOptionWithNegation', [$name, $definition->negation])); + } + + if (array_key_exists($name, $this->unboundOptions)) { + throw new OptionValueMismatchException(lang('Commands.negatableOptionPassedMultipleTimes', [$name])); + } + + throw new OptionValueMismatchException(lang('Commands.negatedOptionPassedMultipleTimes', [$definition->negation])); + } + + /** + * @throws LogicException + */ + private function getOptionDefinitionFor(string $name): Option + { + if (! array_key_exists($name, $this->optionsDefinition)) { + throw new LogicException(sprintf('Option "%s" is not defined on this command.', $name)); + } + + return $this->optionsDefinition[$name]; + } + + /** + * @throws LogicException + */ + private function getCommandAttribute(): Command + { + $class = static::class; + + if (array_key_exists($class, self::$commandAttributeCache)) { + return self::$commandAttributeCache[$class]; + } + + $attribute = (new ReflectionClass($this))->getAttributes(Command::class)[0] + ?? throw new LogicException(lang('Commands.missingCommandAttribute', [$class, Command::class])); + + self::$commandAttributeCache[$class] = $attribute->newInstance(); + + return self::$commandAttributeCache[$class]; + } + + /** + * Create a default usage based on docopt style. + * + * @see http://docopt.org/ + */ + private function createDefaultUsage(): void + { + $usage = [$this->name]; + + if ($this->optionsDefinition !== []) { + $usage[] = '[options]'; + } + + if ($this->argumentsDefinition !== []) { + $usage[] = '[--]'; + + foreach ($this->argumentsDefinition as $name => $definition) { + $usage[] = sprintf( + '%s<%s>%s%s', + $definition->required ? '' : '[', + $name, + $definition->isArray ? '...' : '', + $definition->required ? '' : ']', + ); + } + } + + array_unshift($this->usages, implode(' ', $usage)); + } +} diff --git a/system/Language/en/Commands.php b/system/Language/en/Commands.php new file mode 100644 index 000000000000..bc40faa38b31 --- /dev/null +++ b/system/Language/en/Commands.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +// Commands language settings +return [ + 'arrayArgumentInvalidDefault' => 'Array argument "{0}" must have an array default value or null.', + 'arrayArgumentCannotBeRequired' => 'Array argument "{0}" cannot be required.', + 'arrayOptionInvalidDefault' => 'Array option "--{0}" must have an array default value or null.', + 'arrayOptionMustRequireValue' => 'Array option "--{0}" must require a value.', + 'arrayOptionEmptyArrayDefault' => 'Array option "--{0}" cannot have an empty array as the default value.', + 'argumentAfterArrayArgument' => 'Argument "{0}" cannot be defined after array argument "{1}".', + 'duplicateArgument' => 'An argument with the name "{0}" is already defined.', + 'duplicateCommandName' => 'Warning: command "{0}" is defined as both legacy ({1}) and modern ({2}); the legacy command will execute. Please rename or remove one.', + 'duplicateOption' => 'An option with the name "--{0}" is already defined.', + 'duplicateShortcut' => 'Shortcut "-{0}" cannot be used for option "--{1}"; it is already assigned to option "--{2}".', + 'emptyCommandName' => 'Command name cannot be empty.', + 'emptyArgumentName' => 'Argument name cannot be empty.', + 'emptyOptionName' => 'Option name cannot be empty.', + 'emptyShortcutName' => 'Shortcut name cannot be empty.', + 'flagOptionPassedMultipleTimes' => 'Option "--{0}" is passed multiple times.', + 'invalidCommandName' => 'Command name "{0}" is not valid.', + 'invalidArgumentName' => 'Argument name "{0}" is not valid.', + 'invalidOptionName' => 'Option name "--{0}" is not valid.', + 'invalidShortcutName' => 'Shortcut name "-{0}" is not valid.', + 'invalidShortcutNameLength' => 'Shortcut name "-{0}" must be a single character.', + 'missingCommandAttribute' => 'Command class "{0}" is missing the {1} attribute.', + 'missingRequiredArguments' => 'Command "{0}" is missing the following required {1, plural, =1{argument} other{arguments}}: {2}.', + 'negatableOptionNegationExists' => 'Negatable option "--{0}" cannot be defined because its negation "--no-{0}" already exists as an option.', + 'negatableOptionNoValue' => 'Negatable option "--{0}" does not accept a value.', + 'negatableOptionMustNotAcceptValue' => 'Negatable option "--{0}" cannot be defined to accept a value.', + 'negatableOptionCannotBeArray' => 'Negatable option "--{0}" cannot be defined as an array.', + 'negatableOptionInvalidDefault' => 'Negatable option "--{0}" must have a boolean default value.', + 'negatableOptionPassedMultipleTimes' => 'Negatable option "--{0}" is passed multiple times.', + 'negatableOptionWithNegation' => 'Option "--{0}" and its negation "--{1}" cannot be used together.', + 'negatedOptionNoValue' => 'Negated option "--{0}" does not accept a value.', + 'negatedOptionPassedMultipleTimes' => 'Negated option "--{0}" is passed multiple times.', + 'noArgumentsExpected' => 'No arguments expected for "{0}" command. Received: "{1}".', + 'nonArrayArgumentWithArrayDefault' => 'Argument "{0}" does not accept an array default value.', + 'nonArrayOptionWithArrayValue' => 'Option "--{0}" does not accept an array value.', + 'optionNoValueAndNoDefault' => 'Option "--{0}" does not accept a value and cannot have a default value.', + 'optionNotAcceptingValue' => 'Option "--{0}" does not accept a value.', + 'optionalArgumentNoDefault' => 'Argument "{0}" is optional and must have a default value.', + 'optionRequiresStringDefaultValue' => 'Option "--{0}" requires a string default value.', + 'optionRequiresValue' => 'Option "--{0}" requires a value to be provided.', + 'requiredArgumentNoDefault' => 'Argument "{0}" is required and must not have a default value.', + 'requiredArgumentAfterOptionalArgument' => 'Required argument "{0}" cannot be defined after optional argument "{1}".', + 'reservedArgumentName' => 'Argument name "extra_arguments" is reserved and cannot be used.', + 'reservedOptionName' => 'Option name "--extra_options" is reserved and cannot be used.', + 'tooManyArguments' => '{1, plural, =1{One unexpected argument was} other{Multiple unexpected arguments were}} provided to "{0}" command: "{2}".', + 'unknownOptions' => 'The following {0, plural, =1{option} other{options}} {0, plural, =1{is} other{are}} unknown in the "{1}" command: {2}.', +]; From 01149cdf443dbbe76a493197d8f1c03b6f2c3e27 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 18 Apr 2026 21:35:34 +0800 Subject: [PATCH 05/11] refactor `Console` and `Commands` to cater modern commands --- system/CLI/Commands.php | 264 ++++++++++++++++++++++------- system/CLI/Console.php | 24 ++- tests/system/CLI/CommandsTest.php | 270 +++++++++++++++++++++++++++--- tests/system/CLI/ConsoleTest.php | 31 +++- 4 files changed, 493 insertions(+), 96 deletions(-) diff --git a/system/CLI/Commands.php b/system/CLI/Commands.php index bb628c262b4c..1d3ab8f12dc5 100644 --- a/system/CLI/Commands.php +++ b/system/CLI/Commands.php @@ -14,35 +14,47 @@ namespace CodeIgniter\CLI; use CodeIgniter\Autoloader\FileLocatorInterface; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\Exceptions\CommandNotFoundException; use CodeIgniter\Events\Events; +use CodeIgniter\Exceptions\LogicException; use CodeIgniter\Log\Logger; +use ReflectionAttribute; use ReflectionClass; use ReflectionException; /** - * Core functionality for running, listing, etc commands. + * Command discovery and execution class. * - * @phpstan-type commands_list array, 'file': string, 'group': string,'description': string}> + * @phpstan-type legacy_commands array, file: string, group: string, description: string}> + * @phpstan-type modern_commands array, file: string, group: string, description: string}> */ class Commands { /** - * The found commands. - * - * @var commands_list + * @var legacy_commands */ protected $commands = []; /** - * Logger instance. - * * @var Logger */ protected $logger; /** - * Constructor + * Discovered modern commands keyed by command name. Kept `private` so + * subclasses do not mutate the registry directly; use {@see getModernCommands()}. * + * @var modern_commands + */ + private array $modernCommands = []; + + /** + * Guards {@see discoverCommands()} from re-scanning the filesystem on repeat calls. + */ + private bool $discovered = false; + + /** * @param Logger|null $logger */ public function __construct($logger = null) @@ -52,24 +64,39 @@ public function __construct($logger = null) } /** - * Runs a command given + * Runs a legacy command. + * + * @deprecated 4.8.0 Use {@see runLegacy()} instead. * * @param array $params * - * @return int Exit code + * @return int */ public function run(string $command, array $params) { - if (! $this->verifyCommand($command, $this->commands)) { + @trigger_error(sprintf( + 'Since v4.8.0, "%s()" is deprecated. Use "%s::runLegacy()" instead.', + __METHOD__, + self::class, + ), E_USER_DEPRECATED); + + return $this->runLegacy($command, $params); + } + + /** + * Runs a legacy command. + * + * @param array $params + */ + public function runLegacy(string $command, array $params): int + { + if (! $this->verifyCommand($command)) { return EXIT_ERROR; } - $className = $this->commands[$command]['class']; - $class = new $className($this->logger, $this); - Events::trigger('pre_command'); - $exitCode = $class->run($params); + $exitCode = $this->getCommand($command)->run($params); Events::trigger('post_command'); @@ -79,22 +106,75 @@ public function run(string $command, array $params) $command, get_debug_type($exitCode), ), E_USER_DEPRECATED); - $exitCode = EXIT_SUCCESS; + $exitCode = EXIT_SUCCESS; // @codeCoverageIgnore } return $exitCode; } /** - * Provide access to the list of commands. + * Runs a modern command. * - * @return commands_list + * @param list $arguments + * @param array|string|null> $options + */ + public function runCommand(string $command, array $arguments, array $options): int + { + if (! $this->verifyCommand($command, legacy: false)) { + return EXIT_ERROR; + } + + Events::trigger('pre_command'); + + $exitCode = $this->getCommand($command, legacy: false)->run($arguments, $options); + + Events::trigger('post_command'); + + return $exitCode; + } + + /** + * Provide access to the list of legacy commands. + * + * @return legacy_commands */ public function getCommands() { return $this->commands; } + /** + * Provide access to the list of modern commands. + * + * @return modern_commands + */ + public function getModernCommands(): array + { + return $this->modernCommands; + } + + /** + * @return ($legacy is true ? BaseCommand : AbstractCommand) + * + * @throws CommandNotFoundException + */ + public function getCommand(string $command, bool $legacy = true): AbstractCommand|BaseCommand + { + if ($legacy && isset($this->commands[$command])) { + $className = $this->commands[$command]['class']; + + return new $className($this->logger, $this); + } + + if (! $legacy && isset($this->modernCommands[$command])) { + $className = $this->modernCommands[$command]['class']; + + return new $className($this); + } + + throw new CommandNotFoundException($command); + } + /** * Discovers all commands in the framework and within user code, * and collects instances of them to work with. @@ -103,68 +183,72 @@ public function getCommands() */ public function discoverCommands() { - if ($this->commands !== []) { + if ($this->discovered) { return; } - /** @var FileLocatorInterface */ - $locator = service('locator'); - $files = $locator->listFiles('Commands/'); + $this->discovered = true; - if ($files === []) { - return; - } + /** @var FileLocatorInterface $locator */ + $locator = service('locator'); - foreach ($files as $file) { - /** @var class-string|false */ + foreach ($locator->listFiles('Commands/') as $file) { $className = $locator->findQualifiedNameFromPath($file); if ($className === false || ! class_exists($className)) { continue; } - try { - $class = new ReflectionClass($className); + $class = new ReflectionClass($className); - if (! $class->isInstantiable() || ! $class->isSubclassOf(BaseCommand::class)) { - continue; - } - - $class = new $className($this->logger, $this); - - if ($class->group !== null && ! isset($this->commands[$class->name])) { - $this->commands[$class->name] = [ - 'class' => $className, - 'file' => $file, - 'group' => $class->group, - 'description' => $class->description, - ]; - } + if (! $class->isInstantiable()) { + continue; + } - unset($class); - } catch (ReflectionException $e) { - $this->logger->error($e->getMessage()); + if ($class->isSubclassOf(BaseCommand::class)) { + $this->registerLegacyCommand($class, $file); + } elseif ($class->isSubclassOf(AbstractCommand::class)) { + $this->registerModernCommand($class, $file); } } - asort($this->commands); + ksort($this->commands); + ksort($this->modernCommands); + + foreach (array_keys(array_intersect_key($this->commands, $this->modernCommands)) as $name) { + CLI::write( + lang('Commands.duplicateCommandName', [ + $name, + $this->commands[$name]['class'], + $this->modernCommands[$name]['class'], + ]), + 'yellow', + ); + } } /** - * Verifies if the command being sought is found - * in the commands list. + * Verifies if the command being sought is found in the commands list. * - * @param commands_list $commands + * @param legacy_commands $commands (no longer used) */ - public function verifyCommand(string $command, array $commands): bool + public function verifyCommand(string $command, array $commands = [], bool $legacy = true): bool { - if (isset($commands[$command])) { + if ($commands !== []) { + @trigger_error(sprintf('Since v4.8.0, the $commands parameter of %s() is no longer used.', __METHOD__), E_USER_DEPRECATED); + } + + if (isset($this->commands[$command]) && $legacy) { + return true; + } + + if (isset($this->modernCommands[$command]) && ! $legacy) { return true; } $message = lang('CLI.commandNotFound', [$command]); - $alternatives = $this->getCommandAlternatives($command, $commands); + $alternatives = $this->getCommandAlternatives($command, legacy: $legacy); if ($alternatives !== []) { $message = sprintf( @@ -181,20 +265,24 @@ public function verifyCommand(string $command, array $commands): bool } /** - * Finds alternative of `$name` among collection - * of commands. + * Finds alternative of `$name` among collection of commands. * - * @param commands_list $collection + * @param legacy_commands $collection (no longer used) * * @return list */ - protected function getCommandAlternatives(string $name, array $collection): array + protected function getCommandAlternatives(string $name, array $collection = [], bool $legacy = true): array { + if ($collection !== []) { + @trigger_error(sprintf('Since v4.8.0, the $collection parameter of %s() is no longer used.', __METHOD__), E_USER_DEPRECATED); + } + + $commandCollection = $legacy ? $this->commands : $this->modernCommands; + /** @var array */ $alternatives = []; - /** @var string $commandName */ - foreach (array_keys($collection) as $commandName) { + foreach (array_keys($commandCollection) as $commandName) { $lev = levenshtein($name, $commandName); if ($lev <= strlen($commandName) / 3 || str_contains($commandName, $name)) { @@ -206,4 +294,62 @@ protected function getCommandAlternatives(string $name, array $collection): arra return array_keys($alternatives); } + + /** + * @param ReflectionClass $class + */ + private function registerLegacyCommand(ReflectionClass $class, string $file): void + { + try { + /** @var BaseCommand $instance */ + $instance = $class->newInstance($this->logger, $this); + } catch (ReflectionException $e) { + $this->logger->error($e->getMessage()); + + return; + } + + if ($instance->group === null || isset($this->commands[$instance->name])) { + return; + } + + $this->commands[$instance->name] = [ + 'class' => $class->getName(), + 'file' => $file, + 'group' => $instance->group, + 'description' => $instance->description, + ]; + } + + /** + * @param ReflectionClass $class + */ + private function registerModernCommand(ReflectionClass $class, string $file): void + { + /** @var list> $attributes */ + $attributes = $class->getAttributes(Command::class); + + if ($attributes === []) { + return; + } + + try { + $attribute = $attributes[0]->newInstance(); + } catch (LogicException $e) { + $this->logger->error($e->getMessage()); + + return; + } + + if ($attribute->group === '' || isset($this->modernCommands[$attribute->name])) { + return; + } + + $this->modernCommands[$attribute->name] = [ + 'class' => $class->getName(), + 'file' => $file, + 'group' => $attribute->group, + 'description' => $attribute->description, + ]; + } } diff --git a/system/CLI/Console.php b/system/CLI/Console.php index e0cba43adde9..eb9ee4c1f75b 100644 --- a/system/CLI/Console.php +++ b/system/CLI/Console.php @@ -22,10 +22,7 @@ */ class Console { - /** - * @internal - */ - public const DEFAULT_COMMAND = 'list'; + private const DEFAULT_COMMAND = 'list'; private string $command = ''; @@ -53,21 +50,30 @@ public function run(array $tokens = []) $this->options = $parser->getOptions(); $this->showHeader($this->hasParameterOption(['no-header'])); - unset($this->options['no-header']); - - if ($this->hasParameterOption(['help'])) { - unset($this->options['help']); + if ($this->hasParameterOption(['help', 'h'])) { if ($arguments === []) { $arguments = ['help', self::DEFAULT_COMMAND]; } elseif ($arguments[0] !== 'help') { array_unshift($arguments, 'help'); } + + // Options supplied alongside --help were meant for the target command, + // not for `help` itself. Dropping them avoids feeding unknown options + // into the modern command pipeline's validator. + $this->options = []; } + /** @var Commands $commands */ + $commands = service('commands'); + $this->command = array_shift($arguments) ?? self::DEFAULT_COMMAND; - return service('commands')->run($this->command, array_merge($arguments, $this->options)); + if (array_key_exists($this->command, $commands->getCommands())) { + return $commands->runLegacy($this->command, array_merge($arguments, $this->options)); + } + + return $commands->runCommand($this->command, $arguments, $this->options); } public function initialize(): static diff --git a/tests/system/CLI/CommandsTest.php b/tests/system/CLI/CommandsTest.php index 34bb0601da65..747d2c107161 100644 --- a/tests/system/CLI/CommandsTest.php +++ b/tests/system/CLI/CommandsTest.php @@ -14,23 +14,33 @@ namespace CodeIgniter\CLI; use CodeIgniter\Autoloader\FileLocatorInterface; +use CodeIgniter\CLI\Exceptions\CommandNotFoundException; +use CodeIgniter\CodeIgniter; +use CodeIgniter\Log\Logger; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\ReflectionHelper; use CodeIgniter\Test\StreamFilterTrait; use Config\Services; +use ErrorException; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; use RuntimeException; -use Tests\Support\Commands\AppInfo; +use Tests\Support\Commands\Legacy\AppInfo; +use Tests\Support\Commands\Modern\AppAboutCommand; +use Tests\Support\InvalidCommands\EmptyCommandName; +use Tests\Support\InvalidCommands\NoAttributeCommand; /** * @internal */ #[CoversClass(Commands::class)] +#[CoversClass(CommandNotFoundException::class)] #[Group('Others')] final class CommandsTest extends CIUnitTestCase { + use ReflectionHelper; use StreamFilterTrait; #[After] @@ -42,19 +52,24 @@ protected function resetAll(): void CLI::reset(); } - private function copyAppListCommands(): void + private function getUndecoratedBuffer(): string + { + return preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()); + } + + private function copyCommand(string $path): void { if (! is_dir(APPPATH . 'Commands')) { mkdir(APPPATH . 'Commands'); } - copy(SUPPORTPATH . '_command/ListCommands.php', APPPATH . 'Commands/ListCommands.php'); + copy($path, APPPATH . 'Commands/' . basename($path)); } - private function deleteAppListCommands(): void + private function deleteCommand(string $path): void { - if (is_file(APPPATH . 'Commands/ListCommands.php')) { - unlink(APPPATH . 'Commands/ListCommands.php'); + if (is_file(APPPATH . 'Commands/' . basename($path))) { + unlink(APPPATH . 'Commands/' . basename($path)); } } @@ -62,16 +77,22 @@ public function testRunOnUnknownCommand(): void { $commands = new Commands(); - $this->assertSame(EXIT_ERROR, $commands->run('app:unknown', [])); + $this->assertSame(EXIT_ERROR, $commands->runLegacy('app:unknown', [])); $this->assertArrayNotHasKey('app:unknown', $commands->getCommands()); $this->assertStringContainsString('Command "app:unknown" not found', $this->getStreamFilterBuffer()); + + $this->resetStreamFilterBuffer(); + + $this->assertSame(EXIT_ERROR, $commands->runCommand('app:unknown', [], [])); + $this->assertArrayNotHasKey('app:unknown', $commands->getModernCommands()); + $this->assertStringContainsString('Command "app:unknown" not found', $this->getStreamFilterBuffer()); } - public function testRunOnUnknownCommandButWithOneAlternative(): void + public function testRunOnUnknownLegacyCommandButWithOneAlternative(): void { $commands = new Commands(); - $this->assertSame(EXIT_ERROR, $commands->run('app:inf', [])); + $this->assertSame(EXIT_ERROR, $commands->runLegacy('app:inf', [])); $this->assertSame( <<<'EOT' @@ -81,15 +102,33 @@ public function testRunOnUnknownCommandButWithOneAlternative(): void app:info EOT, - preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()), + $this->getUndecoratedBuffer(), + ); + } + + public function testRunOnUnknownModernCommandButWithOneAlternative(): void + { + $commands = new Commands(); + + $this->assertSame(EXIT_ERROR, $commands->runCommand('app:ab', [], [])); + $this->assertSame( + <<<'EOT' + + Command "app:ab" not found. + + Did you mean this? + app:about + + EOT, + $this->getUndecoratedBuffer(), ); } - public function testRunOnUnknownCommandButWithMultipleAlternatives(): void + public function testRunOnUnknownLegacyCommandButWithMultipleAlternatives(): void { $commands = new Commands(); - $this->assertSame(EXIT_ERROR, $commands->run('app:', [])); + $this->assertSame(EXIT_ERROR, $commands->runLegacy('app:', [])); $this->assertSame( <<<'EOT' @@ -100,28 +139,99 @@ public function testRunOnUnknownCommandButWithMultipleAlternatives(): void app:info EOT, - preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()), + $this->getUndecoratedBuffer(), ); } - public function testRunOnAbstractCommandCannotBeRun(): void + public function testRunOnUnknownModernCommandButWithMultipleAlternatives(): void { $commands = new Commands(); - $this->assertSame(EXIT_ERROR, $commands->run('app:pablo', [])); + $this->assertSame(EXIT_ERROR, $commands->runCommand('clear', [], [])); + $this->assertSame( + <<<'EOT' + + Command "clear" not found. + + Did you mean one of these? + cache:clear + debugbar:clear + logs:clear + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testRunOnAbstractLegacyCommandCannotBeRun(): void + { + $commands = new Commands(); + + $this->assertSame(EXIT_ERROR, $commands->runLegacy('app:pablo', [])); $this->assertArrayNotHasKey('app:pablo', $commands->getCommands()); $this->assertStringContainsString('Command "app:pablo" not found', $this->getStreamFilterBuffer()); } - public function testRunOnKnownCommand(): void + public function testRunOnKnownLegacyCommand(): void { $commands = new Commands(); - $this->assertSame(EXIT_SUCCESS, $commands->run('app:info', [])); + $this->assertSame(EXIT_SUCCESS, $commands->runLegacy('app:info', [])); $this->assertArrayHasKey('app:info', $commands->getCommands()); $this->assertStringContainsString('CodeIgniter Version', $this->getStreamFilterBuffer()); } + public function testRunOnKnownModernCommand(): void + { + $commands = new Commands(); + + $this->assertSame(EXIT_SUCCESS, $commands->runCommand('app:about', ['a'], [])); + $this->assertArrayHasKey('app:about', $commands->getModernCommands()); + $this->assertSame( + sprintf("\nCodeIgniter Version: %s\n", CodeIgniter::CI_VERSION), + $this->getUndecoratedBuffer(), + ); + } + + public function testRunOnLegacyCommandReturningNullIsDeprecated(): void + { + $this->expectException(ErrorException::class); + $this->expectExceptionMessage('Since v4.8.0, commands must return an integer exit code. Last command "null:return" exited with null. Defaulting to EXIT_SUCCESS.'); + + (new Commands())->runLegacy('null:return', []); + } + + public function testRunMethodIsDeprecatedInFavorOfRunLegacy(): void + { + $this->expectException(ErrorException::class); + $this->expectExceptionMessage('Since v4.8.0, "CodeIgniter\\CLI\\Commands::run()" is deprecated. Use "CodeIgniter\\CLI\\Commands::runLegacy()" instead.'); + + (new Commands())->run('app:info', []); + } + + public function testDiscoveryWarnsWhenSameCommandNameExistsInBothRegistries(): void + { + $legacyFixture = SUPPORTPATH . '_command/DuplicateLegacy.php'; + $modernFixture = SUPPORTPATH . '_command/DuplicateModern.php'; + + $this->copyCommand($legacyFixture); + $this->copyCommand($modernFixture); + + try { + $commands = new Commands(); + + $this->assertStringContainsString( + 'Warning: command "dup:test" is defined as both legacy (App\\Commands\\DuplicateLegacy) and modern (App\\Commands\\DuplicateModern)', + $this->getUndecoratedBuffer(), + ); + $this->assertArrayHasKey('dup:test', $commands->getCommands()); + $this->assertArrayHasKey('dup:test', $commands->getModernCommands()); + } finally { + $this->deleteCommand($legacyFixture); + $this->deleteCommand($modernFixture); + } + } + public function testDestructiveCommandIsNotRisky(): void { $this->expectException(RuntimeException::class); @@ -129,6 +239,30 @@ public function testDestructiveCommandIsNotRisky(): void command('app:destructive'); } + public function testGetCommand(): void + { + $commands = new Commands(); + + $this->assertInstanceOf(AppInfo::class, $commands->getCommand('app:info')); + $this->assertInstanceOf(AppAboutCommand::class, $commands->getCommand('app:about', false)); + } + + public function testGetCommandOnUnknownLegacyCommand(): void + { + $this->expectException(CommandNotFoundException::class); + $this->expectExceptionMessage('Command "app:unknown" not found.'); + + (new Commands())->getCommand('app:unknown'); + } + + public function testGetCommandOnUnknownModernCommand(): void + { + $this->expectException(CommandNotFoundException::class); + $this->expectExceptionMessage('Command "app:unknown" not found.'); + + (new Commands())->getCommand('app:unknown', false); + } + public function testDiscoverCommandsDoNotRunTwice(): void { $locator = $this->createMock(FileLocatorInterface::class); @@ -136,18 +270,70 @@ public function testDiscoverCommandsDoNotRunTwice(): void ->expects($this->once()) ->method('listFiles') ->with('Commands/') - ->willReturn([SUPPORTPATH . 'Commands/AppInfo.php']); + ->willReturn([ + SUPPORTPATH . 'Commands/Legacy/AppInfo.php', + SUPPORTPATH . 'Commands/Modern/AppAboutCommand.php', + ]); $locator - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('findQualifiedNameFromPath') - ->with(SUPPORTPATH . 'Commands/AppInfo.php') - ->willReturn(AppInfo::class); + ->willReturnMap([ + [SUPPORTPATH . 'Commands/Legacy/AppInfo.php', AppInfo::class], + [SUPPORTPATH . 'Commands/Modern/AppAboutCommand.php', AppAboutCommand::class], + ]); Services::injectMock('locator', $locator); $commands = new Commands(); // discoverCommands will be called in the constructor $commands->discoverCommands(); } + public function testDiscoverySkipsModernCommandWithoutCommandAttribute(): void + { + $path = SUPPORTPATH . 'InvalidCommands/NoAttributeCommand.php'; + + $locator = $this->createMock(FileLocatorInterface::class); + $locator + ->method('listFiles') + ->with('Commands/') + ->willReturn([$path]); + $locator + ->method('findQualifiedNameFromPath') + ->with($path) + ->willReturn(NoAttributeCommand::class); + Services::injectMock('locator', $locator); + + $commands = new Commands(); + + $this->assertSame([], $commands->getModernCommands()); + $this->assertSame([], $commands->getCommands()); + } + + public function testDiscoveryLogsErrorWhenCommandAttributeFailsToInstantiate(): void + { + $path = SUPPORTPATH . 'InvalidCommands/EmptyCommandName.php'; + + $locator = $this->createMock(FileLocatorInterface::class); + $locator + ->method('listFiles') + ->with('Commands/') + ->willReturn([$path]); + $locator + ->method('findQualifiedNameFromPath') + ->with($path) + ->willReturn(EmptyCommandName::class); + Services::injectMock('locator', $locator); + + $logger = $this->createMock(Logger::class); + $logger + ->expects($this->once()) + ->method('error') + ->with($this->callback(static fn (string $message): bool => $message !== '')); + + $commands = new Commands($logger); + + $this->assertSame([], $commands->getModernCommands()); + } + public function testDiscoverCommandsWithNoFiles(): void { $locator = $this->createMock(FileLocatorInterface::class); @@ -164,15 +350,45 @@ public function testDiscoverCommandsWithNoFiles(): void new Commands(); } - public function testDiscoveredCommandsCanBeOverridden(): void + public function testVerifyCommandThrowsDeprecationWhenCommandsArrayIsPassed(): void + { + $this->expectException(ErrorException::class); + $this->expectExceptionMessage('Since v4.8.0, the $commands parameter of CodeIgniter\CLI\Commands::verifyCommand() is no longer used.'); + + $commands = new Commands(); + $commands->verifyCommand('app:info', $commands->getCommands()); + } + + public function testGetCommandAlternativesThrowsDeprecationWhenCommandsArrayIsPassed(): void + { + $this->expectException(ErrorException::class); + $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()); + } + + public function testDiscoveredLegacyCommandsCanBeOverridden(): void + { + $this->copyCommand(SUPPORTPATH . '_command/AppInfo.php'); + + command('app:info'); + + $this->assertStringContainsString('This is App\Commands\AppInfo', $this->getStreamFilterBuffer()); + $this->assertStringNotContainsString('CodeIgniter Version:', $this->getStreamFilterBuffer()); + + $this->deleteCommand(SUPPORTPATH . '_command/AppInfo.php'); + } + + public function testDiscoveredModernCommandsCanBeOverridden(): void { - $this->copyAppListCommands(); + $this->copyCommand(SUPPORTPATH . '_command/AppAboutCommand.php'); - command('list'); + command('app:about a'); - $this->assertStringContainsString('This is App\Commands\ListCommands', $this->getStreamFilterBuffer()); - $this->assertStringNotContainsString('Displays basic usage information.', $this->getStreamFilterBuffer()); + $this->assertStringContainsString('This is App\Commands\AppAboutCommand', $this->getStreamFilterBuffer()); + $this->assertStringNotContainsString('CodeIgniter Version:', $this->getStreamFilterBuffer()); - $this->deleteAppListCommands(); + $this->deleteCommand(SUPPORTPATH . '_command/AppAboutCommand.php'); } } diff --git a/tests/system/CLI/ConsoleTest.php b/tests/system/CLI/ConsoleTest.php index bf4e34f58b7e..d5682cbcad48 100644 --- a/tests/system/CLI/ConsoleTest.php +++ b/tests/system/CLI/ConsoleTest.php @@ -22,11 +22,13 @@ use CodeIgniter\Test\Mock\MockCLIConfig; use CodeIgniter\Test\Mock\MockCodeIgniter; use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; /** * @internal */ +#[CoversClass(Console::class)] #[Group('Others')] final class ConsoleTest extends CIUnitTestCase { @@ -151,6 +153,19 @@ public function testHelpOptionIsOnlyPassed(): void $this->assertStringContainsString('Lists the available commands.', $this->getStreamFilterBuffer()); } + public function testHelpShortcutStripsOptionsMeantForTargetCommand(): void + { + // Options like `--host` are declared by `serve`, not by `help`. Prior + // behavior forwarded them to `help`, tripping the modern pipeline's + // unknown-option validator. The fix clears options once `--help` is + // detected so `help serve` renders cleanly. + $this->initializeConsole('serve', '--host=example.com', '--help'); + $exitCode = (new Console())->run(); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertStringContainsString('serve [options]', $this->getStreamFilterBuffer()); + } + public function testHelpArgumentAndHelpOptionCombined(): void { $this->initializeConsole('help', '--help'); @@ -160,6 +175,20 @@ public function testHelpArgumentAndHelpOptionCombined(): void $this->assertStringContainsString('Displays basic usage information.', $this->getStreamFilterBuffer()); } + public function testRunRoutesDiscoveredLegacyCommandThroughRunLegacy(): void + { + // `app:info` is a legacy BaseCommand fixture. Console must take the + // legacy branch of run() and delegate to Commands::runLegacy(). + $this->initializeConsole('app:info'); + $exitCode = (new Console())->run(); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertStringContainsString( + sprintf('CodeIgniter Version: %s', CodeIgniter::CI_VERSION), + $this->getStreamFilterBuffer(), + ); + } + public function testConsoleReturnsTheLastExecutedCommand(): void { $console = new Console(); @@ -167,7 +196,7 @@ public function testConsoleReturnsTheLastExecutedCommand(): void $this->initializeConsole(); $console->run(); - $this->assertSame(Console::DEFAULT_COMMAND, $console->getCommand()); + $this->assertSame('list', $console->getCommand()); $this->initializeConsole('help'); $console->run(); From 5cef8239c20831e96e85ac8a07a83220e3769c38 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 18 Apr 2026 21:39:26 +0800 Subject: [PATCH 06/11] migrate some built-in commands --- system/Commands/Cache/ClearCache.php | 72 ++----- system/Commands/Help.php | 194 ++++++++++++----- .../Commands/Housekeeping/ClearDebugbar.php | 44 +--- system/Commands/Housekeeping/ClearLogs.php | 72 +++---- system/Commands/ListCommands.php | 176 +++++++--------- system/Commands/Server/Serve.php | 119 ++++------- system/Language/en/CLI.php | 15 +- .../system/Commands/Cache/ClearCacheTest.php | 2 +- tests/system/Commands/HelpCommandTest.php | 198 ++++++++++++++++-- 9 files changed, 491 insertions(+), 401 deletions(-) diff --git a/system/Commands/Cache/ClearCache.php b/system/Commands/Cache/ClearCache.php index 32f9466a4939..2791384e9c65 100644 --- a/system/Commands/Cache/ClearCache.php +++ b/system/Commands/Cache/ClearCache.php @@ -13,75 +13,47 @@ namespace CodeIgniter\Commands\Cache; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Argument; use Config\Cache; /** - * Clears current cache. + * Clears the current system caches. */ -class ClearCache extends BaseCommand +#[Command(name: 'cache:clear', description: 'Clears the current system caches.', group: 'Cache')] +class ClearCache extends AbstractCommand { - /** - * Command grouping. - * - * @var string - */ - protected $group = 'Cache'; - - /** - * The Command's name - * - * @var string - */ - protected $name = 'cache:clear'; - - /** - * the Command's short description - * - * @var string - */ - protected $description = 'Clears the current system caches.'; - - /** - * the Command's usage - * - * @var string - */ - protected $usage = 'cache:clear []'; - - /** - * the Command's Arguments - * - * @var array - */ - protected $arguments = [ - 'driver' => 'The cache driver to use', - ]; + protected function configure(): void + { + $this->addArgument(new Argument( + name: 'driver', + description: 'The cache driver to use.', + default: config(Cache::class)->handler, + )); + } - /** - * Clears the cache - */ - public function run(array $params) + protected function execute(array $arguments, array $options): int { - $config = config(Cache::class); - $handler = $params[0] ?? $config->handler; + $driver = $arguments['driver']; + $config = config(Cache::class); - if (! array_key_exists($handler, $config->validHandlers)) { - CLI::error(lang('Cache.invalidHandler', [$handler])); + if (! array_key_exists($driver, $config->validHandlers)) { + CLI::error(lang('Cache.invalidHandler', [$driver])); return EXIT_ERROR; } - $config->handler = $handler; + $config->handler = $driver; if (! service('cache', $config)->clean()) { - CLI::error('Error while clearing the cache.'); + CLI::error('Error occurred while clearing the cache.'); return EXIT_ERROR; } - CLI::write(CLI::color('Cache cleared.', 'green')); + CLI::write('Cache cleared.', 'green'); return EXIT_SUCCESS; } diff --git a/system/Commands/Help.php b/system/Commands/Help.php index 84e50c0d5426..fd9033a71eb0 100644 --- a/system/Commands/Help.php +++ b/system/Commands/Help.php @@ -13,77 +13,157 @@ namespace CodeIgniter\Commands; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Argument; /** - * CI Help command for the spark script. - * - * Lists the basic usage information for the spark script, - * and provides a way to list help for other commands. + * Displays the basic usage information for a given command. */ -class Help extends BaseCommand +#[Command(name: 'help', description: 'Displays basic usage information.', group: 'CodeIgniter')] +class Help extends AbstractCommand { - /** - * The group the command is lumped under - * when listing commands. - * - * @var string - */ - protected $group = 'CodeIgniter'; + protected function configure(): void + { + $this->addArgument(new Argument( + name: 'command_name', + description: 'The command name.', + default: $this->getName(), + )); + } - /** - * The Command's name - * - * @var string - */ - protected $name = 'help'; + protected function execute(array $arguments, array $options): int + { + $command = $arguments['command_name']; + assert(is_string($command)); - /** - * the Command's short description - * - * @var string - */ - protected $description = 'Displays basic usage information.'; + $commands = $this->getCommandRunner(); - /** - * the Command's usage - * - * @var string - */ - protected $usage = 'help []'; + if (array_key_exists($command, $commands->getCommands())) { + $commands->getCommand($command)->showHelp(); - /** - * the Command's Arguments - * - * @var array - */ - protected $arguments = [ - 'command_name' => 'The command name [default: "help"]', - ]; + return EXIT_SUCCESS; + } - /** - * the Command's Options - * - * @var array - */ - protected $options = []; + if (! $commands->verifyCommand($command, legacy: false)) { + return EXIT_ERROR; + } - /** - * Displays the help for spark commands. - */ - public function run(array $params) + $this->describeHelp($commands->getCommand($command, legacy: false)); + + return EXIT_SUCCESS; + } + + private function describeHelp(AbstractCommand $command): void { - $command = array_shift($params); - $command ??= 'help'; - $commands = $this->commands->getCommands(); + CLI::write(lang('CLI.helpUsage'), 'yellow'); - if (! $this->commands->verifyCommand($command, $commands)) { - return EXIT_ERROR; + foreach ($command->getUsages() as $usage) { + CLI::write($this->addPadding($usage)); } - $class = new $commands[$command]['class']($this->logger, $this->commands); - $class->showHelp(); + if ($command->getDescription() !== '') { + CLI::newLine(); + CLI::write(lang('CLI.helpDescription'), 'yellow'); + CLI::write($this->addPadding($command->getDescription())); + } - return EXIT_SUCCESS; + $maxPadding = $this->getMaxPadding($command); + + if ($command->getArgumentsDefinition() !== []) { + CLI::newLine(); + CLI::write(lang('CLI.helpArguments'), 'yellow'); + + foreach ($command->getArgumentsDefinition() as $argument => $definition) { + $default = ''; + + if (! $definition->required) { + $default = sprintf(' [default: %s]', $this->formatDefaultValue($definition->default)); + } + + CLI::write(sprintf( + '%s%s%s', + CLI::color($this->addPadding($argument, 2, $maxPadding), 'green'), + $definition->description, + CLI::color($default, 'yellow'), + )); + } + } + + if ($command->getOptionsDefinition() !== []) { + CLI::newLine(); + CLI::write(lang('CLI.helpOptions'), 'yellow'); + + $hasShortcuts = $command->getShortcuts() !== []; + + foreach ($command->getOptionsDefinition() as $option => $definition) { + $value = ''; + + if ($definition->acceptsValue) { + $value = sprintf('=%s', strtoupper($definition->valueLabel ?? '')); + + if (! $definition->requiresValue) { + $value = sprintf('[%s]', $value); + } + } + + $optionString = sprintf( + '%s--%s%s%s', + $definition->shortcut !== null + ? sprintf('-%s, ', $definition->shortcut) + : ($hasShortcuts ? ' ' : ''), + $option, + $value, + $definition->negation !== null ? sprintf('|--%s', $definition->negation) : '', + ); + + CLI::write(sprintf( + '%s%s%s', + CLI::color($this->addPadding($optionString, 2, $maxPadding), 'green'), + $definition->description, + $definition->isArray ? CLI::color(' (multiple values allowed)', 'yellow') : '', + )); + } + } + } + + private function addPadding(string $item, int $before = 2, ?int $max = null): string + { + return str_pad(str_repeat(' ', $before) . $item, $max ?? (strlen($item) + $before)); + } + + private function getMaxPadding(AbstractCommand $command): int + { + $max = 0; + + foreach (array_keys($command->getArgumentsDefinition()) as $argument) { + $max = max($max, strlen($argument)); + } + + $hasShortcuts = $command->getShortcuts() !== []; + + foreach ($command->getOptionsDefinition() as $option => $definition) { + $optionLength = strlen($option) + 2 // Account for the "--" prefix on options. + + ($definition->acceptsValue ? strlen($definition->valueLabel ?? '') + ($definition->requiresValue ? 1 : 3) : 0) // Account for the "=%s" value notation if the option accepts a value. + + ($hasShortcuts ? 4 : 0) // Account for the "-%s, " shortcut notation if shortcuts are present. + + ($definition->negation !== null ? 3 + strlen($definition->negation) : 0); // Account for the "|--no-%s" negation notation if a negation exists for this option. + + $max = max($max, $optionLength); + } + + return $max + 4; // Account for the extra padding around the option/argument. + } + + /** + * @param list|string $value + */ + private function formatDefaultValue(array|string $value): string + { + if (is_array($value)) { + return sprintf('[%s]', implode(', ', array_map($this->formatDefaultValue(...), $value))); + } + + return sprintf('"%s"', $value); } } diff --git a/system/Commands/Housekeeping/ClearDebugbar.php b/system/Commands/Housekeeping/ClearDebugbar.php index 281a2c865d6b..bc4ef4dfdcc7 100644 --- a/system/Commands/Housekeeping/ClearDebugbar.php +++ b/system/Commands/Housekeeping/ClearDebugbar.php @@ -13,51 +13,21 @@ namespace CodeIgniter\Commands\Housekeeping; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; /** - * ClearDebugbar Command + * Clears all debugbar JSON files. */ -class ClearDebugbar extends BaseCommand +#[Command(name: 'debugbar:clear', description: 'Clears all debugbar JSON files.', group: 'Housekeeping')] +class ClearDebugbar extends AbstractCommand { - /** - * The group the command is lumped under - * when listing commands. - * - * @var string - */ - protected $group = 'Housekeeping'; - - /** - * The Command's name - * - * @var string - */ - protected $name = 'debugbar:clear'; - - /** - * The Command's usage - * - * @var string - */ - protected $usage = 'debugbar:clear'; - - /** - * The Command's short description. - * - * @var string - */ - protected $description = 'Clears all debugbar JSON files.'; - - /** - * Actually runs the command. - */ - public function run(array $params) + protected function execute(array $arguments, array $options): int { helper('filesystem'); - if (! delete_files(WRITEPATH . 'debugbar', false, true)) { + if (! delete_files(WRITEPATH . 'debugbar', htdocs: true)) { CLI::error('Error deleting the debugbar JSON files.'); return EXIT_ERROR; diff --git a/system/Commands/Housekeeping/ClearLogs.php b/system/Commands/Housekeeping/ClearLogs.php index 71f299969055..794b60006e18 100644 --- a/system/Commands/Housekeeping/ClearLogs.php +++ b/system/Commands/Housekeeping/ClearLogs.php @@ -13,60 +13,42 @@ namespace CodeIgniter\Commands\Housekeeping; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; /** - * ClearLogs command. + * Clears all log files. */ -class ClearLogs extends BaseCommand +#[Command(name: 'logs:clear', description: 'Clears all log files.', group: 'Housekeeping')] +class ClearLogs extends AbstractCommand { - /** - * The group the command is lumped under - * when listing commands. - * - * @var string - */ - protected $group = 'Housekeeping'; - - /** - * The Command's name - * - * @var string - */ - protected $name = 'logs:clear'; + protected function configure(): void + { + $this->addOption(new Option( + name: 'force', + shortcut: 'f', + description: 'Forces the clearing of log files without confirmation.', + )); + } - /** - * The Command's short description - * - * @var string - */ - protected $description = 'Clears all log files.'; + protected function interact(array &$arguments, array &$options): void + { + if ($this->hasUnboundOption('force', $options)) { + return; + } - /** - * The Command's usage - * - * @var string - */ - protected $usage = 'logs:clear [option'; + if (CLI::prompt('Are you sure you want to delete the logs?', ['n', 'y']) === 'n') { + return; + } - /** - * The Command's options - * - * @var array - */ - protected $options = [ - '--force' => 'Force delete of all logs files without prompting.', - ]; + $options['force'] = null; // simulate the presence of the --force option + } - /** - * Actually execute a command. - */ - public function run(array $params) + protected function execute(array $arguments, array $options): int { - $force = array_key_exists('force', $params) || CLI::getOption('force'); - - if (! $force && CLI::prompt('Are you sure you want to delete the logs?', ['n', 'y']) === 'n') { + if ($options['force'] === false) { CLI::error('Deleting logs aborted.'); CLI::error('If you want, use the "--force" option to force delete all log files.'); @@ -75,7 +57,7 @@ public function run(array $params) helper('filesystem'); - if (! delete_files(WRITEPATH . 'logs', false, true)) { + if (! delete_files(WRITEPATH . 'logs', htdocs: true)) { CLI::error('Error in deleting the logs files.'); return EXIT_ERROR; diff --git a/system/Commands/ListCommands.php b/system/Commands/ListCommands.php index 8761e2bb7bbc..29dc5f2e91a6 100644 --- a/system/Commands/ListCommands.php +++ b/system/Commands/ListCommands.php @@ -13,134 +13,108 @@ namespace CodeIgniter\Commands; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; /** - * CI Help command for the spark script. - * - * Lists the basic usage information for the spark script, - * and provides a way to list help for other commands. + * Lists the available commands. */ -class ListCommands extends BaseCommand +#[Command(name: 'list', description: 'Lists the available commands.', group: 'CodeIgniter')] +class ListCommands extends AbstractCommand { - /** - * The group the command is lumped under - * when listing commands. - * - * @var string - */ - protected $group = 'CodeIgniter'; - - /** - * The Command's name - * - * @var string - */ - protected $name = 'list'; - - /** - * the Command's short description - * - * @var string - */ - protected $description = 'Lists the available commands.'; - - /** - * the Command's usage - * - * @var string - */ - protected $usage = 'list'; - - /** - * the Command's Arguments - * - * @var array - */ - protected $arguments = []; - - /** - * the Command's Options - * - * @var array - */ - protected $options = [ - '--simple' => 'Prints a list of the commands with no other info', - ]; - - /** - * Displays the help for the spark cli script itself. - * - * @return int - */ - public function run(array $params) + protected function configure(): void { - $commands = $this->commands->getCommands(); - ksort($commands); - - // Check for 'simple' format - return array_key_exists('simple', $params) || CLI::getOption('simple') === true - ? $this->listSimple($commands) - : $this->listFull($commands); + $this->addOption(new Option( + name: 'simple', + description: 'Prints a list of commands with no other information.', + )); } - /** - * Lists the commands with accompanying info. - * - * @return int - */ - protected function listFull(array $commands) + protected function execute(array $arguments, array $options): int { - // Sort into buckets by group - $groups = []; + if ($options['simple'] === true) { + return $this->describeCommandsSimple(); + } - foreach ($commands as $title => $command) { - if (! isset($groups[$command['group']])) { - $groups[$command['group']] = []; - } + return $this->describeCommandsDetailed(); + } - $groups[$command['group']][$title] = $command; + private function describeCommandsSimple(): int + { + $commands = [ + ...array_keys($this->getCommandRunner()->getCommands()), + ...array_keys($this->getCommandRunner()->getModernCommands()), + ]; + sort($commands); + + foreach ($commands as $command) { + CLI::write($command); } - $length = max(array_map(strlen(...), array_keys($commands))); + return EXIT_SUCCESS; + } - ksort($groups); + private function describeCommandsDetailed(): int + { + CLI::write(lang('CLI.helpUsage'), 'yellow'); + CLI::write($this->addPadding('command [options] [--] [arguments]')); - // Display it all... - foreach ($groups as $group => $commands) { - CLI::write($group, 'yellow'); + $entries = []; + $maxPad = 0; - foreach ($commands as $name => $command) { - $name = $this->setPad($name, $length, 2, 2); - $output = CLI::color($name, 'green'); + foreach ([ + ...$this->getCommandRunner()->getCommands(), + ...$this->getCommandRunner()->getModernCommands(), + ] as $command => $details) { + $maxPad = max($maxPad, strlen($command) + 4); - if (isset($command['description'])) { - $output .= CLI::wrap($command['description'], 125, strlen($name)); - } + $entries[] = [$details['group'], $command, $details['description']]; + } + + usort($entries, static function (array $a, array $b): int { + $cmp = strcmp($a[0], $b[0]); - CLI::write($output); + if ($cmp !== 0) { + return $cmp; } - if ($group !== array_key_last($groups)) { + return strcmp($a[1], $b[1]); + }); + + $groups = []; + + foreach ($entries as [$group, $command, $description]) { + $groups[$group][] = [$command, $description]; + } + + CLI::newLine(); + CLI::write(lang('CLI.helpAvailableCommands'), 'yellow'); + + $firstGroup = array_key_first($groups); + + foreach ($groups as $group => $commands) { + if ($group !== $firstGroup) { CLI::newLine(); } + + CLI::write($group, 'yellow'); + + foreach ($commands as $command) { + CLI::write(sprintf( + '%s%s', + CLI::color($this->addPadding($command[0], 2, $maxPad), 'green'), + CLI::wrap($command[1]), + )); + } } return EXIT_SUCCESS; } - /** - * Lists the commands only. - * - * @return int - */ - protected function listSimple(array $commands) + private function addPadding(string $item, int $before = 2, ?int $max = null): string { - foreach (array_keys($commands) as $title) { - CLI::write($title); - } - - return EXIT_SUCCESS; + return str_pad(str_repeat(' ', $before) . $item, $max ?? (strlen($item) + $before)); } } diff --git a/system/Commands/Server/Serve.php b/system/Commands/Server/Serve.php index f02ec0c0933d..061f6d8da3dc 100644 --- a/system/Commands/Server/Serve.php +++ b/system/Commands/Server/Serve.php @@ -13,107 +13,60 @@ namespace CodeIgniter\Commands\Server; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; /** - * Launch the PHP development server - * - * Not testable, as it throws phpunit for a loop :-/ - * - * @codeCoverageIgnore + * Launches the CodeIgniter PHP-Development Server. */ -class Serve extends BaseCommand +#[Command(name: 'serve', description: 'Launches the CodeIgniter PHP-Development Server.', group: 'CodeIgniter')] +class Serve extends AbstractCommand { - /** - * Group - * - * @var string - */ - protected $group = 'CodeIgniter'; - - /** - * Name - * - * @var string - */ - protected $name = 'serve'; - - /** - * Description - * - * @var string - */ - protected $description = 'Launches the CodeIgniter PHP-Development Server.'; - - /** - * Usage - * - * @var string - */ - protected $usage = 'serve'; - - /** - * Arguments - * - * @var array - */ - protected $arguments = []; - /** * The current port offset. - * - * @var int */ - protected $portOffset = 0; + private int $portOffset = 0; /** - * The max number of ports to attempt to serve from - * - * @var int + * The number of times to retry if the port is already in use. */ - protected $tries = 10; + private int $retries = 10; - /** - * Options - * - * @var array - */ - protected $options = [ - '--php' => 'The PHP Binary [default: "PHP_BINARY"]', - '--host' => 'The HTTP Host [default: "localhost"]', - '--port' => 'The HTTP Host Port [default: "8080"]', - ]; + protected function configure(): void + { + $this + ->addOption(new Option(name: 'php', description: 'The PHP binary to use.', acceptsValue: true, default: PHP_BINARY)) + ->addOption(new Option(name: 'host', description: 'The host to serve on.', acceptsValue: true, default: 'localhost')) + ->addOption(new Option(name: 'port', description: 'The port to serve on.', acceptsValue: true, default: '8080')); + } - /** - * Run the server - */ - public function run(array $params) + protected function execute(array $arguments, array $options): int { - // Collect any user-supplied options and apply them. - $php = escapeshellarg(CLI::getOption('php') ?? PHP_BINARY); - $host = CLI::getOption('host') ?? 'localhost'; - $port = (int) (CLI::getOption('port') ?? 8080) + $this->portOffset; + $port = (int) $options['port'] + $this->portOffset; - // Get the party started. - CLI::write('CodeIgniter development server started on http://' . $host . ':' . $port, 'green'); + CLI::write(sprintf('CodeIgniter development server started on http://%s:%s', $options['host'], $port), 'green'); CLI::write('Press Control-C to stop.'); - - // Set the Front Controller path as Document Root. - $docroot = escapeshellarg(FCPATH); - - // Mimic Apache's mod_rewrite functionality with user settings. - $rewrite = escapeshellarg(SYSTEMPATH . 'rewrite.php'); - - // Call PHP's built-in webserver, making sure to set our - // base path to the public folder, and to use the rewrite file - // to ensure our environment is set and it simulates basic mod_rewrite. - passthru($php . ' -S ' . $host . ':' . $port . ' -t ' . $docroot . ' ' . $rewrite, $status); - - if ($status !== EXIT_SUCCESS && $this->portOffset < $this->tries) { + CLI::newLine(); + + passthru( + sprintf( + '%s -S %s:%s -t %s %s', + escapeshellarg($options['php']), + escapeshellarg($options['host']), + escapeshellarg((string) $port), + escapeshellarg(FCPATH), + escapeshellarg(SYSTEMPATH . 'rewrite.php'), + ), + $status, + ); + + if ($status !== EXIT_SUCCESS && $this->portOffset < $this->retries) { + CLI::newLine(); $this->portOffset++; - return $this->run($params); + return $this->execute($arguments, $options); } return $status; diff --git a/system/Language/en/CLI.php b/system/Language/en/CLI.php index 01e60c402955..f5c653e1edb5 100644 --- a/system/Language/en/CLI.php +++ b/system/Language/en/CLI.php @@ -47,13 +47,14 @@ 'cell' => 'Cell view name', ], ], - 'helpArguments' => 'Arguments:', - 'helpDescription' => 'Description:', - 'helpOptions' => 'Options:', - 'helpUsage' => 'Usage:', - 'invalidColor' => 'Invalid "{0}" color: "{1}".', - 'namespaceNotDefined' => 'Namespace "{0}" is not defined.', - 'signals' => [ + 'helpArguments' => 'Arguments:', + 'helpAvailableCommands' => 'Available commands:', + 'helpDescription' => 'Description:', + 'helpOptions' => 'Options:', + 'helpUsage' => 'Usage:', + 'invalidColor' => 'Invalid "{0}" color: "{1}".', + 'namespaceNotDefined' => 'Namespace "{0}" is not defined.', + 'signals' => [ 'noPcntlExtension' => 'PCNTL extension not available. Signal handling disabled.', 'noPosixExtension' => 'SIGTSTP/SIGCONT handling requires POSIX extension. These signals will be removed from registration.', 'failedSignal' => 'Failed to register handler for signal: "{0}".', diff --git a/tests/system/Commands/Cache/ClearCacheTest.php b/tests/system/Commands/Cache/ClearCacheTest.php index a845eccda139..4f84a10902d8 100644 --- a/tests/system/Commands/Cache/ClearCacheTest.php +++ b/tests/system/Commands/Cache/ClearCacheTest.php @@ -82,7 +82,7 @@ public function testClearCacheFails(): void command('cache:clear'); $this->assertSame( - "\nError while clearing the cache.\n", + "\nError occurred while clearing the cache.\n", preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()), ); } diff --git a/tests/system/Commands/HelpCommandTest.php b/tests/system/Commands/HelpCommandTest.php index 8f22d645b53a..b233b590df10 100644 --- a/tests/system/Commands/HelpCommandTest.php +++ b/tests/system/Commands/HelpCommandTest.php @@ -13,9 +13,12 @@ namespace CodeIgniter\Commands; +use CodeIgniter\CLI\CLI; use CodeIgniter\CodeIgniter; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\Group; /** @@ -26,43 +29,198 @@ final class HelpCommandTest extends CIUnitTestCase { use StreamFilterTrait; - protected function getBuffer(): string + #[After] + #[Before] + protected function resetCli(): void { - return $this->getStreamFilterBuffer(); + CLI::reset(); } - public function testHelpCommand(): void + private function getUndecoratedBuffer(): string + { + return preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()) ?? ''; + } + + public function testNoArgumentDescribesItself(): void { command('help'); - // make sure the result looks like a command list - $this->assertStringContainsString('Displays basic usage information.', $this->getBuffer()); - $this->assertStringContainsString('command_name', $this->getBuffer()); + $this->assertSame( + <<<'EOT' + + Usage: + help [options] [--] [] + + Description: + Displays basic usage information. + + Arguments: + command_name The command name. [default: "help"] + + Options: + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + + EOT, + $this->getUndecoratedBuffer(), + ); } - public function testHelpCommandWithMissingUsage(): void + public function testDescribeCommandNoArguments(): void { - command('help app:info'); - $this->assertStringContainsString('app:info [arguments]', $this->getBuffer()); + command('help app:about'); + + $this->assertSame( + <<<'EOT' + + Usage: + app:about [options] [--] [] [...] + app:about required-value + + Description: + Displays basic application information. + + Arguments: + required Unused required argument. + optional Unused optional argument. [default: "val"] + array Unused array argument. [default: ["a", "b"]] + + Options: + -f, --foo=FOO Option that requires a value. + -a, --bar[=BAR] Option that optionally accepts a value. + -b, --baz=BAZ Option that allows multiple values. (multiple values allowed) + --quux|--no-quux Negatable option. + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + + EOT, + $this->getUndecoratedBuffer(), + ); } - public function testHelpCommandOnSpecificCommand(): void + public function testDescribeSpecificCommand(): void { command('help cache:clear'); - $this->assertStringContainsString('Clears the current system caches.', $this->getBuffer()); + + $this->assertSame( + <<<'EOT' + + Usage: + cache:clear [options] [--] [] + + Description: + Clears the current system caches. + + Arguments: + driver The cache driver to use. [default: "file"] + + Options: + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testDescribeLegacyCommandUsesLegacyShowHelp(): void + { + // `app:info` is a legacy BaseCommand fixture. Help must take the + // legacy branch and delegate to BaseCommand::showHelp() instead of + // rendering via the modern describeHelp() pipeline. + command('help app:info'); + + $this->assertSame( + <<<'EOT' + + Usage: + app:info [arguments] + + Description: + Displays basic application information. + + Arguments: + draft unused + + EOT, + $this->getUndecoratedBuffer(), + ); } - public function testHelpCommandOnInexistentCommand(): void + public function testDescribeInexistentCommand(): void { command('help fixme'); - $this->assertStringContainsString('Command "fixme" not found', $this->getBuffer()); + + $this->assertSame("\nCommand \"fixme\" not found.\n", $this->getUndecoratedBuffer()); } - public function testHelpCommandOnInexistentCommandButWithAlternatives(): void + public function testDescribeInexistentCommandButWithAlternatives(): void { command('help clear'); - $this->assertStringContainsString('Command "clear" not found.', $this->getBuffer()); - $this->assertStringContainsString('Did you mean one of these?', $this->getBuffer()); + + $this->assertSame( + <<<'EOT' + + Command "clear" not found. + + Did you mean one of these? + cache:clear + debugbar:clear + logs:clear + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testDescribeUsingHelpOption(): void + { + command('cache:clear --help'); + + $this->assertSame( + <<<'EOT' + + Usage: + cache:clear [options] [--] [] + + Description: + Clears the current system caches. + + Arguments: + driver The cache driver to use. [default: "file"] + + Options: + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testDescribeUsingHelpShortOption(): void + { + command('cache:clear -h'); + + $this->assertSame( + <<<'EOT' + + Usage: + cache:clear [options] [--] [] + + Description: + Clears the current system caches. + + Arguments: + driver The cache driver to use. [default: "file"] + + Options: + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + + EOT, + $this->getUndecoratedBuffer(), + ); } public function testNormalHelpCommandHasNoBanner(): void @@ -71,9 +229,9 @@ public function testNormalHelpCommandHasNoBanner(): void $this->assertStringNotContainsString( sprintf('CodeIgniter %s Command Line Tool', CodeIgniter::CI_VERSION), - $this->getBuffer(), + $this->getStreamFilterBuffer(), ); - $this->assertStringContainsString('Displays basic usage information.', $this->getBuffer()); + $this->assertStringContainsString('Displays basic usage information.', $this->getStreamFilterBuffer()); } public function testHelpCommandWithDoubleHyphenStillRemovesBanner(): void @@ -82,8 +240,8 @@ public function testHelpCommandWithDoubleHyphenStillRemovesBanner(): void $this->assertStringNotContainsString( sprintf('CodeIgniter %s Command Line Tool', CodeIgniter::CI_VERSION), - $this->getBuffer(), + $this->getStreamFilterBuffer(), ); - $this->assertStringContainsString('Lists the available commands.', $this->getBuffer()); + $this->assertStringContainsString('Lists the available commands.', $this->getStreamFilterBuffer()); } } From a05a54f2dfe52131c08799c0d0a046b9ab78ffea Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 18 Apr 2026 22:38:41 +0800 Subject: [PATCH 07/11] move legacy command fixtures --- rector.php | 2 +- tests/_support/Commands/{ => Legacy}/AbstractInfo.php | 2 +- tests/_support/Commands/{ => Legacy}/AppInfo.php | 4 ++-- .../_support/Commands/{ => Legacy}/DestructiveCommand.php | 2 +- tests/_support/Commands/{ => Legacy}/Foobar.php | 0 tests/_support/Commands/{ => Legacy}/InvalidCommand.php | 4 ++-- tests/_support/Commands/{ => Legacy}/LanguageCommand.php | 2 +- tests/_support/Commands/{ => Legacy}/SignalCommand.php | 2 +- .../Commands/{ => Legacy}/SignalCommandNoPcntl.php | 2 +- .../Commands/{ => Legacy}/SignalCommandNoPosix.php | 2 +- tests/_support/Commands/{ => Legacy}/Unsuffixable.php | 2 +- tests/_support/_command/{ListCommands.php => AppInfo.php} | 8 ++++---- tests/system/CLI/BaseCommandTest.php | 2 +- tests/system/CLI/SignalTest.php | 6 +++--- tests/system/Commands/ConfigurableSortImportsTest.php | 6 +++--- .../src/PhpCsFixer/CodeIgniterRuleCustomisationPolicy.php | 2 +- 16 files changed, 24 insertions(+), 24 deletions(-) rename tests/_support/Commands/{ => Legacy}/AbstractInfo.php (92%) rename tests/_support/Commands/{ => Legacy}/AppInfo.php (93%) rename tests/_support/Commands/{ => Legacy}/DestructiveCommand.php (94%) rename tests/_support/Commands/{ => Legacy}/Foobar.php (100%) rename tests/_support/Commands/{ => Legacy}/InvalidCommand.php (84%) rename tests/_support/Commands/{ => Legacy}/LanguageCommand.php (97%) rename tests/_support/Commands/{ => Legacy}/SignalCommand.php (98%) rename tests/_support/Commands/{ => Legacy}/SignalCommandNoPcntl.php (93%) rename tests/_support/Commands/{ => Legacy}/SignalCommandNoPosix.php (93%) rename tests/_support/Commands/{ => Legacy}/Unsuffixable.php (97%) rename tests/_support/_command/{ListCommands.php => AppInfo.php} (80%) diff --git a/rector.php b/rector.php index 9880dffe9d51..6cb9881f0cda 100644 --- a/rector.php +++ b/rector.php @@ -83,7 +83,7 @@ __DIR__ . '/system/ThirdParty', __DIR__ . '/tests/system/Config/fixtures', __DIR__ . '/tests/system/Filters/fixtures', - __DIR__ . '/tests/_support/Commands/Foobar.php', + __DIR__ . '/tests/_support/Commands/Legacy/Foobar.php', __DIR__ . '/tests/_support/View', __DIR__ . '/tests/system/View/Views', diff --git a/tests/_support/Commands/AbstractInfo.php b/tests/_support/Commands/Legacy/AbstractInfo.php similarity index 92% rename from tests/_support/Commands/AbstractInfo.php rename to tests/_support/Commands/Legacy/AbstractInfo.php index 3195688db2ce..5e9a2808ec84 100644 --- a/tests/_support/Commands/AbstractInfo.php +++ b/tests/_support/Commands/Legacy/AbstractInfo.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace Tests\Support\Commands; +namespace Tests\Support\Commands\Legacy; use CodeIgniter\CLI\BaseCommand; diff --git a/tests/_support/Commands/AppInfo.php b/tests/_support/Commands/Legacy/AppInfo.php similarity index 93% rename from tests/_support/Commands/AppInfo.php rename to tests/_support/Commands/Legacy/AppInfo.php index 80e080b8bc85..ac5b11b6416d 100644 --- a/tests/_support/Commands/AppInfo.php +++ b/tests/_support/Commands/Legacy/AppInfo.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace Tests\Support\Commands; +namespace Tests\Support\Commands\Legacy; use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; @@ -50,6 +50,6 @@ public function bomb(): int public function helpMe(): int { - return $this->call('help'); + return $this->call('help:legacy'); } } diff --git a/tests/_support/Commands/DestructiveCommand.php b/tests/_support/Commands/Legacy/DestructiveCommand.php similarity index 94% rename from tests/_support/Commands/DestructiveCommand.php rename to tests/_support/Commands/Legacy/DestructiveCommand.php index 723b880b0cd4..b7320a7e2563 100644 --- a/tests/_support/Commands/DestructiveCommand.php +++ b/tests/_support/Commands/Legacy/DestructiveCommand.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace Tests\Support\Commands; +namespace Tests\Support\Commands\Legacy; use RuntimeException; diff --git a/tests/_support/Commands/Foobar.php b/tests/_support/Commands/Legacy/Foobar.php similarity index 100% rename from tests/_support/Commands/Foobar.php rename to tests/_support/Commands/Legacy/Foobar.php diff --git a/tests/_support/Commands/InvalidCommand.php b/tests/_support/Commands/Legacy/InvalidCommand.php similarity index 84% rename from tests/_support/Commands/InvalidCommand.php rename to tests/_support/Commands/Legacy/InvalidCommand.php index fcd30cd3befe..8d6fadb80dfd 100644 --- a/tests/_support/Commands/InvalidCommand.php +++ b/tests/_support/Commands/Legacy/InvalidCommand.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace Tests\Support\Commands; +namespace Tests\Support\Commands\Legacy; use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; @@ -26,7 +26,7 @@ class InvalidCommand extends BaseCommand public function __construct() { - throw new ReflectionException(); + throw new ReflectionException('This command is invalid and should not be instantiated.'); } public function run(array $params): int diff --git a/tests/_support/Commands/LanguageCommand.php b/tests/_support/Commands/Legacy/LanguageCommand.php similarity index 97% rename from tests/_support/Commands/LanguageCommand.php rename to tests/_support/Commands/Legacy/LanguageCommand.php index dac34083708f..fe9efe692493 100644 --- a/tests/_support/Commands/LanguageCommand.php +++ b/tests/_support/Commands/Legacy/LanguageCommand.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace Tests\Support\Commands; +namespace Tests\Support\Commands\Legacy; use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\GeneratorTrait; diff --git a/tests/_support/Commands/SignalCommand.php b/tests/_support/Commands/Legacy/SignalCommand.php similarity index 98% rename from tests/_support/Commands/SignalCommand.php rename to tests/_support/Commands/Legacy/SignalCommand.php index 219f76a48296..490efe6e1509 100644 --- a/tests/_support/Commands/SignalCommand.php +++ b/tests/_support/Commands/Legacy/SignalCommand.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace Tests\Support\Commands; +namespace Tests\Support\Commands\Legacy; use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\SignalTrait; diff --git a/tests/_support/Commands/SignalCommandNoPcntl.php b/tests/_support/Commands/Legacy/SignalCommandNoPcntl.php similarity index 93% rename from tests/_support/Commands/SignalCommandNoPcntl.php rename to tests/_support/Commands/Legacy/SignalCommandNoPcntl.php index 2903c714afb3..43a5d0f9e65d 100644 --- a/tests/_support/Commands/SignalCommandNoPcntl.php +++ b/tests/_support/Commands/Legacy/SignalCommandNoPcntl.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace Tests\Support\Commands; +namespace Tests\Support\Commands\Legacy; /** * Mock command that simulates missing PCNTL extension diff --git a/tests/_support/Commands/SignalCommandNoPosix.php b/tests/_support/Commands/Legacy/SignalCommandNoPosix.php similarity index 93% rename from tests/_support/Commands/SignalCommandNoPosix.php rename to tests/_support/Commands/Legacy/SignalCommandNoPosix.php index 02ce3a1fcefd..caafbd5db2aa 100644 --- a/tests/_support/Commands/SignalCommandNoPosix.php +++ b/tests/_support/Commands/Legacy/SignalCommandNoPosix.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace Tests\Support\Commands; +namespace Tests\Support\Commands\Legacy; /** * Mock command that simulates missing POSIX extension diff --git a/tests/_support/Commands/Unsuffixable.php b/tests/_support/Commands/Legacy/Unsuffixable.php similarity index 97% rename from tests/_support/Commands/Unsuffixable.php rename to tests/_support/Commands/Legacy/Unsuffixable.php index 48856c82c286..5d25e0b0e29a 100644 --- a/tests/_support/Commands/Unsuffixable.php +++ b/tests/_support/Commands/Legacy/Unsuffixable.php @@ -11,7 +11,7 @@ * the LICENSE file that was distributed with this source code. */ -namespace Tests\Support\Commands; +namespace Tests\Support\Commands\Legacy; use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\GeneratorTrait; diff --git a/tests/_support/_command/ListCommands.php b/tests/_support/_command/AppInfo.php similarity index 80% rename from tests/_support/_command/ListCommands.php rename to tests/_support/_command/AppInfo.php index 5b73548d89de..775c6871d948 100644 --- a/tests/_support/_command/ListCommands.php +++ b/tests/_support/_command/AppInfo.php @@ -19,7 +19,7 @@ /** * @internal */ -final class ListCommands extends BaseCommand +final class AppInfo extends BaseCommand { /** * @var string @@ -29,17 +29,17 @@ final class ListCommands extends BaseCommand /** * @var string */ - protected $name = 'list'; + protected $name = 'app:info'; /** * @var string */ - protected $description = 'This is testing to override `list` command.'; + protected $description = 'This is testing to override `app:info` command.'; /** * @var string */ - protected $usage = 'list'; + protected $usage = 'app:info'; /** * Displays the help for the spark cli script itself. diff --git a/tests/system/CLI/BaseCommandTest.php b/tests/system/CLI/BaseCommandTest.php index 143851dee9ce..7923f7667ed2 100644 --- a/tests/system/CLI/BaseCommandTest.php +++ b/tests/system/CLI/BaseCommandTest.php @@ -22,7 +22,7 @@ use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; -use Tests\Support\Commands\AppInfo; +use Tests\Support\Commands\Legacy\AppInfo; /** * @internal diff --git a/tests/system/CLI/SignalTest.php b/tests/system/CLI/SignalTest.php index 8e444895f226..ab92a135ebb5 100644 --- a/tests/system/CLI/SignalTest.php +++ b/tests/system/CLI/SignalTest.php @@ -17,9 +17,9 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; use PHPUnit\Framework\Attributes\Group; -use Tests\Support\Commands\SignalCommand; -use Tests\Support\Commands\SignalCommandNoPcntl; -use Tests\Support\Commands\SignalCommandNoPosix; +use Tests\Support\Commands\Legacy\SignalCommand; +use Tests\Support\Commands\Legacy\SignalCommandNoPcntl; +use Tests\Support\Commands\Legacy\SignalCommandNoPosix; /** * @internal diff --git a/tests/system/Commands/ConfigurableSortImportsTest.php b/tests/system/Commands/ConfigurableSortImportsTest.php index 4f54390d7ffe..e09aa6b8e9ee 100644 --- a/tests/system/Commands/ConfigurableSortImportsTest.php +++ b/tests/system/Commands/ConfigurableSortImportsTest.php @@ -32,7 +32,7 @@ public function testPublishLanguageWithoutOptions(): void $file = APPPATH . 'Language/en/Foobar.php'; $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); $this->assertFileExists($file); - $this->assertNotSame(sha1_file(SUPPORTPATH . 'Commands/Foobar.php'), sha1_file($file)); + $this->assertNotSame(sha1_file(SUPPORTPATH . 'Commands/Legacy/Foobar.php'), sha1_file($file)); if (is_file($file)) { unlink($file); } @@ -45,7 +45,7 @@ public function testEnabledSortImportsWillDisruptLanguageFilePublish(): void $file = APPPATH . 'Language/es/Foobar.php'; $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); $this->assertFileExists($file); - $this->assertNotSame(sha1_file(SUPPORTPATH . 'Commands/Foobar.php'), sha1_file($file)); + $this->assertNotSame(sha1_file(SUPPORTPATH . 'Commands/Legacy/Foobar.php'), sha1_file($file)); if (is_file($file)) { unlink($file); } @@ -62,7 +62,7 @@ public function testDisabledSortImportsWillNotAffectLanguageFilesPublish(): void $file = APPPATH . 'Language/ar/Foobar.php'; $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); $this->assertFileExists($file); - $this->assertSame(sha1_file(SUPPORTPATH . 'Commands/Foobar.php'), sha1_file($file)); + $this->assertSame(sha1_file(SUPPORTPATH . 'Commands/Legacy/Foobar.php'), sha1_file($file)); if (is_file($file)) { unlink($file); } diff --git a/utils/src/PhpCsFixer/CodeIgniterRuleCustomisationPolicy.php b/utils/src/PhpCsFixer/CodeIgniterRuleCustomisationPolicy.php index ad2f64ba07a2..46f987ecdeb8 100644 --- a/utils/src/PhpCsFixer/CodeIgniterRuleCustomisationPolicy.php +++ b/utils/src/PhpCsFixer/CodeIgniterRuleCustomisationPolicy.php @@ -43,7 +43,7 @@ public function getRuleCustomisers(): array ), 'ordered_imports' => static fn (SplFileInfo $file): bool => ! $normalisedStrEndsWith( $file->getPathname(), - '/tests/_support/Commands/Foobar.php', + '/tests/_support/Commands/Legacy/Foobar.php', ), ]; } From 3641bb7c575ca129fe8f293477ec60ae72e0ff51 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 18 Apr 2026 22:39:53 +0800 Subject: [PATCH 08/11] add tests to AbstractCommand --- system/CLI/BaseCommand.php | 2 +- .../Commands/Legacy/HelpLegacyCommand.php | 34 + .../Commands/Legacy/NullReturningCommand.php | 31 + .../Commands/Modern/AppAboutCommand.php | 136 +++ .../Modern/InteractFixtureCommand.php | 59 ++ .../Commands/Modern/TestFixtureCommand.php | 26 + .../InvalidCommands/EmptyCommandName.php | 26 + .../InvalidCommands/NoAttributeCommand.php | 24 + tests/_support/_command/AppAboutCommand.php | 35 + tests/_support/_command/DuplicateLegacy.php | 31 + tests/_support/_command/DuplicateModern.php | 29 + tests/system/CLI/AbstractCommandTest.php | 812 ++++++++++++++++++ .../Translation/LocalizationFinderTest.php | 4 +- 13 files changed, 1246 insertions(+), 3 deletions(-) create mode 100644 tests/_support/Commands/Legacy/HelpLegacyCommand.php create mode 100644 tests/_support/Commands/Legacy/NullReturningCommand.php create mode 100644 tests/_support/Commands/Modern/AppAboutCommand.php create mode 100644 tests/_support/Commands/Modern/InteractFixtureCommand.php create mode 100644 tests/_support/Commands/Modern/TestFixtureCommand.php create mode 100644 tests/_support/InvalidCommands/EmptyCommandName.php create mode 100644 tests/_support/InvalidCommands/NoAttributeCommand.php create mode 100644 tests/_support/_command/AppAboutCommand.php create mode 100644 tests/_support/_command/DuplicateLegacy.php create mode 100644 tests/_support/_command/DuplicateModern.php create mode 100644 tests/system/CLI/AbstractCommandTest.php diff --git a/system/CLI/BaseCommand.php b/system/CLI/BaseCommand.php index 5446b40c5906..6f781443784e 100644 --- a/system/CLI/BaseCommand.php +++ b/system/CLI/BaseCommand.php @@ -116,7 +116,7 @@ abstract public function run(array $params); */ protected function call(string $command, array $params = []) { - return $this->commands->run($command, $params); + return $this->commands->runLegacy($command, $params); } /** diff --git a/tests/_support/Commands/Legacy/HelpLegacyCommand.php b/tests/_support/Commands/Legacy/HelpLegacyCommand.php new file mode 100644 index 000000000000..b6673e01aaa0 --- /dev/null +++ b/tests/_support/Commands/Legacy/HelpLegacyCommand.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands\Legacy; + +use CodeIgniter\CLI\BaseCommand; + +/** + * Test fixture only. Exercises that a legacy `BaseCommand` can invoke a modern + * `AbstractCommand` via {@see \CodeIgniter\CLI\Commands::runCommand()}. Not a + * pattern to follow in application code — migrate legacy commands to + * `AbstractCommand` instead. + */ +final class HelpLegacyCommand extends BaseCommand +{ + protected $group = 'Fixtures'; + protected $name = 'help:legacy'; + protected $description = 'Legacy command to call the help command.'; + + public function run(array $params): int + { + return $this->commands->runCommand('help', [], []); + } +} diff --git a/tests/_support/Commands/Legacy/NullReturningCommand.php b/tests/_support/Commands/Legacy/NullReturningCommand.php new file mode 100644 index 000000000000..e1cdca8dfbd8 --- /dev/null +++ b/tests/_support/Commands/Legacy/NullReturningCommand.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands\Legacy; + +use CodeIgniter\CLI\BaseCommand; + +/** + * @internal + */ +final class NullReturningCommand extends BaseCommand +{ + protected $group = 'Fixtures'; + protected $name = 'null:return'; + protected $description = 'A command that returns null.'; + + public function run(array $params) + { + return null; + } +} diff --git a/tests/_support/Commands/Modern/AppAboutCommand.php b/tests/_support/Commands/Modern/AppAboutCommand.php new file mode 100644 index 000000000000..dc92b5afafbe --- /dev/null +++ b/tests/_support/Commands/Modern/AppAboutCommand.php @@ -0,0 +1,136 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands\Modern; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Argument; +use CodeIgniter\CLI\Input\Option; +use CodeIgniter\CodeIgniter; +use CodeIgniter\Exceptions\RuntimeException; + +#[Command(name: 'app:about', description: 'Displays basic application information.', group: 'Fixtures')] +final class AppAboutCommand extends AbstractCommand +{ + protected function configure(): void + { + $this + ->addArgument(new Argument(name: 'required', description: 'Unused required argument.', required: true)) + ->addArgument(new Argument(name: 'optional', description: 'Unused optional argument.', default: 'val')) + ->addArgument(new Argument(name: 'array', description: 'Unused array argument.', isArray: true, default: ['a', 'b'])) + ->addOption(new Option(name: 'foo', shortcut: 'f', description: 'Option that requires a value.', requiresValue: true, default: 'qux')) + ->addOption(new Option(name: 'bar', shortcut: 'a', description: 'Option that optionally accepts a value.', acceptsValue: true)) + ->addOption(new Option(name: 'baz', shortcut: 'b', description: 'Option that allows multiple values.', requiresValue: true, isArray: true, default: ['a'])) + ->addOption(new Option(name: 'quux', description: 'Negatable option.', negatable: true, default: false)) + ->addUsage('app:about required-value'); + } + + protected function execute(array $arguments, array $options): int + { + CLI::write(sprintf('CodeIgniter Version: %s', CLI::color(CodeIgniter::CI_VERSION, 'red'))); + + return EXIT_SUCCESS; + } + + public function bomb(): int + { + try { + CLI::color('test', 'white', 'Background'); + + return EXIT_SUCCESS; + } catch (RuntimeException $e) { + $this->renderThrowable($e); + + return EXIT_ERROR; + } + } + + public function helpMe(): int + { + return $this->call('help'); + } + + /** + * @param array|string|null>|null $options + */ + public function callHasUnboundOption(string $name, ?array $options = null): bool + { + return $this->hasUnboundOption($name, $options); + } + + /** + * @param array|string|null>|null $options + * @param list|string|null $default + * + * @return list|string|null + */ + public function callGetUnboundOption(string $name, ?array $options = null, array|string|null $default = null): array|string|null + { + return $this->getUnboundOption($name, $options, $default); + } + + /** + * @return list + */ + public function callGetUnboundArguments(): array + { + return $this->getUnboundArguments(); + } + + public function callGetUnboundArgument(int $index): string + { + return $this->getUnboundArgument($index); + } + + /** + * @return array|string|null> + */ + public function callGetUnboundOptions(): array + { + return $this->getUnboundOptions(); + } + + /** + * @return array|string> + */ + public function callGetValidatedArguments(): array + { + return $this->getValidatedArguments(); + } + + /** + * @return list|string + */ + public function callGetValidatedArgument(string $name): array|string + { + return $this->getValidatedArgument($name); + } + + /** + * @return array|string|null> + */ + public function callGetValidatedOptions(): array + { + return $this->getValidatedOptions(); + } + + /** + * @return bool|list|string|null + */ + public function callGetValidatedOption(string $name): array|bool|string|null + { + return $this->getValidatedOption($name); + } +} diff --git a/tests/_support/Commands/Modern/InteractFixtureCommand.php b/tests/_support/Commands/Modern/InteractFixtureCommand.php new file mode 100644 index 000000000000..821bee24deb4 --- /dev/null +++ b/tests/_support/Commands/Modern/InteractFixtureCommand.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands\Modern; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\Input\Argument; +use CodeIgniter\CLI\Input\Option; + +#[Command(name: 'test:interact', description: 'Fixture that mutates arguments and options in interact().', group: 'Fixtures')] +final class InteractFixtureCommand extends AbstractCommand +{ + /** + * @var array|string> + */ + public array $executedArguments = []; + + /** + * @var array|string|null> + */ + public array $executedOptions = []; + + protected function configure(): void + { + $this + ->addArgument(new Argument(name: 'name', default: 'anonymous')) + ->addOption(new Option(name: 'force')); + } + + protected function interact(array &$arguments, array &$options): void + { + // Supply a positional argument the caller omitted. + if ($arguments === []) { + $arguments[] = 'from-interact'; + } + + // Simulate the `--force` flag being passed so execute() sees it bound to `true`. + $options['force'] = null; + } + + protected function execute(array $arguments, array $options): int + { + $this->executedArguments = $arguments; + $this->executedOptions = $options; + + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/Commands/Modern/TestFixtureCommand.php b/tests/_support/Commands/Modern/TestFixtureCommand.php new file mode 100644 index 000000000000..e442790db72a --- /dev/null +++ b/tests/_support/Commands/Modern/TestFixtureCommand.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands\Modern; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; + +#[Command(name: 'test:fixture', group: 'Fixtures', description: 'A command used as a fixture for testing purposes.')] +final class TestFixtureCommand extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int + { + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/InvalidCommands/EmptyCommandName.php b/tests/_support/InvalidCommands/EmptyCommandName.php new file mode 100644 index 000000000000..1deb18614c3e --- /dev/null +++ b/tests/_support/InvalidCommands/EmptyCommandName.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\InvalidCommands; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; + +#[Command('')] +final class EmptyCommandName extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int + { + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/InvalidCommands/NoAttributeCommand.php b/tests/_support/InvalidCommands/NoAttributeCommand.php new file mode 100644 index 000000000000..0d0fcd6ec09b --- /dev/null +++ b/tests/_support/InvalidCommands/NoAttributeCommand.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\InvalidCommands; + +use CodeIgniter\CLI\AbstractCommand; + +final class NoAttributeCommand extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int + { + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/_command/AppAboutCommand.php b/tests/_support/_command/AppAboutCommand.php new file mode 100644 index 000000000000..0efe65be8b77 --- /dev/null +++ b/tests/_support/_command/AppAboutCommand.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace App\Commands; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Argument; + +#[Command(name: 'app:about', description: 'This is testing to override `app:about` command.', group: 'App')] +final class AppAboutCommand extends AbstractCommand +{ + protected function configure(): void + { + $this->addArgument(new Argument(name: 'unused', description: 'This argument is not used.', required: true)); + } + + protected function execute(array $arguments, array $options): int + { + CLI::write('This is ' . self::class); + + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/_command/DuplicateLegacy.php b/tests/_support/_command/DuplicateLegacy.php new file mode 100644 index 000000000000..76e041ac2fe1 --- /dev/null +++ b/tests/_support/_command/DuplicateLegacy.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace App\Commands; + +use CodeIgniter\CLI\BaseCommand; + +/** + * @internal + */ +final class DuplicateLegacy extends BaseCommand +{ + protected $group = 'Fixtures'; + protected $name = 'dup:test'; + protected $description = 'Legacy fixture that collides with a modern command of the same name.'; + + public function run(array $params): int + { + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/_command/DuplicateModern.php b/tests/_support/_command/DuplicateModern.php new file mode 100644 index 000000000000..fcc715c77776 --- /dev/null +++ b/tests/_support/_command/DuplicateModern.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace App\Commands; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; + +/** + * @internal + */ +#[Command(name: 'dup:test', description: 'Modern fixture that collides with a legacy command of the same name.', group: 'Fixtures')] +final class DuplicateModern extends AbstractCommand +{ + protected function execute(array $arguments, array $options): int + { + return EXIT_SUCCESS; + } +} diff --git a/tests/system/CLI/AbstractCommandTest.php b/tests/system/CLI/AbstractCommandTest.php new file mode 100644 index 000000000000..e03572da2abf --- /dev/null +++ b/tests/system/CLI/AbstractCommandTest.php @@ -0,0 +1,812 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\Exceptions\ArgumentCountMismatchException; +use CodeIgniter\CLI\Exceptions\InvalidArgumentDefinitionException; +use CodeIgniter\CLI\Exceptions\InvalidOptionDefinitionException; +use CodeIgniter\CLI\Exceptions\OptionValueMismatchException; +use CodeIgniter\CLI\Exceptions\UnknownOptionException; +use CodeIgniter\CLI\Input\Argument; +use CodeIgniter\CLI\Input\Option; +use CodeIgniter\CodeIgniter; +use CodeIgniter\Commands\Help; +use CodeIgniter\Exceptions\LogicException; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use ReflectionClass; +use Tests\Support\Commands\Modern\AppAboutCommand; +use Tests\Support\Commands\Modern\InteractFixtureCommand; +use Tests\Support\Commands\Modern\TestFixtureCommand; +use Throwable; + +/** + * @internal + */ +#[CoversClass(AbstractCommand::class)] +#[Group('Others')] +final class AbstractCommandTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + #[After] + #[Before] + protected function resetAll(): void + { + $this->resetServices(); + + CLI::reset(); + } + + private function getUndecoratedBuffer(): string + { + return preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()) ?? ''; + } + + public function testConstructorSetsNeededProperties(): void + { + $commands = new Commands(); + $command = new Help($commands); + $attribute = (new ReflectionClass($command))->getAttributes(Command::class)[0]->newInstance(); + + $this->assertSame($attribute->name, $command->getName()); + $this->assertSame($attribute->description, $command->getDescription()); + $this->assertSame($attribute->group, $command->getGroup()); + $this->assertSame($commands, $command->getCommandRunner()); + $this->assertSame('help [options] [--] []', $command->getUsages()[0]); + } + + public function testCommandRequiresCommandAttribute(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageMatches('/^Command class ".*" is missing the CodeIgniter\\\\CLI\\\\Attributes\\\\Command attribute\.$/'); + + new class (new Commands()) extends AbstractCommand { + protected function execute(array $arguments, array $options): int + { + return EXIT_SUCCESS; + } + }; + } + + public function testCommandCanGetDefinitions(): void + { + $command = new Help(new Commands()); + + $this->assertCount(1, $command->getArgumentsDefinition()); + $this->assertCount(2, $command->getOptionsDefinition()); + $this->assertCount(1, $command->getShortcuts()); + $this->assertEmpty($command->getNegations()); + } + + public function testCommandHasDefaultOptions(): void + { + $defaultOptions = ['help', 'no-header']; + + $this->assertSame($defaultOptions, array_keys((new Help(new Commands()))->getOptionsDefinition())); + } + + /** + * @param list> $definitions + */ + #[DataProvider('provideCollectionLevelArgumentRegistrationIsRejected')] + public function testCollectionLevelArgumentRegistrationIsRejected(string $message, array $definitions): void + { + $this->expectException(InvalidArgumentDefinitionException::class); + $this->expectExceptionMessage($message); + + $command = new TestFixtureCommand(new Commands()); + + foreach ($definitions as $definition) { + $command->addArgument(new Argument(...$definition)); + } + } + + /** + * @return iterable>}> + */ + public static function provideCollectionLevelArgumentRegistrationIsRejected(): iterable + { + yield 'duplicate name' => [ + 'An argument with the name "command_name" is already defined.', + [ + ['name' => 'command_name', 'default' => 'file'], + ['name' => 'command_name', 'default' => 'file2'], + ], + ]; + + yield 'non-array argument after array argument' => [ + 'Argument "second" cannot be defined after array argument "first".', + [ + ['name' => 'first', 'isArray' => true], + ['name' => 'second', 'default' => 'x'], + ], + ]; + + yield 'required argument after optional argument' => [ + 'Required argument "second" cannot be defined after optional argument "first".', + [ + ['name' => 'first', 'default' => 'value'], + ['name' => 'second', 'required' => true], + ], + ]; + } + + /** + * @param list> $definitions + */ + #[DataProvider('provideCollectionLevelOptionRegistrationIsRejected')] + public function testCollectionLevelOptionRegistrationIsRejected(string $message, array $definitions): void + { + $this->expectException(InvalidOptionDefinitionException::class); + $this->expectExceptionMessage($message); + + $command = new TestFixtureCommand(new Commands()); + + foreach ($definitions as $definition) { + $command->addOption(new Option(...$definition)); + } + } + + /** + * @return iterable>}> + */ + public static function provideCollectionLevelOptionRegistrationIsRejected(): iterable + { + yield 'duplicate name' => [ + 'An option with the name "--test" is already defined.', + [ + ['name' => 'test'], + ['name' => 'test'], + ], + ]; + + yield 'shortcut name already in use' => [ + 'Shortcut "-t" cannot be used for option "--test2"; it is already assigned to option "--test1".', + [ + ['name' => 'test1', 'shortcut' => 't'], + ['name' => 'test2', 'shortcut' => 't'], + ], + ]; + + yield 'negatable option already defined as option' => [ + 'Negatable option "--test" cannot be defined because its negation "--no-test" already exists as an option.', + [ + ['name' => 'no-test'], + ['name' => 'test', 'negatable' => true, 'default' => false], + ], + ]; + } + + public function testRenderThrowable(): void + { + $command = new AppAboutCommand(new Commands()); + + $this->assertSame(EXIT_ERROR, $command->bomb()); + $this->assertStringContainsString('[CodeIgniter\CLI\Exceptions\CLIException]', $this->getStreamFilterBuffer()); + $this->assertStringContainsString('Invalid "background" color: "Background".', $this->getStreamFilterBuffer()); + } + + public function testCheckingOfArgumentsAndOptions(): void + { + $command = new Help(new Commands()); + + $this->assertTrue($command->hasArgument('command_name')); + $this->assertFalse($command->hasArgument('lorem')); + $this->assertTrue($command->hasOption('help')); + $this->assertTrue($command->hasOption('no-header')); + $this->assertFalse($command->hasOption('lorem')); + $this->assertTrue($command->hasShortcut('h')); + $this->assertFalse($command->hasShortcut('x')); + $this->assertFalse($command->hasNegation('no-help')); + } + + public function testCommandCanCallAnotherCommand(): void + { + $command = new AppAboutCommand(new Commands()); + + $this->assertSame(0, $command->helpMe()); + $this->assertStringContainsString('help [options] [--] []', $this->getStreamFilterBuffer()); + } + + public function testRunCommand(): void + { + command('app:about a'); + + $this->assertSame( + sprintf("\nCodeIgniter Version: %s\n", CodeIgniter::CI_VERSION), + $this->getUndecoratedBuffer(), + ); + } + + /** + * @param list $arguments + */ + #[DataProvider('provideBindingOfArguments')] + public function testBindingOfArguments(array $arguments, string $key, mixed $value): void + { + $command = new AppAboutCommand(new Commands()); + $command->run($arguments, []); + + $this->assertSame($value, $command->callGetValidatedArgument($key)); + } + + /** + * @return iterable, string, mixed}> + */ + public static function provideBindingOfArguments(): iterable + { + yield 'Required argument provided [app:about a]' => [ + ['a'], + 'required', + 'a', + ]; + + yield 'Optional argument omitted [app:about a]' => [ + ['a'], + 'optional', + 'val', // default value + ]; + + yield 'Optional argument provided [app:about a opt]' => [ + ['a', 'opt'], + 'optional', + 'opt', + ]; + + yield 'Array argument omitted [app:about a]' => [ + ['a'], + 'array', + ['a', 'b'], // default values + ]; + + yield 'Multiple array arguments provided [app:about a b x y]' => [ + ['a', 'b', 'x', 'y'], + 'array', + ['x', 'y'], + ]; + + yield 'One array argument provided [app:about a b z]' => [ + ['a', 'b', 'z'], + 'array', + ['z'], + ]; + } + + /** + * @param array $options + */ + #[DataProvider('provideBindingOfOptions')] + public function testBindingOfOptions(array $options, string $key, mixed $value): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a'], $options); + + $this->assertSame($value, $command->callGetValidatedOption($key)); + } + + /** + * @return iterable, string, mixed}> + */ + public static function provideBindingOfOptions(): iterable + { + yield 'Option requiring value [app:about a --foo=bar]' => [ + ['foo' => 'bar'], + 'foo', + 'bar', + ]; + + yield 'Option shortcut requiring value [app:about a -f bar]' => [ + ['f' => 'bar'], + 'foo', + 'bar', + ]; + + yield 'Option optionally accepting value [app:about a --bar]' => [ + ['bar' => null], + 'bar', + null, + ]; + + yield 'Option shortcut optionally accepting value [app:about a -a 3]' => [ + ['a' => '3'], + 'bar', + '3', + ]; + + yield 'Option allowing multiple values [app:about a --baz=val1]' => [ + ['baz' => ['val1']], + 'baz', + ['val1'], + ]; + + yield 'Option allowing multiple values [app:about a --baz=val1] (as string)' => [ + ['baz' => 'val1'], + 'baz', + ['val1'], + ]; + + yield 'Option allowing multiple values [app:about a --baz=val1 --baz=val2]' => [ + ['baz' => ['val1', 'val2']], + 'baz', + ['val1', 'val2'], + ]; + + yield 'Option shortcut allowing multiple values [app:about a -b 1 -b 2]' => [ + ['b' => ['1', '2']], + 'baz', + ['1', '2'], + ]; + + yield 'Option and shortcut allowing multiple values [app:about a -b 1 --baz 2]' => [ + ['b' => '1', 'baz' => '2'], + 'baz', + ['2', '1'], // long names of array options are recognised first + ]; + + yield 'Negatable option provided [app:about a --quux]' => [ + ['quux' => null], + 'quux', + true, + ]; + + yield 'Negated option provided [app:about a --no-quux]' => [ + ['no-quux' => null], + 'quux', + false, + ]; + } + + /** + * @param list $arguments + */ + #[DataProvider('provideValidationNoArgumentsExpected')] + public function testValidationNoArgumentsExpected(string $message, array $arguments): void + { + $command = new TestFixtureCommand(new Commands()); + + $this->expectException(ArgumentCountMismatchException::class); + $this->expectExceptionMessage($message); + + $command->run($arguments, []); + } + + /** + * @return iterable}> + */ + public static function provideValidationNoArgumentsExpected(): iterable + { + yield 'one extra argument [test:fixture a]' => [ + 'No arguments expected for "test:fixture" command. Received: "a".', + ['a'], + ]; + + yield 'two extra arguments [test:fixture a b]' => [ + 'No arguments expected for "test:fixture" command. Received: "a", "b".', + ['a', 'b'], + ]; + } + + /** + * @param list $arguments + */ + #[DataProvider('provideValidationTooManyArguments')] + public function testValidationTooManyArguments(string $message, array $arguments): void + { + $command = (new TestFixtureCommand(new Commands())) + ->addArgument(new Argument(name: 'first', default: 'a')) + ->addArgument(new Argument(name: 'second', default: 'b')); + + $this->expectException(ArgumentCountMismatchException::class); + $this->expectExceptionMessage($message); + + $command->run($arguments, []); + } + + /** + * @return iterable}> + */ + public static function provideValidationTooManyArguments(): iterable + { + yield 'one extra argument [test:fixture a b c]' => [ + 'One unexpected argument was provided to "test:fixture" command: "c".', + ['a', 'b', 'c'], + ]; + + yield 'two extra arguments [test:fixture a b c d]' => [ + 'Multiple unexpected arguments were provided to "test:fixture" command: "c", "d".', + ['a', 'b', 'c', 'd'], + ]; + } + + public function testValidationWithMissingRequiredArgument(): void + { + $command = new TestFixtureCommand(new Commands()); + $command->addArgument(new Argument(name: 'required_arg', required: true)); + + $this->expectException(ArgumentCountMismatchException::class); + $this->expectExceptionMessage('Command "test:fixture" is missing the following required argument: required_arg.'); + + $command->run([], []); + } + + public function testValidationWithMissingMultipleRequiredArguments(): void + { + $command = new TestFixtureCommand(new Commands()); + $command->addArgument(new Argument(name: 'first_required', required: true)); + $command->addArgument(new Argument(name: 'second_required', required: true)); + + $this->expectException(ArgumentCountMismatchException::class); + $this->expectExceptionMessage('Command "test:fixture" is missing the following required arguments: first_required, second_required.'); + + $command->run([], []); + } + + /** + * @param class-string $exception + * @param array $options + */ + #[DataProvider('provideValidationOfOptions')] + public function testValidationOfOptions(string $exception, string $message, array $options): void + { + $command = new AppAboutCommand(new Commands()); + + $this->expectException($exception); + $this->expectExceptionMessage($message); + + $command->run(['a'], $options); + } + + /** + * @return iterable, string, array}> + */ + public static function provideValidationOfOptions(): iterable + { + yield 'flag option passed multiple times [app:about a --help --help]' => [ + LogicException::class, + 'Option "--help" is passed multiple times.', + ['help' => [null, null]], + ]; + + yield 'flag option shortcut passed multiple times [app:about a -h -h]' => [ + LogicException::class, + 'Option "--help" is passed multiple times.', + ['h' => [null, null]], + ]; + + yield 'flag option and its shortcut passed [app:about a --help -h]' => [ + LogicException::class, + 'Option "--help" is passed multiple times.', + ['help' => null, 'h' => null], + ]; + + yield 'flag option passed with value [app:about a --help=value]' => [ + OptionValueMismatchException::class, + 'Option "--help" does not accept a value.', + ['help' => 'value'], + ]; + + yield 'option requiring value passed without value [app:about a --foo]' => [ + OptionValueMismatchException::class, + 'Option "--foo" requires a value to be provided.', + ['foo' => null], + ]; + + yield 'option not accepting value passed with value [app:about a --no-header=value]' => [ + OptionValueMismatchException::class, + 'Option "--no-header" does not accept a value.', + ['no-header' => 'value'], + ]; + + yield 'negatable option passed with value [app:about a --quux=value]' => [ + OptionValueMismatchException::class, + 'Negatable option "--quux" does not accept a value.', + ['quux' => 'value'], + ]; + + yield 'negation of negatable option passed with value [app:about a --no-quux=value]' => [ + OptionValueMismatchException::class, + 'Negated option "--no-quux" does not accept a value.', + ['no-quux' => 'value'], + ]; + + yield 'non-array option accepting value passed multiple times [app:about a --foo=a --foo=b]' => [ + OptionValueMismatchException::class, + 'Option "--foo" does not accept an array value.', + ['foo' => ['a', 'b']], + ]; + + yield 'non-array option accepting value passed multiple times via shortcut [app:about a -f c -f d]' => [ + OptionValueMismatchException::class, + 'Option "--foo" does not accept an array value.', + ['f' => ['c', 'd']], + ]; + + yield 'negatable option passed multiple times [app:about a --quux --quux]' => [ + OptionValueMismatchException::class, + 'Negatable option "--quux" is passed multiple times.', + ['quux' => [null, null]], + ]; + + yield 'negatable option passed multiple times some with value [app:about a --quux --quux=b]' => [ + OptionValueMismatchException::class, + 'Negatable option "--quux" is passed multiple times.', + ['quux' => [null, 'b']], + ]; + + yield 'negatable option passed multiple times all with values [app:about a --quux=b --quux=c]' => [ + OptionValueMismatchException::class, + 'Negatable option "--quux" is passed multiple times.', + ['quux' => ['b', 'c']], + ]; + + yield 'negation of negatable option passed multiple times [app:about a --no-quux --no-quux]' => [ + OptionValueMismatchException::class, + 'Negated option "--no-quux" is passed multiple times.', + ['no-quux' => [null, null]], + ]; + + yield 'negation of negatable option passed multiple times some with value [app:about a --no-quux --no-quux=d]' => [ + OptionValueMismatchException::class, + 'Negated option "--no-quux" is passed multiple times.', + ['no-quux' => [null, 'd']], + ]; + + yield 'negation of negatable option passed multiple times all with values [app:about a --no-quux=e --no-quux=f]' => [ + OptionValueMismatchException::class, + 'Negated option "--no-quux" is passed multiple times.', + ['no-quux' => ['e', 'f']], + ]; + + yield 'negatable option passed with its negation [app:about a --quux --no-quux]' => [ + LogicException::class, + 'Option "--quux" and its negation "--no-quux" cannot be used together.', + ['quux' => null, 'no-quux' => null], + ]; + + yield 'negatable option passed with its negation multiple times [app:about a --quux --no-quux --no-quux]' => [ + LogicException::class, + 'Option "--quux" and its negation "--no-quux" cannot be used together.', + ['quux' => null, 'no-quux' => [null, null]], + ]; + + yield 'negatable option passed multiple times with its negation [app:about a --quux --quux --no-quux]' => [ + LogicException::class, + 'Option "--quux" and its negation "--no-quux" cannot be used together.', + ['quux' => [null, null], 'no-quux' => null], + ]; + + yield 'unknown option passed [app:about a --unknown]' => [ + UnknownOptionException::class, + 'The following option is unknown in the "app:about" command: --unknown.', + ['unknown' => 'value'], + ]; + + yield 'multiple unknown options passed [app:about a --unknown1 --unknown2]' => [ + UnknownOptionException::class, + 'The following options are unknown in the "app:about" command: --unknown1, --unknown2.', + ['unknown1' => 'value', 'unknown2' => 'value'], + ]; + } + + public function testInteractMutationsCarryThroughToExecute(): void + { + // Supply neither the positional argument nor the --force flag. + // interact() populates both; bind() and validate() run afterwards, so + // execute() should see the mutated values fully bound and validated. + $command = new InteractFixtureCommand(new Commands()); + $command->run([], []); + + $this->assertSame(['name' => 'from-interact'], $command->executedArguments); + $this->assertTrue($command->executedOptions['force']); + } + + /** + * @param array|string|null> $options + */ + #[DataProvider('provideHasUnboundOptionResolvesAlias')] + public function testHasUnboundOptionResolvesAlias(string $name, array $options, bool $expected): void + { + $command = new AppAboutCommand(new Commands()); + + $this->assertSame($expected, $command->callHasUnboundOption($name, $options)); + } + + /** + * @return iterable|string|null>, bool}> + */ + public static function provideHasUnboundOptionResolvesAlias(): iterable + { + yield 'long name' => ['foo', ['foo' => 'bar'], true]; + + yield 'shortcut' => ['foo', ['f' => 'bar'], true]; + + yield 'negation' => ['quux', ['no-quux' => null], true]; + + yield 'not provided' => ['foo', [], false]; + } + + public function testHasUnboundOptionThrowsForUndeclaredOption(): void + { + $command = new AppAboutCommand(new Commands()); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Option "undeclared" is not defined on this command.'); + + $command->callHasUnboundOption('undeclared', []); + } + + /** + * @param array|string|null> $options + * @param list|string|null $default + * @param list|string|null $expected + */ + #[DataProvider('provideGetUnboundOptionResolvesAlias')] + public function testGetUnboundOptionResolvesAlias(string $name, array $options, array|string|null $default, array|string|null $expected): void + { + $command = new AppAboutCommand(new Commands()); + + $this->assertSame($expected, $command->callGetUnboundOption($name, $options, $default)); + } + + /** + * @return iterable|string|null>, list|string|null, list|string|null}> + */ + public static function provideGetUnboundOptionResolvesAlias(): iterable + { + yield 'long name' => ['foo', ['foo' => 'bar'], null, 'bar']; + + yield 'shortcut' => ['foo', ['f' => 'bar'], null, 'bar']; + + yield 'negation' => ['quux', ['no-quux' => null], null, null]; + + yield 'not provided' => ['foo', [], 'fallback', 'fallback']; + } + + public function testGetUnboundOptionThrowsForUndeclaredOption(): void + { + $command = new AppAboutCommand(new Commands()); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Option "undeclared" is not defined on this command.'); + + $command->callGetUnboundOption('undeclared', []); + } + + public function testUnboundOptionHelpersFallBackToInstanceStateAfterRun(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a'], ['f' => 'shortcut-value']); + + // Options array passed to run() is the raw (unbound) input. After run() + // completes, $this->unboundOptions holds that snapshot. Calling the + // helpers with $options = null should read from that state. + $this->assertTrue($command->callHasUnboundOption('foo')); + $this->assertSame('shortcut-value', $command->callGetUnboundOption('foo')); + } + + public function testGetUnboundArgumentsReturnsRawArgumentList(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a', 'b', 'extra'], []); + + $this->assertSame(['a', 'b', 'extra'], $command->callGetUnboundArguments()); + } + + public function testGetUnboundArgumentByIndex(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a', 'b'], []); + + $this->assertSame('a', $command->callGetUnboundArgument(0)); + $this->assertSame('b', $command->callGetUnboundArgument(1)); + } + + public function testGetUnboundArgumentThrowsForMissingIndex(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a'], []); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Unbound argument at index "5" does not exist.'); + + $command->callGetUnboundArgument(5); + } + + public function testGetUnboundOptionsReturnsRawOptionMap(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a'], ['f' => 'shortcut', 'bar' => 'longname']); + + $this->assertSame( + ['f' => 'shortcut', 'bar' => 'longname'], + $command->callGetUnboundOptions(), + ); + } + + public function testGetValidatedArgumentsReflectsDefaultsAfterBinding(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a'], []); + + $this->assertSame( + ['required' => 'a', 'optional' => 'val', 'array' => ['a', 'b']], + $command->callGetValidatedArguments(), + ); + } + + public function testGetValidatedArgumentByName(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['hello', 'world'], []); + + $this->assertSame('hello', $command->callGetValidatedArgument('required')); + $this->assertSame('world', $command->callGetValidatedArgument('optional')); + } + + public function testGetValidatedArgumentThrowsForUnknownName(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a'], []); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Validated argument with name "missing" does not exist.'); + + $command->callGetValidatedArgument('missing'); + } + + public function testGetValidatedOptionsReflectsDefaultsAfterBinding(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a'], ['foo' => 'provided']); + + $this->assertSame( + [ + 'foo' => 'provided', + 'bar' => null, + 'baz' => ['a'], + 'quux' => false, + 'help' => false, + 'no-header' => false, + ], + $command->callGetValidatedOptions(), + ); + } + + public function testGetValidatedOptionByName(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a'], ['foo' => 'provided']); + + $this->assertSame('provided', $command->callGetValidatedOption('foo')); + $this->assertFalse($command->callGetValidatedOption('help')); + } + + public function testGetValidatedOptionThrowsForUnknownName(): void + { + $command = new AppAboutCommand(new Commands()); + $command->run(['a'], []); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Validated option with name "missing" does not exist.'); + + $command->callGetValidatedOption('missing'); + } +} diff --git a/tests/system/Commands/Translation/LocalizationFinderTest.php b/tests/system/Commands/Translation/LocalizationFinderTest.php index f40ae88098f5..c33489b2c971 100644 --- a/tests/system/Commands/Translation/LocalizationFinderTest.php +++ b/tests/system/Commands/Translation/LocalizationFinderTest.php @@ -67,7 +67,7 @@ public function testUpdateWithIncorrectLocaleOption(): void self::$locale = 'test_locale_incorrect'; $this->makeLocaleDirectory(); - $status = service('commands')->run('lang:find', [ + $status = service('commands')->runLegacy('lang:find', [ 'dir' => 'Translation', 'locale' => self::$locale, ]); @@ -88,7 +88,7 @@ public function testUpdateWithIncorrectDirOption(): void { $this->makeLocaleDirectory(); - $status = service('commands')->run('lang:find', [ + $status = service('commands')->runLegacy('lang:find', [ 'dir' => 'Translation/NotExistFolder', ]); From 80ee38cee3078e5c3db586b6e727917cddd6e3b8 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 18 Apr 2026 22:40:44 +0800 Subject: [PATCH 09/11] remove support for the `-h` option in `routes` command --- system/Commands/Utilities/Routes.php | 8 ----- .../system/Commands/Utilities/RoutesTest.php | 32 ------------------- 2 files changed, 40 deletions(-) diff --git a/system/Commands/Utilities/Routes.php b/system/Commands/Utilities/Routes.php index b3fcdf37e6fa..0d44baef45d9 100644 --- a/system/Commands/Utilities/Routes.php +++ b/system/Commands/Utilities/Routes.php @@ -84,14 +84,6 @@ public function run(array $params) { $sortByHandler = array_key_exists('handler', $params); - if (! $sortByHandler && array_key_exists('h', $params)) { - // Support -h as a shortcut but print a warning that it is not the intended use of -h. - CLI::write('Warning: -h will be used as shortcut for --help in v4.8.0. Please use --handler to sort by handler.', 'yellow'); - CLI::newLine(); - - $sortByHandler = true; - } - $host = $params['host'] ?? null; // Set HTTP_HOST diff --git a/tests/system/Commands/Utilities/RoutesTest.php b/tests/system/Commands/Utilities/RoutesTest.php index f2dc10bfc5bd..f4defea933b9 100644 --- a/tests/system/Commands/Utilities/RoutesTest.php +++ b/tests/system/Commands/Utilities/RoutesTest.php @@ -117,38 +117,6 @@ public function testRoutesCommandSortByHandler(): void $this->assertStringContainsString($expected, $this->getBuffer()); } - /** - * @todo To remove this test and the backward compatibility for -h in v4.8.0. - */ - public function testRoutesCommandSortByHandlerUsingShortcutForBc(): void - { - Services::resetSingle('routes'); - - command('routes -h'); - - $expected = <<<'EOL' - Warning: -h will be used as shortcut for --help in v4.8.0. Please use --handler to sort by handler. - - +---------+---------+---------------+----------------------------------------+----------------+---------------+ - | Method | Route | Name | Handler ↓ | Before Filters | After Filters | - +---------+---------+---------------+----------------------------------------+----------------+---------------+ - | GET | closure | » | (Closure) | | | - | GET | / | » | \App\Controllers\Home::index | | | - | GET | testing | testing-index | \App\Controllers\TestController::index | | | - | HEAD | testing | testing-index | \App\Controllers\TestController::index | | | - | POST | testing | testing-index | \App\Controllers\TestController::index | | | - | PATCH | testing | testing-index | \App\Controllers\TestController::index | | | - | PUT | testing | testing-index | \App\Controllers\TestController::index | | | - | DELETE | testing | testing-index | \App\Controllers\TestController::index | | | - | OPTIONS | testing | testing-index | \App\Controllers\TestController::index | | | - | TRACE | testing | testing-index | \App\Controllers\TestController::index | | | - | CONNECT | testing | testing-index | \App\Controllers\TestController::index | | | - | CLI | testing | testing-index | \App\Controllers\TestController::index | | | - +---------+---------+---------------+----------------------------------------+----------------+---------------+ - EOL; - $this->assertStringContainsString($expected, $this->getBuffer()); - } - public function testRoutesCommandHostHostname(): void { Services::resetSingle('routes'); From a944b893b07c6e55ff8ffe9879c7d51883df2195 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 18 Apr 2026 22:44:57 +0800 Subject: [PATCH 10/11] add documentation and changelog --- user_guide_src/source/changelogs/v4.8.0.rst | 7 + .../source/cli/cli_modern_commands.rst | 449 ++++++++++++++++++ .../source/cli/cli_modern_commands/001.php | 18 + .../source/cli/cli_modern_commands/002.php | 41 ++ .../source/cli/cli_modern_commands/003.php | 58 +++ .../source/cli/cli_modern_commands/004.php | 51 ++ .../source/cli/cli_modern_commands/005.php | 38 ++ .../source/cli/cli_modern_commands/006.php | 9 + .../source/cli/cli_modern_commands/007.php | 28 ++ .../source/cli/cli_modern_commands/008.php | 6 + user_guide_src/source/cli/index.rst | 1 + .../missingType.iterableValue.neon | 10 - 12 files changed, 706 insertions(+), 10 deletions(-) create mode 100644 user_guide_src/source/cli/cli_modern_commands.rst create mode 100644 user_guide_src/source/cli/cli_modern_commands/001.php create mode 100644 user_guide_src/source/cli/cli_modern_commands/002.php create mode 100644 user_guide_src/source/cli/cli_modern_commands/003.php create mode 100644 user_guide_src/source/cli/cli_modern_commands/004.php create mode 100644 user_guide_src/source/cli/cli_modern_commands/005.php create mode 100644 user_guide_src/source/cli/cli_modern_commands/006.php create mode 100644 user_guide_src/source/cli/cli_modern_commands/007.php create mode 100644 user_guide_src/source/cli/cli_modern_commands/008.php diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 99c0adfc8355..2fce0f075c7d 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -25,6 +25,8 @@ Behavior Changes - The static ``Boot::initializeConsole()`` method no longer handles the display of the console header. This is now handled within ``Console::run()``. If you have overridden ``Boot::initializeConsole()``, you should remove any code related to displaying the console header, as this is now the responsibility of the ``Console`` class. +- **Commands:** The ``-h`` option to ``routes`` command which was mapped previously to the ``--handler`` option is now removed. + Use ``--handler`` instead to sort the routes by handler when running the ``routes`` command. - **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 /``). - **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. - **Filters:** HTTP method matching for method-based filters is now case-sensitive. The keys in ``Config\Filters::$methods`` must exactly match the request method @@ -172,6 +174,9 @@ Enhancements Commands ======== +- Added a new attribute-based command style built on :php:class:`AbstractCommand ` and the ``#[Command]`` attribute, + with ``configure()`` / ``initialize()`` / ``interact()`` / ``execute()`` hooks and typed ``Argument`` / ``Option`` definitions. + The legacy ``BaseCommand`` style continues to work. See :doc:`../cli/cli_modern_commands`. - You can now retrieve the last executed command in the console using the new ``Console::getCommand()`` method. This is useful for logging, debugging, or any situation where you need to know which command was run. - ``CLI`` now supports the ``--`` separator to mean that what follows are arguments, not options. This allows you to have arguments that start with ``-`` without them being treated as options. For example: ``spark my:command -- --myarg`` will pass ``--myarg`` as an argument instead of an option. @@ -276,6 +281,8 @@ Deprecations - **CLI:** The ``CLI::parseCommandLine()`` method is now deprecated and will be removed in a future release. The ``CLI`` class now uses the new ``CommandLineParser`` class to handle command-line argument parsing. - **CLI:** Returning a non-integer exit code from a command is now deprecated and will trigger a deprecation notice. Command methods should return an integer exit code (e.g., ``0`` for success, non-zero for errors) to ensure proper behavior across all platforms. +- **CLI:** ``Commands::run()`` is now deprecated in favor of ``Commands::runLegacy()`` for legacy ``BaseCommand`` commands, and ``Commands::runCommand()`` for modern ``AbstractCommand`` commands. +- **CLI:** The ``$commands`` parameter of ``Commands::verifyCommand()`` and the ``$collection`` parameter of ``Commands::getCommandAlternatives()`` are no longer used. Passing a non-empty array for either will trigger a deprecation notice. - **HTTP:** The ``CLIRequest::parseCommand()`` method is now deprecated and will be removed in a future release. The ``CLIRequest`` class now uses the new ``CommandLineParser`` class to handle command-line argument parsing. - **HTTP:** ``URI::setSilent()`` is now hard deprecated. This method was only previously marked as deprecated. It will now trigger a deprecation notice when used. diff --git a/user_guide_src/source/cli/cli_modern_commands.rst b/user_guide_src/source/cli/cli_modern_commands.rst new file mode 100644 index 000000000000..955bce86af3b --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands.rst @@ -0,0 +1,449 @@ +##################### +Modern Spark Commands +##################### + +.. versionadded:: 4.8.0 + +Modern commands are a newer style of :doc:`Spark command `. +Instead of declaring metadata through class properties, modern commands describe +themselves through a ``#[Command]`` attribute and build their argument/option +surface inside a ``configure()`` method. The framework then parses the command +line, applies the declared defaults, validates what was passed, and finally +calls ``execute()`` with typed, validated values. + +Modern and legacy commands can coexist (for now): existing ``BaseCommand`` classes +continue to work, and the framework routes invocations to whichever command +matches the requested name, regardless of style. + +.. contents:: + :local: + :depth: 2 + +******************* +Creating a Command +******************* + +A modern command is a class that: + +- extends ``CodeIgniter\CLI\AbstractCommand``; +- declares a ``#[Command]`` attribute with a ``name``, a ``description`` and a ``group``; +- implements ``execute(array $arguments, array $options): int`` and returns an ``EXIT_*`` status code. + +A minimal example: + +.. literalinclude:: cli_modern_commands/001.php + +File Location +============= + +Same rule as the legacy style — commands must live under a directory named +**Commands** that is reachable through PSR-4 autoloading, for instance +**app/Commands/**. The framework auto-discovers them the first time the +command runner is used. + +The ``#[Command]`` Attribute +============================ + +The attribute holds the command's identity: + +- ``name`` is the token users type after ``php spark``. It must not be empty, must not contain + whitespace, and may use a colon to namespace related commands (``cache:clear``, ``make:migration``). + Leading, trailing, or consecutive colons are rejected. +- ``description`` is shown in the ``list`` output and at the top of ``help ``. +- ``group`` controls how the command is grouped in the ``list`` output. A command with an empty + ``group`` is skipped by discovery. + +The attribute itself validates these constraints at construction time — if you +misspell ``name``, you will see the error at discovery rather than at run time. + +***************** +Command Lifecycle +***************** + +When the runner invokes a modern command, it walks through several phases in +this order: + +1. **Construction.** The ``#[Command]`` attribute is read, then your + ``configure(): void`` hook runs so you can register arguments, options, and + extra usage examples. A default ``--help``/ ``-h`` flag and ``--no-header`` + flag are added automatically afterwards. +2. ``initialize(array &$arguments, array &$options): void`` receives the raw + arguments and options by reference. Useful when your command needs to + massage input — for instance, to unfold an alias argument into the canonical + form before anything else runs. +3. ``interact(array &$arguments, array &$options): void`` also receives the + raw arguments and options by reference. This is where you prompt the user + for missing input, set values conditionally, or abort early. +4. **Bind & validate.** The framework maps the raw input to the definitions + you declared in ``configure()``, applies defaults, and rejects input that + violates the definitions (missing required argument, unknown option, array + option passed without a value, and so on). +5. ``execute(array $arguments, array $options): int`` receives the bound and + validated arguments and options, and returns an exit code. + +You only have to implement ``execute()``; the other hooks are optional. + +********* +Arguments +********* + +Arguments are positional — the first token after the command name is bound to +the first declared argument, the second token to the second declared argument, +and so on. They are declared inside ``configure()`` using the +``CodeIgniter\CLI\Input\Argument`` value object: + +.. literalinclude:: cli_modern_commands/002.php + +The following rules are enforced at configuration time. Violating any of them +raises an ``InvalidArgumentDefinitionException``: + +- A required argument **must not** have a default value. +- An optional argument **must** have a default value. +- An array argument collects every remaining positional token. + Only one array argument may be declared, and it must come last. +- An array argument cannot be required (but it can have a non-empty default). +- Required arguments must all come before optional arguments. +- Argument names must match ``[A-Za-z0-9_-]+`` and the name ``extra_arguments`` is reserved. + +******* +Options +******* + +Options are name-based. They are declared with ``CodeIgniter\CLI\Input\Option``: + +.. literalinclude:: cli_modern_commands/003.php + +Options support the following modes (they can be combined where it makes +sense): + +- **Flag** — the default. The option takes no value. Presence makes the bound + value ``true``; absence leaves it ``false``. +- ``requiresValue: true`` — the option must be followed by a value when passed. +- ``acceptsValue: true`` — the option may be followed by a value, but the value is optional. +- ``isArray: true`` — the option may be passed multiple times; each value is appended to the bound array. +- ``negatable: true`` — a second long form ``--no-`` is registered automatically. + Passing ``--name`` sets the option to ``true``; passing ``--no-name`` sets it to ``false``. + +Every option may also declare a single-character ``shortcut`` (e.g., ``-f`` for ``--force``). +Shortcuts must be a single alphanumeric character and unique within the command. + +A few quirks are worth knowing: + +- ``requiresValue: true`` and ``isArray: true`` both imply ``acceptsValue: true``. +- An option that requires a value must be given a **string** default. The default is used only when the + option is not passed at all; passing the option without a value throws at validation. +- An array option must require a value. Its default must be ``null`` or a non-empty array + (``null`` is normalised to an empty array internally). +- A negatable option cannot accept a value or be an array. Its default must be a boolean. +- A negatable option's auto-generated ``--no-`` form will clash if another option is already named ``no-``. +- Option names must match ``[A-Za-z0-9_-]+`` and the name ``extra_options`` is reserved. +- ``--help`` / ``-h`` and ``--no-header`` are reserved for the framework and registered on every command automatically. + +Configuration-time violations raise ``InvalidOptionDefinitionException``. + +************************* +Interacting With the User +************************* + +``interact()`` is designed for commands that need to prompt, confirm, or fill +in missing input before validation runs. Its ``$arguments`` and ``$options`` +parameters are **raw** — they are the tokens the framework parsed from the +command line, *before* the values are mapped to your declared definitions. + +Because the raw input may be keyed by the long name, the shortcut, or the +negation form, two helpers make lookups alias-aware: + +- ``hasUnboundOption(string $name, ?array $options = null): bool`` +- ``getUnboundOption(string $name, ?array $options = null, $default = null)`` + +Inside ``interact()`` pass ``$options`` explicitly — the instance state is not +populated yet. Outside ``interact()`` (for example inside ``execute()``) you +can omit ``$options`` and the helpers will read from the instance snapshot +taken right before bind and validate. + +.. literalinclude:: cli_modern_commands/004.php + +Any change you make to ``$arguments`` or ``$options`` inside ``interact()`` +carries through to bind, validate, and ``execute()``. + +****************** +Inside execute() +****************** + +``execute()`` receives two arrays that mirror your declared definitions: + +- ``$arguments`` contains every declared argument, bound to the provided value or the declared default. +- ``$options`` contains every declared option plus the framework defaults + (``help``, ``no-header``), bound to the provided value or the declared default. + +The same data is available through typed helpers so you don't have to sprinkle +``is_string()`` / ``is_array()`` guards across your command: + +- ``getValidatedArgument(string $name)`` / ``getValidatedArguments()`` +- ``getValidatedOption(string $name)`` / ``getValidatedOptions()`` +- ``getUnboundArgument(int $index)`` / ``getUnboundArguments()`` +- ``getUnboundOption(string $name, ...)`` / ``getUnboundOptions()`` + +The *validated* variants expose the bound values (what your definition says). +The *unbound* variants expose the raw input snapshot — useful when forwarding +the command to another command, or when your logic needs to know whether a +flag was actually passed rather than whether it resolved to a default value. + +.. literalinclude:: cli_modern_commands/005.php + +Accessors that take a name throw ``LogicException`` when the name is not declared on the command. + +*********************** +Calling Another Command +*********************** + +Inside ``execute()``, a modern command can invoke another modern command through +``$this->call()``. ``call()`` must not be used from ``configure()``, ``initialize()``, +or ``interact()`` — the current command has not been bound and validated yet at +those points, and its unbound state has not been snapshotted. + +.. literalinclude:: cli_modern_commands/006.php + +The ``$arguments`` and ``$options`` you pass are interpreted as raw input — +they go through bind and validate on the target command, just like a user +invocation. + +To forward the caller's own input through to the target command, pass +``$this->getUnboundArguments()`` and ``$this->getUnboundOptions()`` to ``call()``: + +.. literalinclude:: cli_modern_commands/008.php + +************** +Usage Examples +************** + +The default usage line is built automatically from the command name and the +declared argument list. You can append additional example lines by calling +``addUsage()`` inside ``configure()``: + +.. literalinclude:: cli_modern_commands/007.php + +In the ``help `` or `` --help`` output the default usage line is shown first, +followed by each ``addUsage()`` entry in the order it was added. + +********************** +Rendering an Exception +********************** + +If your command catches a ``Throwable`` and wants to produce the same +formatted output the framework uses for uncaught exceptions, call +``$this->renderThrowable($exception)``. The helper is safe to call from any +command, and it will not disturb the currently shared request. + +******************************** +Coexistence With Legacy Commands +******************************** + +Legacy ``BaseCommand`` classes are still supported, and they are discovered +alongside modern commands. If the same name is claimed by both a legacy and a +modern command, the legacy one is invoked and a warning is printed once at +discovery time so you can rename or retire one of the two. + +The ``help`` command understands both styles — it delegates to the legacy +``showHelp()`` method for legacy commands and renders a structured view for +modern ones. + +.. note:: + + Legacy commands remain supported while the framework's own built-in + commands are being migrated to the modern style. Once that migration is + complete, ``BaseCommand`` will start emitting deprecation notices. New + commands should be written against ``AbstractCommand`` from the start. + +*************** +AbstractCommand +*************** + +The ``AbstractCommand`` class that all modern commands must extend exposes a +number of utility methods you call from within your own command. Hooks like +``configure()``, ``initialize()``, ``interact()``, and ``execute()`` are +covered in the sections above and are not listed here. + +.. php:namespace:: CodeIgniter\CLI + +.. php:class:: AbstractCommand + + .. php:method:: getCommandRunner(): Commands + + Returns the ``Commands`` runner the command was constructed with. + Useful when you need to introspect other discovered commands (for + instance, building a custom ``list``-style command). + + .. php:method:: getName(): string + + Returns the command name declared on the ``#[Command]`` attribute. + + .. php:method:: getDescription(): string + + Returns the command description declared on the ``#[Command]`` + attribute. + + .. php:method:: getGroup(): string + + Returns the command group declared on the ``#[Command]`` attribute. + + .. php:method:: getUsages(): array + + Returns every usage line registered for the command — the default + line built from the argument list, followed by each ``addUsage()`` + entry in declaration order. + + .. php:method:: getArgumentsDefinition(): array + + Returns the ``Argument`` value objects registered on this command, + keyed by argument name and ordered by declaration. + + .. php:method:: getOptionsDefinition(): array + + Returns the ``Option`` value objects registered on this command, + keyed by option name. + + .. php:method:: getShortcuts(): array + + Returns the shortcut-to-option-name map (for example + ``['f' => 'force']``). Empty when no shortcut is declared. + + .. php:method:: getNegations(): array + + Returns the negation-to-option-name map (for example + ``['no-force' => 'force']``). Empty when no negatable option is + declared. + + .. php:method:: addUsage(string $usage): static + + :param string $usage: An extra usage example line. + + Adds a usage example to the ``help `` output. The default + usage line derived from the argument list is always shown first. + + .. php:method:: addArgument(Argument $argument): static + + :param Argument $argument: The argument definition to register. + + Registers a positional argument. Call from ``configure()``. + + .. php:method:: addOption(Option $option): static + + :param Option $option: The option definition to register. + + Registers an option. Call from ``configure()``. + + .. php:method:: renderThrowable(Throwable $e): void + + :param Throwable $e: The throwable to render. + + Produces the same formatted output the framework uses for uncaught + exceptions. Safe to call from any command. + + .. php:method:: hasArgument(string $name): bool + + :param string $name: The argument name to look up. + + Returns ``true`` if an argument with that name is declared on the + command. + + .. php:method:: hasOption(string $name): bool + + :param string $name: The option name to look up. + + Returns ``true`` if an option with that name is declared on the + command. + + .. php:method:: hasShortcut(string $shortcut): bool + + :param string $shortcut: The shortcut character to look up. + + Returns ``true`` if the shortcut is claimed by one of the declared + options. + + .. php:method:: hasNegation(string $name): bool + + :param string $name: The negation name (for example ``no-force``) to look up. + + Returns ``true`` if the negation is registered by one of the + declared options. + + .. php:method:: run(array $arguments, array $options): int + + :param array $arguments: The raw positional arguments parsed from the command line. + :param array $options: The raw option map parsed from the command line. + :returns: The exit code returned by ``execute()``. + + **Final.** Walks the command through ``initialize()``, ``interact()``, + bind, validate, and finally ``execute()``. The framework calls this + on your behalf — you rarely invoke it directly, but you can when + driving a command manually (for instance, from a test). + + .. php:method:: call(string $command[, array $arguments = [], array $options = []]): int + + :param string $command: The name of the modern command to call. + :param array $arguments: Positional arguments to forward. + :param array $options: Options to forward, keyed by long name, shortcut, or negation. + :returns: The exit code returned by the called command. + + Invokes another modern command. The arguments and options go through + bind and validate on the target command, just like a user invocation. + + .. php:method:: getUnboundArguments(): array + + Returns the raw, parsed positional arguments as passed to the + command. + + .. php:method:: getUnboundArgument(int $index): string + + :param int $index: The zero-based index of the argument to read. + + Returns a single raw positional argument. Throws + ``LogicException`` when the index does not exist. + + .. php:method:: getUnboundOptions(): array + + Returns the raw, parsed option map, keyed by long name, shortcut, + or negation. + + .. php:method:: getUnboundOption(string $name[, array|null $options = null, array|string|null $default = null]): array|string|null + + :param string $name: The declared option name to look up. + :param array|null $options: Raw option map to read from. Required inside ``interact()``, optional elsewhere. + :param array|string|null $default: Value to return when the option was not provided. + + Returns the raw value the option was given, resolving its shortcut + and negation. Falls back to ``$default`` when the option was not + provided. Throws ``LogicException`` when the option is not declared + on this command. + + .. php:method:: hasUnboundOption(string $name[, array|null $options = null]): bool + + :param string $name: The declared option name to look up. + :param array|null $options: Raw option map to read from. Required inside ``interact()``, optional elsewhere. + + Returns ``true`` if the option was provided under its long name, + shortcut, or negation. Throws ``LogicException`` when the option is + not declared on this command. + + .. php:method:: getValidatedArguments(): array + + Returns the bound and validated arguments, keyed by declared name. + + .. php:method:: getValidatedArgument(string $name): array|string + + :param string $name: The declared argument name to read. + + Returns the bound and validated value for a single argument. Throws + ``LogicException`` when the argument is not declared on this command. + + .. php:method:: getValidatedOptions(): array + + Returns the bound and validated options, keyed by declared name. + + .. php:method:: getValidatedOption(string $name): bool|array|string|null + + :param string $name: The declared option name to read. + + Returns the bound and validated value for a single option. Throws + ``LogicException`` when the option is not declared on this command. diff --git a/user_guide_src/source/cli/cli_modern_commands/001.php b/user_guide_src/source/cli/cli_modern_commands/001.php new file mode 100644 index 000000000000..0033a19b122b --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/001.php @@ -0,0 +1,18 @@ +addArgument(new Argument( + name: 'name', + description: 'Who to greet.', + required: true, + )) + // Optional — a default is mandatory. + ->addArgument(new Argument( + name: 'salutation', + description: 'Optional salutation.', + default: 'Hello', + )) + // Array — collects every remaining token. Must be declared last. + ->addArgument(new Argument( + name: 'extras', + description: 'Any extra tokens.', + isArray: true, + )); + } + + protected function execute(array $arguments, array $options): int + { + // ... + + return EXIT_SUCCESS; + } +} diff --git a/user_guide_src/source/cli/cli_modern_commands/003.php b/user_guide_src/source/cli/cli_modern_commands/003.php new file mode 100644 index 000000000000..2e3bb3bfcee3 --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/003.php @@ -0,0 +1,58 @@ +addOption(new Option( + name: 'verbose', + shortcut: 'v', + description: 'Enable verbose output.', + )) + // Value-required. --destination=path or -d path. A string default is mandatory. + ->addOption(new Option( + name: 'destination', + shortcut: 'd', + description: 'Destination folder.', + requiresValue: true, + default: 'public', + )) + // Value-optional. Both `--driver` and `--driver=redis` are accepted. + ->addOption(new Option( + name: 'driver', + description: 'Optional driver override.', + acceptsValue: true, + )) + // Array. `--tag=a --tag=b` collects to ['a', 'b']. Array options must require a value. + ->addOption(new Option( + name: 'tag', + shortcut: 't', + description: 'Tag to publish (may be repeated).', + requiresValue: true, + isArray: true, + )) + // Negatable. Both --clean and --no-clean are registered automatically. + ->addOption(new Option( + name: 'clean', + description: 'Clean the destination first.', + negatable: true, + default: true, + )); + } + + protected function execute(array $arguments, array $options): int + { + // ... + + return EXIT_SUCCESS; + } +} diff --git a/user_guide_src/source/cli/cli_modern_commands/004.php b/user_guide_src/source/cli/cli_modern_commands/004.php new file mode 100644 index 000000000000..5c9340d07f41 --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/004.php @@ -0,0 +1,51 @@ +addOption(new Option( + name: 'force', + shortcut: 'f', + description: 'Skip the confirmation prompt.', + )); + } + + protected function interact(array &$arguments, array &$options): void + { + // hasUnboundOption() resolves --force, -f, and --no-force in one call, + // even though $options here is still the raw parsed input. + if ($this->hasUnboundOption('force', $options)) { + return; + } + + if (CLI::prompt('Delete the logs?', ['n', 'y']) === 'n') { + return; + } + + // Mutations made here flow through to bind(), validate(), and execute(). + // For a flag option, writing `null` models "the flag was passed". + $options['force'] = null; + } + + protected function execute(array $arguments, array $options): int + { + if ($this->getValidatedOption('force') === false) { + CLI::error('Aborted.'); + + return EXIT_ERROR; + } + + // ... actually delete the logs ... + + return EXIT_SUCCESS; + } +} diff --git a/user_guide_src/source/cli/cli_modern_commands/005.php b/user_guide_src/source/cli/cli_modern_commands/005.php new file mode 100644 index 000000000000..9a2ca303b8ff --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/005.php @@ -0,0 +1,38 @@ +addArgument(new Argument(name: 'name', required: true)) + ->addOption(new Option(name: 'loud', negatable: true, default: false)); + } + + protected function execute(array $arguments, array $options): int + { + // Directly from the parameters: + $name = $arguments['name']; + + // Or via the validated accessors — throws LogicException if the name + // is not declared on this command: + $name = $this->getValidatedArgument('name'); + $loud = $this->getValidatedOption('loud'); + + // Need to know whether --loud was actually passed, not just whether it + // resolved to its declared default? Use the unbound accessors: + $loudWasPassed = $this->hasUnboundOption('loud'); + + // ... + + return EXIT_SUCCESS; + } +} diff --git a/user_guide_src/source/cli/cli_modern_commands/006.php b/user_guide_src/source/cli/cli_modern_commands/006.php new file mode 100644 index 000000000000..760f1d2d198e --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/006.php @@ -0,0 +1,9 @@ +call('cache:clear', arguments: ['file']); + +// ...and/or with options. Use `null` for a flag's value to model "the flag was passed". +$this->call('logs:clear', options: ['force' => null]); diff --git a/user_guide_src/source/cli/cli_modern_commands/007.php b/user_guide_src/source/cli/cli_modern_commands/007.php new file mode 100644 index 000000000000..d9d959918296 --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/007.php @@ -0,0 +1,28 @@ +addArgument(new Argument(name: 'name', required: true)) + // The default usage line is always generated from the declared + // arguments; these extra lines are appended after it. + ->addUsage('app:greet Alice') + ->addUsage('app:greet "Bob the Builder"'); + } + + protected function execute(array $arguments, array $options): int + { + // ... + + return EXIT_SUCCESS; + } +} diff --git a/user_guide_src/source/cli/cli_modern_commands/008.php b/user_guide_src/source/cli/cli_modern_commands/008.php new file mode 100644 index 000000000000..43213a3d2690 --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/008.php @@ -0,0 +1,6 @@ +call('cache:clear', $this->getUnboundArguments(), $this->getUnboundOptions()); diff --git a/user_guide_src/source/cli/index.rst b/user_guide_src/source/cli/index.rst index cbbb572243e6..d6cf47d998e8 100644 --- a/user_guide_src/source/cli/index.rst +++ b/user_guide_src/source/cli/index.rst @@ -11,6 +11,7 @@ CodeIgniter 4 can also be used with command line programs. cli_controllers spark_commands cli_commands + cli_modern_commands cli_generators cli_library cli_signals diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index 9bd09760795e..befa4252421a 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -62,16 +62,6 @@ parameters: count: 1 path: ../../system/CodeIgniter.php - - - message: '#^Method CodeIgniter\\Commands\\ListCommands\:\:listFull\(\) has parameter \$commands with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Commands/ListCommands.php - - - - message: '#^Method CodeIgniter\\Commands\\ListCommands\:\:listSimple\(\) has parameter \$commands with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Commands/ListCommands.php - - message: '#^Method CodeIgniter\\Commands\\Translation\\LocalizationFinder\:\:arrayToTableRows\(\) has parameter \$array with no value type specified in iterable type array\.$#' count: 1 From 5a9adf36d3409ee3fcd91837e3c446905b7d6470 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sun, 19 Apr 2026 04:39:44 +0800 Subject: [PATCH 11/11] address reviews --- system/CLI/AbstractCommand.php | 18 ++-- system/CLI/Commands.php | 22 ++--- system/Language/en/Commands.php | 3 +- tests/system/CLI/AbstractCommandTest.php | 8 ++ tests/system/CLI/CommandsTest.php | 59 +++++++++++-- tests/system/Commands/HelpCommandTest.php | 17 ++++ user_guide_src/source/changelogs/v4.8.0.rst | 2 + .../source/cli/cli_modern_commands.rst | 84 +++++++++++++++++++ .../source/cli/cli_modern_commands/009.php | 40 +++++++++ .../source/cli/cli_modern_commands/010.php | 49 +++++++++++ 10 files changed, 275 insertions(+), 27 deletions(-) create mode 100644 user_guide_src/source/cli/cli_modern_commands/009.php create mode 100644 user_guide_src/source/cli/cli_modern_commands/010.php diff --git a/system/CLI/AbstractCommand.php b/system/CLI/AbstractCommand.php index 3e0950f29cbf..d63c9dc3468d 100644 --- a/system/CLI/AbstractCommand.php +++ b/system/CLI/AbstractCommand.php @@ -217,7 +217,7 @@ public function addArgument(Argument $argument): static { $name = $argument->name; - if (array_key_exists($name, $this->argumentsDefinition)) { + if ($this->hasArgument($name)) { throw new InvalidArgumentDefinitionException(lang('Commands.duplicateArgument', [$name])); } @@ -253,15 +253,19 @@ public function addOption(Option $option): static { $name = $option->name; - if (array_key_exists($name, $this->optionsDefinition)) { + if ($this->hasOption($name)) { throw new InvalidOptionDefinitionException(lang('Commands.duplicateOption', [$name])); } - if ($option->shortcut !== null && array_key_exists($option->shortcut, $this->shortcuts)) { + if ($this->hasNegation($name)) { + throw new InvalidOptionDefinitionException(lang('Commands.optionClashesWithExistingNegation', [$name, $this->negations[$name]])); + } + + if ($option->shortcut !== null && $this->hasShortcut($option->shortcut)) { throw new InvalidOptionDefinitionException(lang('Commands.duplicateShortcut', [$option->shortcut, $name, $this->shortcuts[$option->shortcut]])); } - if ($option->negation !== null && array_key_exists($option->negation, $this->optionsDefinition)) { + if ($option->negation !== null && $this->hasOption($option->negation)) { throw new InvalidOptionDefinitionException(lang('Commands.negatableOptionNegationExists', [$name])); } @@ -692,7 +696,7 @@ private function bind(array $arguments, array $options): array // 4. If there are still options left that are not defined, we will mark them as extraneous. foreach ($options as $name => $value) { - if (array_key_exists($name, $this->shortcuts)) { + if ($this->hasShortcut($name)) { // This scenario can happen when the command has an array option with a shortcut, // and the shortcut is used alongside the long name, causing it to be not bound // in the previous loop. @@ -707,7 +711,7 @@ private function bind(array $arguments, array $options): array continue; } - if (array_key_exists($name, $this->negations)) { + if ($this->hasNegation($name)) { // This scenario can happen when the command has a negatable option, // and both the option and its negation are used, causing the negation // to be not bound in the previous loop. @@ -859,7 +863,7 @@ private function validateNegatableOption(string $name, Option $definition, array */ private function getOptionDefinitionFor(string $name): Option { - if (! array_key_exists($name, $this->optionsDefinition)) { + if (! $this->hasOption($name)) { throw new LogicException(sprintf('Option "%s" is not defined on this command.', $name)); } diff --git a/system/CLI/Commands.php b/system/CLI/Commands.php index 1d3ab8f12dc5..c1f3fff5bbf9 100644 --- a/system/CLI/Commands.php +++ b/system/CLI/Commands.php @@ -217,11 +217,13 @@ public function discoverCommands() foreach (array_keys(array_intersect_key($this->commands, $this->modernCommands)) as $name) { CLI::write( - lang('Commands.duplicateCommandName', [ - $name, - $this->commands[$name]['class'], - $this->modernCommands[$name]['class'], - ]), + CLI::wrap( + lang('Commands.duplicateCommandName', [ + $name, + $this->commands[$name]['class'], + $this->modernCommands[$name]['class'], + ]), + ), 'yellow', ); } @@ -248,7 +250,7 @@ public function verifyCommand(string $command, array $commands = [], bool $legac $message = lang('CLI.commandNotFound', [$command]); - $alternatives = $this->getCommandAlternatives($command, legacy: $legacy); + $alternatives = $this->getCommandAlternatives($command); if ($alternatives !== []) { $message = sprintf( @@ -265,24 +267,22 @@ public function verifyCommand(string $command, array $commands = [], bool $legac } /** - * Finds alternative of `$name` among collection of commands. + * Finds alternative of `$name` across both legacy and modern commands. * * @param legacy_commands $collection (no longer used) * * @return list */ - protected function getCommandAlternatives(string $name, array $collection = [], bool $legacy = true): array + protected 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); } - $commandCollection = $legacy ? $this->commands : $this->modernCommands; - /** @var array */ $alternatives = []; - foreach (array_keys($commandCollection) as $commandName) { + foreach (array_keys($this->commands + $this->modernCommands) as $commandName) { $lev = levenshtein($name, $commandName); if ($lev <= strlen($commandName) / 3 || str_contains($commandName, $name)) { diff --git a/system/Language/en/Commands.php b/system/Language/en/Commands.php index bc40faa38b31..29e33a9a876f 100644 --- a/system/Language/en/Commands.php +++ b/system/Language/en/Commands.php @@ -20,7 +20,7 @@ 'arrayOptionEmptyArrayDefault' => 'Array option "--{0}" cannot have an empty array as the default value.', 'argumentAfterArrayArgument' => 'Argument "{0}" cannot be defined after array argument "{1}".', 'duplicateArgument' => 'An argument with the name "{0}" is already defined.', - 'duplicateCommandName' => 'Warning: command "{0}" is defined as both legacy ({1}) and modern ({2}); the legacy command will execute. Please rename or remove one.', + '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.', 'duplicateOption' => 'An option with the name "--{0}" is already defined.', 'duplicateShortcut' => 'Shortcut "-{0}" cannot be used for option "--{1}"; it is already assigned to option "--{2}".', 'emptyCommandName' => 'Command name cannot be empty.', @@ -47,6 +47,7 @@ 'noArgumentsExpected' => 'No arguments expected for "{0}" command. Received: "{1}".', 'nonArrayArgumentWithArrayDefault' => 'Argument "{0}" does not accept an array default value.', 'nonArrayOptionWithArrayValue' => 'Option "--{0}" does not accept an array value.', + 'optionClashesWithExistingNegation' => 'Option "--{0}" clashes with the negation of negatable option "--{1}".', 'optionNoValueAndNoDefault' => 'Option "--{0}" does not accept a value and cannot have a default value.', 'optionNotAcceptingValue' => 'Option "--{0}" does not accept a value.', 'optionalArgumentNoDefault' => 'Argument "{0}" is optional and must have a default value.', diff --git a/tests/system/CLI/AbstractCommandTest.php b/tests/system/CLI/AbstractCommandTest.php index e03572da2abf..a268c9f8fd32 100644 --- a/tests/system/CLI/AbstractCommandTest.php +++ b/tests/system/CLI/AbstractCommandTest.php @@ -193,6 +193,14 @@ public static function provideCollectionLevelOptionRegistrationIsRejected(): ite ['name' => 'test', 'negatable' => true, 'default' => false], ], ]; + + yield 'option name clashes with existing negation' => [ + 'Option "--no-test" clashes with the negation of negatable option "--test".', + [ + ['name' => 'test', 'negatable' => true, 'default' => false], + ['name' => 'no-test'], + ], + ]; } public function testRenderThrowable(): void diff --git a/tests/system/CLI/CommandsTest.php b/tests/system/CLI/CommandsTest.php index 747d2c107161..7675624bdecd 100644 --- a/tests/system/CLI/CommandsTest.php +++ b/tests/system/CLI/CommandsTest.php @@ -79,13 +79,14 @@ public function testRunOnUnknownCommand(): void $this->assertSame(EXIT_ERROR, $commands->runLegacy('app:unknown', [])); $this->assertArrayNotHasKey('app:unknown', $commands->getCommands()); - $this->assertStringContainsString('Command "app:unknown" not found', $this->getStreamFilterBuffer()); + $this->assertSame("\nCommand \"app:unknown\" not found.\n", $this->getUndecoratedBuffer()); $this->resetStreamFilterBuffer(); + CLI::resetLastWrite(); $this->assertSame(EXIT_ERROR, $commands->runCommand('app:unknown', [], [])); $this->assertArrayNotHasKey('app:unknown', $commands->getModernCommands()); - $this->assertStringContainsString('Command "app:unknown" not found', $this->getStreamFilterBuffer()); + $this->assertSame("\nCommand \"app:unknown\" not found.\n", $this->getUndecoratedBuffer()); } public function testRunOnUnknownLegacyCommandButWithOneAlternative(): void @@ -135,6 +136,7 @@ public function testRunOnUnknownLegacyCommandButWithMultipleAlternatives(): void Command "app:" not found. Did you mean one of these? + app:about app:destructive app:info @@ -143,6 +145,42 @@ public function testRunOnUnknownLegacyCommandButWithMultipleAlternatives(): void ); } + public function testRunOnUnknownLegacyCommandAlsoSuggestsModernAlternatives(): void + { + $commands = new Commands(); + + $this->assertSame(EXIT_ERROR, $commands->runLegacy('app:ab', [])); + $this->assertSame( + <<<'EOT' + + Command "app:ab" not found. + + Did you mean this? + app:about + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testRunOnUnknownModernCommandAlsoSuggestsLegacyAlternatives(): void + { + $commands = new Commands(); + + $this->assertSame(EXIT_ERROR, $commands->runCommand('app:inf', [], [])); + $this->assertSame( + <<<'EOT' + + Command "app:inf" not found. + + Did you mean this? + app:info + + EOT, + $this->getUndecoratedBuffer(), + ); + } + public function testRunOnUnknownModernCommandButWithMultipleAlternatives(): void { $commands = new Commands(); @@ -169,7 +207,7 @@ public function testRunOnAbstractLegacyCommandCannotBeRun(): void $this->assertSame(EXIT_ERROR, $commands->runLegacy('app:pablo', [])); $this->assertArrayNotHasKey('app:pablo', $commands->getCommands()); - $this->assertStringContainsString('Command "app:pablo" not found', $this->getStreamFilterBuffer()); + $this->assertSame("\nCommand \"app:pablo\" not found.\n", $this->getUndecoratedBuffer()); } public function testRunOnKnownLegacyCommand(): void @@ -178,7 +216,10 @@ public function testRunOnKnownLegacyCommand(): void $this->assertSame(EXIT_SUCCESS, $commands->runLegacy('app:info', [])); $this->assertArrayHasKey('app:info', $commands->getCommands()); - $this->assertStringContainsString('CodeIgniter Version', $this->getStreamFilterBuffer()); + $this->assertSame( + sprintf("\nCodeIgniter Version: %s\n", CodeIgniter::CI_VERSION), + $this->getUndecoratedBuffer(), + ); } public function testRunOnKnownModernCommand(): void @@ -218,12 +259,14 @@ public function testDiscoveryWarnsWhenSameCommandNameExistsInBothRegistries(): v $this->copyCommand($modernFixture); try { + $message = wordwrap( + '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.', + CLI::getWidth(), + ); + $commands = new Commands(); - $this->assertStringContainsString( - 'Warning: command "dup:test" is defined as both legacy (App\\Commands\\DuplicateLegacy) and modern (App\\Commands\\DuplicateModern)', - $this->getUndecoratedBuffer(), - ); + $this->assertSame("\n{$message}\n", $this->getUndecoratedBuffer()); $this->assertArrayHasKey('dup:test', $commands->getCommands()); $this->assertArrayHasKey('dup:test', $commands->getModernCommands()); } finally { diff --git a/tests/system/Commands/HelpCommandTest.php b/tests/system/Commands/HelpCommandTest.php index b233b590df10..4cd8dbbde3dd 100644 --- a/tests/system/Commands/HelpCommandTest.php +++ b/tests/system/Commands/HelpCommandTest.php @@ -173,6 +173,23 @@ public function testDescribeInexistentCommandButWithAlternatives(): void ); } + public function testDescribeInexistentCommandSuggestsLegacyAlternatives(): void + { + command('help app:inf'); + + $this->assertSame( + <<<'EOT' + + Command "app:inf" not found. + + Did you mean this? + app:info + + EOT, + $this->getUndecoratedBuffer(), + ); + } + public function testDescribeUsingHelpOption(): void { command('cache:clear --help'); diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 2fce0f075c7d..9999d33c387a 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -28,6 +28,8 @@ Behavior Changes - **Commands:** The ``-h`` option to ``routes`` command which was mapped previously to the ``--handler`` option is now removed. Use ``--handler`` instead to sort the routes by handler when running the ``routes`` command. - **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 /``). +- **Commands:** Several built-in commands have been migrated from ``BaseCommand`` to the modern ``AbstractCommand`` style. Applications that extend a built-in command to override + 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. - **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. - **Filters:** HTTP method matching for method-based filters is now case-sensitive. The keys in ``Config\Filters::$methods`` must exactly match the request method (e.g., ``GET``, ``POST``). Lowercase method names (e.g., ``post``) will no longer match. diff --git a/user_guide_src/source/cli/cli_modern_commands.rst b/user_guide_src/source/cli/cli_modern_commands.rst index 955bce86af3b..dcb14e242f38 100644 --- a/user_guide_src/source/cli/cli_modern_commands.rst +++ b/user_guide_src/source/cli/cli_modern_commands.rst @@ -141,6 +141,17 @@ A few quirks are worth knowing: Configuration-time violations raise ``InvalidOptionDefinitionException``. +.. note:: + + The command-line parser does **not** understand bundled shortcuts or + shortcuts with a glued value: + + - ``-abc`` is read as one option named ``abc``, *not* as ``-a -b -c``. + - ``-fvalue`` is read as one option named ``fvalue``; it is not split into + shortcut ``-f`` with value ``value``. + + Pass shortcut values with ``-f=value`` or ``-f value`` instead. + ************************* Interacting With the User ************************* @@ -235,6 +246,79 @@ formatted output the framework uses for uncaught exceptions, call ``$this->renderThrowable($exception)``. The helper is safe to call from any command, and it will not disturb the currently shared request. +********************************** +Migrating From ``BaseCommand`` +********************************** + +The modern command system is a superset of the legacy ``BaseCommand`` API — the +same capabilities are there, just expressed through an attribute and explicit +definitions rather than class properties and ad-hoc lookups. + +**Identity** + +``protected $name`` + Moves to ``name:`` on the ``#[Command]`` attribute. + +``protected $description`` + Moves to ``description:`` on the ``#[Command]`` attribute. + +``protected $group`` + Moves to ``group:`` on the ``#[Command]`` attribute. An empty group skips the command at discovery. + +**Input surface (declare inside** ``configure()`` **)** + +``protected $usage`` + The default usage line is generated from the declared arguments. Register extras with ``addUsage()``. + +``protected $arguments`` + One ``addArgument(new Argument(...))`` call per argument. + +``protected $options`` + One ``addOption(new Option(...))`` call per option. A long name is required; a legacy ``-x``-style + option becomes ``new Option(name: 'something', shortcut: 'x')``. + +**Runtime** + +``run(array $params)`` + No longer the extension point — ``run()`` is ``final`` on ``AbstractCommand`` and drives the lifecycle itself. + Move the body into ``execute(array $arguments, array $options): int``, which must return an ``EXIT_*`` status. + +``$params[0]`` + Use ``$arguments['name']`` or ``$this->getValidatedArgument('name')``. + +``$params['name']`` / ``CLI::getOption('name')`` + Use ``$options['name']`` or ``$this->getValidatedOption('name')``. + Call ``$this->hasUnboundOption('name')`` when you need to know whether the flag was actually passed. + +``$this->call('other', $params)`` + Becomes ``$this->call('other', $arguments, $options)``; only from inside ``execute()``. + To forward the caller's own raw input, pass ``$this->getUnboundArguments()`` and ``$this->getUnboundOptions()``. + +``$this->showError($e)`` + Becomes ``$this->renderThrowable($e)``. + +``showHelp()`` override + Gone. The built-in ``help`` command builds the help output itself from the declared arguments, options, and usages. + +Prompting the user mid-run stays with ``CLI::prompt()``, but the idiomatic spot moves from ``run()`` +to ``interact()`` so validation can see whatever the user provides interactively. + +A typical ``BaseCommand`` implementation: + +.. literalinclude:: cli_modern_commands/009.php + +…becomes, as a modern command: + +.. literalinclude:: cli_modern_commands/010.php + +Two behavioural changes are worth calling out explicitly: + +- **Validated, not raw.** Arguments and options are parsed, defaulted, and validated before ``execute()`` runs. + If a required argument is missing or a ``requiresValue`` option was passed without a value, the framework + raises a typed exception and your command is never entered. +- **Exit codes are mandatory.** Legacy ``run()`` could return ``null``. The modern ``execute()`` must return an + integer; the framework emits a deprecation notice for any legacy command that still returns ``null``. + ******************************** Coexistence With Legacy Commands ******************************** diff --git a/user_guide_src/source/cli/cli_modern_commands/009.php b/user_guide_src/source/cli/cli_modern_commands/009.php new file mode 100644 index 000000000000..9536813ab6fa --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/009.php @@ -0,0 +1,40 @@ +] [options]'; + protected $arguments = ['target' => 'What to publish.']; + protected $options = [ + '--force' => 'Overwrite existing output.', + '--dry-run' => 'Print the plan without writing anything.', + ]; + + public function run(array $params) + { + try { + $target = $params[0] ?? CLI::prompt('What should I publish?', null, 'required'); + $force = CLI::getOption('force') !== null; + $dryRun = CLI::getOption('dry-run') !== null; + + // ... + unset($force, $dryRun); + + CLI::write(sprintf('publishing %s', $target), 'green'); + + return EXIT_SUCCESS; + } catch (Throwable $e) { + $this->showError($e); + + return EXIT_ERROR; + } + } +} diff --git a/user_guide_src/source/cli/cli_modern_commands/010.php b/user_guide_src/source/cli/cli_modern_commands/010.php new file mode 100644 index 000000000000..23d414808e5a --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/010.php @@ -0,0 +1,49 @@ +addArgument(new Argument(name: 'target', description: 'What to publish.', required: true)) + ->addOption(new Option(name: 'force', description: 'Overwrite existing output.')) + ->addOption(new Option(name: 'dry-run', description: 'Print the plan without writing anything.')); + } + + protected function interact(array &$arguments, array &$options): void + { + if ($arguments === []) { + $arguments[] = CLI::prompt('What should I publish?', null, 'required'); + } + } + + protected function execute(array $arguments, array $options): int + { + try { + $target = $this->getValidatedArgument('target'); + $force = $this->getValidatedOption('force') === true; + $dryRun = $this->getValidatedOption('dry-run') === true; + + // ... + unset($force, $dryRun); + + CLI::write(sprintf('publishing %s', $target), 'green'); + + return EXIT_SUCCESS; + } catch (Throwable $e) { + $this->renderThrowable($e); + + return EXIT_ERROR; + } + } +}