From 849a589b4ea018afbe193bdb7b6b73005a19cb82 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Wed, 9 Sep 2026 02:26:11 +0800 Subject: [PATCH] refactor: migrate `make:controller` to `AbstractGeneratorCommand` --- system/CLI/AbstractGeneratorCommand.php | 4 +- .../Generators/ControllerGenerator.php | 173 ++++++++---------- .../Commands/Generators/ScaffoldGenerator.php | 4 +- system/Language/en/CLI.php | 27 +-- .../CLI/AbstractGeneratorCommandTest.php | 6 +- .../Generators/ControllerGeneratorTest.php | 122 ++++++++---- tests/system/Commands/HelpCommandTest.php | 8 +- user_guide_src/source/changelogs/v4.8.0.rst | 2 + user_guide_src/source/cli/cli_generators.rst | 10 +- 9 files changed, 196 insertions(+), 160 deletions(-) diff --git a/system/CLI/AbstractGeneratorCommand.php b/system/CLI/AbstractGeneratorCommand.php index 38235a764d22..ecb1da28f821 100644 --- a/system/CLI/AbstractGeneratorCommand.php +++ b/system/CLI/AbstractGeneratorCommand.php @@ -80,9 +80,9 @@ protected function configure(): void protected function provideDefaultOptions(): void { - parent::provideDefaultOptions(); - $this->provideGeneratorOptions(); + + parent::provideDefaultOptions(); } /** diff --git a/system/Commands/Generators/ControllerGenerator.php b/system/Commands/Generators/ControllerGenerator.php index 672b344082ca..4a53c6258668 100644 --- a/system/Commands/Generators/ControllerGenerator.php +++ b/system/Commands/Generators/ControllerGenerator.php @@ -13,126 +13,103 @@ namespace CodeIgniter\Commands\Generators; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractGeneratorCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\Attributes\GeneratorCommand; use CodeIgniter\CLI\CLI; -use CodeIgniter\CLI\GeneratorTrait; +use CodeIgniter\CLI\Input\Option; use CodeIgniter\Controller; use CodeIgniter\RESTful\ResourceController; use CodeIgniter\RESTful\ResourcePresenter; -/** - * Generates a skeleton controller file. - */ -class ControllerGenerator extends BaseCommand +#[Command(name: 'make:controller', description: 'Generates a new controller file.', group: 'Generators')] +#[GeneratorCommand( + component: 'Controller', + template: 'controller.tpl.php', + directory: 'Controllers', + classNameLang: 'CLI.generator.className.controller', +)] +class ControllerGenerator extends AbstractGeneratorCommand { - use GeneratorTrait; + protected function configure(): void + { + parent::configure(); + + $this + ->addOption(new Option( + name: 'bare', + shortcut: 'b', + description: 'Extend CodeIgniter\Controller instead of BaseController.', + )) + ->addOption(new Option( + name: 'restful', + shortcut: 'r', + description: 'Extend a RESTful resource: "controller" (default when no value is given) or "presenter".', + acceptsValue: true, + valueLabel: 'type', + )); + } - /** - * The Command's Group - * - * @var string - */ - protected $group = 'Generators'; + protected function interact(array &$arguments, array &$options): void + { + $type = $this->getUnboundOption('restful', $options); - /** - * The Command's Name - * - * @var string - */ - protected $name = 'make:controller'; + if (! is_string($type) || $type === 'controller' || $type === 'presenter') { + return; + } - /** - * The Command's Description - * - * @var string - */ - protected $description = 'Generates a new controller file.'; + $options['restful'] = CLI::prompt(lang('CLI.generator.parentClass'), ['controller', 'presenter'], 'required'); + } - /** - * The Command's Usage - * - * @var string - */ - protected $usage = 'make:controller [options]'; + protected function execute(array $arguments, array $options): int + { + $type = $this->getResourceType(); - /** - * The Command's Arguments - * - * @var array - */ - protected $arguments = [ - 'name' => 'The controller class name.', - ]; + if (! in_array($type, [null, 'controller', 'presenter'], true)) { + CLI::error(lang('CLI.generator.invalidParentClass', [$type])); - /** - * The Command's Options - * - * @var array - */ - protected $options = [ - '--bare' => 'Extends from CodeIgniter\Controller instead of BaseController.', - '--restful' => 'Extends from a RESTful resource, Options: [controller, presenter]. Default: "controller".', - '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".', - '--suffix' => 'Append the component title to the class name (e.g. User => UserController).', - '--force' => 'Force overwrite existing file.', - ]; + return EXIT_ERROR; + } - /** - * Actually execute a command. - */ - public function run(array $params) + return $this->generateClass(); + } + + protected function getReplacements(string $class): array { - $this->component = 'Controller'; - $this->directory = 'Controllers'; - $this->template = 'controller.tpl.php'; + $parent = $this->getParentClass(); - $this->classNameLang = 'CLI.generator.className.controller'; - $this->generateClass($params); + return ['{useStatement}' => $parent, '{extends}' => class_basename($parent)]; + } + + protected function getTemplateData(string $class): array + { + return ['type' => $this->getValidatedOption('bare') === true ? null : $this->getResourceType()]; + } - return EXIT_SUCCESS; + private function getParentClass(): string + { + if ($this->getValidatedOption('bare') === true) { + return Controller::class; + } + + return match ($this->getResourceType()) { + 'controller' => ResourceController::class, + 'presenter' => ResourcePresenter::class, + default => trim(APP_NAMESPACE, '\\') . '\\Controllers\\BaseController', + }; } /** - * Prepare options and do the necessary replacements. + * Returns the RESTful resource type, or `null` when `--restful` was not passed. */ - protected function prepare(string $class): string + private function getResourceType(): ?string { - $bare = $this->getOption('bare'); - $rest = $this->getOption('restful'); - - $useStatement = trim(APP_NAMESPACE, '\\') . '\Controllers\BaseController'; - $extends = 'BaseController'; - - // Gets the appropriate parent class to extend. - if ($bare || $rest) { - if ($bare) { - $useStatement = Controller::class; - $extends = 'Controller'; - } elseif ($rest) { - $rest = is_string($rest) ? $rest : 'controller'; - - if (! in_array($rest, ['controller', 'presenter'], true)) { - // @codeCoverageIgnoreStart - $rest = CLI::prompt(lang('CLI.generator.parentClass'), ['controller', 'presenter'], 'required'); - CLI::newLine(); - // @codeCoverageIgnoreEnd - } - - if ($rest === 'controller') { - $useStatement = ResourceController::class; - $extends = 'ResourceController'; - } elseif ($rest === 'presenter') { - $useStatement = ResourcePresenter::class; - $extends = 'ResourcePresenter'; - } - } + if (! $this->hasUnboundOption('restful')) { + return null; } - return $this->parseTemplate( - $class, - ['{useStatement}', '{extends}'], - [$useStatement, $extends], - ['type' => $rest], - ); + $type = $this->getValidatedOption('restful'); + + return is_string($type) ? $type : 'controller'; } } diff --git a/system/Commands/Generators/ScaffoldGenerator.php b/system/Commands/Generators/ScaffoldGenerator.php index 727f19298731..a5d3d62632e1 100644 --- a/system/Commands/Generators/ScaffoldGenerator.php +++ b/system/Commands/Generators/ScaffoldGenerator.php @@ -103,7 +103,9 @@ public function run(array $params) if ($this->getOption('bare')) { $controllerOpts['bare'] = null; } elseif ($this->getOption('restful')) { - $controllerOpts['restful'] = $this->getOption('restful'); + $restful = $this->getOption('restful'); + + $controllerOpts['restful'] = is_string($restful) ? $restful : null; } $modelOpts = [ diff --git a/system/Language/en/CLI.php b/system/Language/en/CLI.php index 3d44dec060da..9a9753fd08d9 100644 --- a/system/Language/en/CLI.php +++ b/system/Language/en/CLI.php @@ -36,19 +36,20 @@ 'transformer' => 'Transformer class name', 'validation' => 'Validation class name', ], - 'commandType' => 'Command type', - 'confirmContinue' => 'Are you sure you want to continue?', - 'databaseGroup' => 'Database group', - 'fileCreate' => 'File created: {0}', - 'fileError' => 'Error while creating file: "{0}"', - 'fileExist' => 'File exists: "{0}"', - 'fileOverwrite' => 'File overwritten: "{0}"', - 'invalidClassName' => 'Class name "{0}" is not valid.', - 'parentClass' => 'Parent class', - 'returnType' => 'Return type', - 'tableName' => 'Table name', - 'usingCINamespace' => 'Warning: Using the "CodeIgniter" namespace will generate the file in the system directory.', - 'viewName' => [ + 'commandType' => 'Command type', + 'confirmContinue' => 'Are you sure you want to continue?', + 'databaseGroup' => 'Database group', + 'fileCreate' => 'File created: {0}', + 'fileError' => 'Error while creating file: "{0}"', + 'fileExist' => 'File exists: "{0}"', + 'fileOverwrite' => 'File overwritten: "{0}"', + 'invalidClassName' => 'Class name "{0}" is not valid.', + 'invalidParentClass' => 'Parent class "{0}" is not valid.', + 'parentClass' => 'Parent class', + 'returnType' => 'Return type', + 'tableName' => 'Table name', + 'usingCINamespace' => 'Warning: Using the "CodeIgniter" namespace will generate the file in the system directory.', + 'viewName' => [ 'cell' => 'Cell view name', ], ], diff --git a/tests/system/CLI/AbstractGeneratorCommandTest.php b/tests/system/CLI/AbstractGeneratorCommandTest.php index 80d2809d961a..3830b765e993 100644 --- a/tests/system/CLI/AbstractGeneratorCommandTest.php +++ b/tests/system/CLI/AbstractGeneratorCommandTest.php @@ -84,11 +84,11 @@ public function testCommandDeclaresGeneratorDefinition(): void $this->assertSame(['name'], array_keys($arguments)); $this->assertTrue($arguments['name']->required); $this->assertSame( - ['help', 'no-header', 'no-interaction', 'namespace', 'suffix', 'force'], + ['namespace', 'suffix', 'force', 'help', 'no-header', 'no-interaction'], array_keys($command->getOptionsDefinition()), ); $this->assertSame( - ['h' => 'help', 'N' => 'no-interaction', 'n' => 'namespace', 's' => 'suffix', 'f' => 'force'], + ['n' => 'namespace', 's' => 'suffix', 'f' => 'force', 'h' => 'help', 'N' => 'no-interaction'], $command->getShortcuts(), ); $this->assertSame('make:testwidget [options] [--] ', $command->getUsages()[0]); @@ -99,7 +99,7 @@ public function testTrimmedCommandDeclaresReducedDefinition(): void $command = new TrimmedOptionsGeneratorCommand(new Commands()); $this->assertSame( - ['help', 'no-header', 'no-interaction', 'namespace'], + ['namespace', 'help', 'no-header', 'no-interaction'], array_keys($command->getOptionsDefinition()), ); } diff --git a/tests/system/Commands/Generators/ControllerGeneratorTest.php b/tests/system/Commands/Generators/ControllerGeneratorTest.php index 5382c05e70f4..4b39c1f19d78 100644 --- a/tests/system/Commands/Generators/ControllerGeneratorTest.php +++ b/tests/system/Commands/Generators/ControllerGeneratorTest.php @@ -13,7 +13,10 @@ namespace CodeIgniter\Commands\Generators; +use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Commands; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockInputOutput; use CodeIgniter\Test\StreamFilterTrait; use PHPUnit\Framework\Attributes\Group; @@ -25,64 +28,115 @@ final class ControllerGeneratorTest extends CIUnitTestCase { use StreamFilterTrait; + protected function setUp(): void + { + parent::setUp(); + + CLI::reset(); + } + protected function tearDown(): void { - $result = str_replace(["\033[0;32m", "\033[0m", "\n"], '', $this->getStreamFilterBuffer()); - $file = str_replace('APPPATH' . DIRECTORY_SEPARATOR, APPPATH, trim(substr($result, 14))); - if (is_file($file)) { - unlink($file); + parent::tearDown(); + + CLI::reset(); + + foreach (['User.php', 'Blog.php', 'Order.php', 'Pay.php', 'Mixed.php', 'Bogus.php', 'DashboardController.php'] as $file) { + if (is_file(APPPATH . 'Controllers/' . $file)) { + unlink(APPPATH . 'Controllers/' . $file); + } } } - protected function getFileContents(string $filepath): string + private function getUndecoratedBuffer(): string { - if (! is_file($filepath)) { - return ''; - } + return preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()) ?? ''; + } - return (string) file_get_contents($filepath); + private function getContents(string $file): string + { + $contents = file_get_contents(APPPATH . 'Controllers/' . $file); + $this->assertIsString($contents); + + return $contents; } public function testGenerateController(): void { command('make:controller user'); - $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); - $file = APPPATH . 'Controllers/User.php'; - $this->assertFileExists($file); - $this->assertStringContainsString('extends BaseController', $this->getFileContents($file)); + + $this->assertSame( + PHP_EOL . 'File created: ' . clean_path(APPPATH . 'Controllers/User.php') . PHP_EOL, + $this->getUndecoratedBuffer(), + ); + + $contents = $this->getContents('User.php'); + $this->assertStringContainsString('use App\Controllers\BaseController;', $contents); + $this->assertStringContainsString('class User extends BaseController', $contents); } - public function testGenerateControllerWithOptionBare(): void + public function testGenerateControllerWithBare(): void { - command('make:controller blog -bare'); - $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); - $file = APPPATH . 'Controllers/Blog.php'; - $this->assertFileExists($file); - $this->assertStringContainsString('extends Controller', $this->getFileContents($file)); + command('make:controller blog --bare'); + + $contents = $this->getContents('Blog.php'); + $this->assertStringContainsString('use CodeIgniter\Controller;', $contents); + $this->assertStringContainsString('class Blog extends Controller', $contents); } - public function testGenerateControllerWithOptionRestful(): void + public function testGenerateControllerWithRestful(): void { - command('make:controller order -restful'); - $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); - $file = APPPATH . 'Controllers/Order.php'; - $this->assertFileExists($file); - $this->assertStringContainsString('extends ResourceController', $this->getFileContents($file)); + command('make:controller order --restful'); + + $contents = $this->getContents('Order.php'); + $this->assertStringContainsString('class Order extends ResourceController', $contents); + $this->assertStringContainsString('public function show($id = null)', $contents); } - public function testGenerateControllerWithOptionRestfulPresenter(): void + public function testGenerateControllerWithRestfulPresenter(): void { - command('make:controller pay -restful presenter'); - $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); - $file = APPPATH . 'Controllers/Pay.php'; - $this->assertFileExists($file); - $this->assertStringContainsString('extends ResourcePresenter', $this->getFileContents($file)); + command('make:controller pay --restful presenter'); + + $contents = $this->getContents('Pay.php'); + $this->assertStringContainsString('class Pay extends ResourcePresenter', $contents); + $this->assertStringContainsString('public function remove($id = null)', $contents); } - public function testGenerateControllerWithOptionSuffix(): void + public function testBareTakesPrecedenceOverRestful(): void { - command('make:controller dashboard -suffix'); - $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); + command('make:controller mixed --bare --restful'); + + $contents = $this->getContents('Mixed.php'); + $this->assertStringContainsString('class Mixed extends Controller', $contents); + $this->assertStringNotContainsString('public function show', $contents); + } + + public function testInvalidRestfulTypeIsRejectedWhenNotInteractive(): void + { + command('make:controller bogus --restful api --no-interaction'); + + $this->assertSame(PHP_EOL . 'Parent class "api" is not valid.' . PHP_EOL, $this->getUndecoratedBuffer()); + $this->assertFileDoesNotExist(APPPATH . 'Controllers/Bogus.php'); + } + + public function testInvalidRestfulTypePromptsWhenInteractive(): void + { + $io = new MockInputOutput(); + $io->setInputs(['presenter']); + CLI::setInputOutput($io); + + $command = new ControllerGenerator(new Commands()); + $command->setInteractive(true); + + $this->assertSame(EXIT_SUCCESS, $command->run(['pay'], ['restful' => 'api'])); + $this->assertStringContainsString('Parent class', $io->getOutput()); + $this->assertStringContainsString('class Pay extends ResourcePresenter', $this->getContents('Pay.php')); + } + + public function testGenerateControllerWithSuffix(): void + { + command('make:controller dashboard --suffix'); + $this->assertFileExists(APPPATH . 'Controllers/DashboardController.php'); } } diff --git a/tests/system/Commands/HelpCommandTest.php b/tests/system/Commands/HelpCommandTest.php index f39ef72efde5..61c3b281e578 100644 --- a/tests/system/Commands/HelpCommandTest.php +++ b/tests/system/Commands/HelpCommandTest.php @@ -223,12 +223,12 @@ public function testDescribeGeneratorCommand(): void name The widget class name. Options: - -h, --help Display help for the given command. - --no-header Do not display the banner when running the command. - -N, --no-interaction Do not ask any interactive questions. -n, --namespace=NAMESPACE Set the root namespace. [default: "App"] -s, --suffix Append the "Widget" suffix to the class name. -f, --force Force overwrite existing file. + -h, --help Display help for the given command. + --no-header Do not display the banner when running the command. + -N, --no-interaction Do not ask any interactive questions. EOT, $this->getUndecoratedBuffer(), @@ -252,10 +252,10 @@ public function testDescribeGeneratorCommandWithTrimmedOptions(): void name The widget class name. Options: + -n, --namespace=NAMESPACE Set the root namespace. [default: "App"] -h, --help Display help for the given command. --no-header Do not display the banner when running the command. -N, --no-interaction Do not ask any interactive questions. - -n, --namespace=NAMESPACE Set the root namespace. [default: "App"] EOT, $this->getUndecoratedBuffer(), diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index d296985cca1d..21e61a9df992 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -234,6 +234,8 @@ Commands and ``-f`` shortcuts for ``--namespace``, ``--suffix``, and ``--force``. See :doc:`../cli/cli_modern_generators`. - Legacy ``BaseCommand::call()`` can now invoke modern commands: integer-keyed params are passed as arguments and string-keyed params as options, which the target command validates like any other input. +- The ``make:controller`` command now accepts ``-b`` and ``-r`` as shortcuts for ``--bare`` and ``--restful``, and rejects an invalid + ``--restful`` value on non-interactive runs instead of prompting. - Modern commands can now opt in to prompting for missing required arguments on interactive runs by implementing the new ``PromptsForMissingInputInterface`` marker interface. Prompt labels can be customized via ``getArgumentPromptLabels()``, and an ``afterPrompting()`` hook runs when prompting occurred. Non-interactive runs keep failing fast with the missing-arguments error. diff --git a/user_guide_src/source/cli/cli_generators.rst b/user_guide_src/source/cli/cli_generators.rst index 80634f8867dd..53f73bf01f41 100644 --- a/user_guide_src/source/cli/cli_generators.rst +++ b/user_guide_src/source/cli/cli_generators.rst @@ -129,11 +129,11 @@ Argument: Options: ======== -* ``--bare``: Extends from ``CodeIgniter\Controller`` instead of ``BaseController``. -* ``--restful``: Extends from a RESTful resource. Choices are ``controller`` and ``presenter``. Defaults to ``controller``. -* ``--namespace``: Set the root namespace. Defaults to value of ``APP_NAMESPACE``. -* ``--suffix``: Append the component suffix to the generated class name. -* ``--force``: Set this flag to overwrite existing files on destination. +* ``--bare`` (``-b``): Extends from ``CodeIgniter\Controller`` instead of ``BaseController``. +* ``--restful`` (``-r``): Extends from a RESTful resource. Choices are ``controller`` and ``presenter``. Defaults to ``controller``. +* ``--namespace`` (``-n``): Set the root namespace. Defaults to value of ``APP_NAMESPACE``. +* ``--suffix`` (``-s``): Append the component suffix to the generated class name. +* ``--force`` (``-f``): Set this flag to overwrite existing files on destination. .. note:: If you use ``--suffix``, the generated controller name will be like ``ProductController``. That violates the Controller naming convention