From 2477ead4b8429092df2fa72db810843233e4b549 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sun, 6 Sep 2026 00:38:05 +0800 Subject: [PATCH] feat: add `AbstractGeneratorCommand` base class for modern generator commands --- app/Config/Generators.php | 19 +- system/CLI/AbstractCommand.php | 94 +++- system/CLI/AbstractGeneratorCommand.php | 414 ++++++++++++++++ system/CLI/Attributes/GeneratorCommand.php | 97 ++++ system/CLI/Commands.php | 15 + system/CLI/GeneratorTrait.php | 2 +- .../CLI/PromptsForMissingInputInterface.php | 21 + system/Language/en/CLI.php | 3 + system/Language/en/Commands.php | 7 + .../Modern/GeneratorFixtureCommand.php | 24 + .../Modern/ImportSortingGeneratorCommand.php | 38 ++ .../NoImportSortingGeneratorCommand.php | 23 + .../Modern/PromptingFixtureCommand.php | 60 +++ .../Modern/TrimmedOptionsGeneratorCommand.php | 38 ++ .../InvalidComponentGeneratorCommand.php | 24 + .../NoAttributeGeneratorCommand.php | 22 + tests/system/CLI/AbstractCommandTest.php | 81 ++++ .../CLI/AbstractGeneratorCommandTest.php | 453 ++++++++++++++++++ .../CLI/Attributes/GeneratorCommandTest.php | 115 +++++ tests/system/CLI/CommandsTest.php | 58 +++ tests/system/Commands/HelpCommandTest.php | 56 +++ user_guide_src/source/changelogs/v4.8.0.rst | 6 + user_guide_src/source/cli/cli_generators.rst | 4 + .../source/cli/cli_modern_commands.rst | 29 ++ .../source/cli/cli_modern_commands/015.php | 34 ++ .../source/cli/cli_modern_generators.rst | 354 ++++++++++++++ .../source/cli/cli_modern_generators/001.php | 13 + .../source/cli/cli_modern_generators/002.php | 13 + .../source/cli/cli_modern_generators/003.php | 38 ++ .../source/cli/cli_modern_generators/004.php | 24 + .../source/cli/cli_modern_generators/005.php | 33 ++ user_guide_src/source/cli/index.rst | 1 + 32 files changed, 2188 insertions(+), 25 deletions(-) create mode 100644 system/CLI/AbstractGeneratorCommand.php create mode 100644 system/CLI/Attributes/GeneratorCommand.php create mode 100644 system/CLI/PromptsForMissingInputInterface.php create mode 100644 tests/_support/Commands/Modern/GeneratorFixtureCommand.php create mode 100644 tests/_support/Commands/Modern/ImportSortingGeneratorCommand.php create mode 100644 tests/_support/Commands/Modern/NoImportSortingGeneratorCommand.php create mode 100644 tests/_support/Commands/Modern/PromptingFixtureCommand.php create mode 100644 tests/_support/Commands/Modern/TrimmedOptionsGeneratorCommand.php create mode 100644 tests/_support/InvalidCommands/InvalidComponentGeneratorCommand.php create mode 100644 tests/_support/InvalidCommands/NoAttributeGeneratorCommand.php create mode 100644 tests/system/CLI/AbstractGeneratorCommandTest.php create mode 100644 tests/system/CLI/Attributes/GeneratorCommandTest.php create mode 100644 user_guide_src/source/cli/cli_modern_commands/015.php create mode 100644 user_guide_src/source/cli/cli_modern_generators.rst create mode 100644 user_guide_src/source/cli/cli_modern_generators/001.php create mode 100644 user_guide_src/source/cli/cli_modern_generators/002.php create mode 100644 user_guide_src/source/cli/cli_modern_generators/003.php create mode 100644 user_guide_src/source/cli/cli_modern_generators/004.php create mode 100644 user_guide_src/source/cli/cli_modern_generators/005.php diff --git a/app/Config/Generators.php b/app/Config/Generators.php index cc92c7aa432f..2600f0cdea08 100644 --- a/app/Config/Generators.php +++ b/app/Config/Generators.php @@ -30,15 +30,14 @@ class Generators extends BaseConfig 'class' => 'CodeIgniter\Commands\Generators\Views\cell.tpl.php', 'view' => 'CodeIgniter\Commands\Generators\Views\cell_view.tpl.php', ], - 'make:command' => 'CodeIgniter\Commands\Generators\Views\command.tpl.php', - 'make:config' => 'CodeIgniter\Commands\Generators\Views\config.tpl.php', - 'make:controller' => 'CodeIgniter\Commands\Generators\Views\controller.tpl.php', - 'make:entity' => 'CodeIgniter\Commands\Generators\Views\entity.tpl.php', - 'make:filter' => 'CodeIgniter\Commands\Generators\Views\filter.tpl.php', - 'make:migration' => 'CodeIgniter\Commands\Generators\Views\migration.tpl.php', - 'make:model' => 'CodeIgniter\Commands\Generators\Views\model.tpl.php', - 'make:seeder' => 'CodeIgniter\Commands\Generators\Views\seeder.tpl.php', - 'make:validation' => 'CodeIgniter\Commands\Generators\Views\validation.tpl.php', - 'session:migration' => 'CodeIgniter\Commands\Generators\Views\migration.tpl.php', + 'make:command' => 'CodeIgniter\Commands\Generators\Views\command.tpl.php', + 'make:config' => 'CodeIgniter\Commands\Generators\Views\config.tpl.php', + 'make:controller' => 'CodeIgniter\Commands\Generators\Views\controller.tpl.php', + 'make:entity' => 'CodeIgniter\Commands\Generators\Views\entity.tpl.php', + 'make:filter' => 'CodeIgniter\Commands\Generators\Views\filter.tpl.php', + 'make:migration' => 'CodeIgniter\Commands\Generators\Views\migration.tpl.php', + 'make:model' => 'CodeIgniter\Commands\Generators\Views\model.tpl.php', + 'make:seeder' => 'CodeIgniter\Commands\Generators\Views\seeder.tpl.php', + 'make:validation' => 'CodeIgniter\Commands\Generators\Views\validation.tpl.php', ]; } diff --git a/system/CLI/AbstractCommand.php b/system/CLI/AbstractCommand.php index 011058598d99..2f8c4c167f07 100644 --- a/system/CLI/AbstractCommand.php +++ b/system/CLI/AbstractCommand.php @@ -82,11 +82,9 @@ abstract class AbstractCommand private array $requiredArguments = []; /** - * Cache of resolved `Command` attributes keyed by class name. - * - * @var array, Command> + * @var array */ - private static array $commandAttributeCache = []; + private static array $classAttributeCache = []; /** * The unbound arguments that can be passed to other commands when called via the `call()` method. @@ -138,7 +136,7 @@ abstract class AbstractCommand */ public function __construct(private readonly Commands $commands) { - $attribute = $this->getCommandAttribute(); + $attribute = $this->resolveClassAttribute(Command::class); $this->name = $attribute->name; $this->description = $attribute->description; @@ -408,6 +406,8 @@ public function setInteractive(bool $interactive): static * 1. Run `isAvailable()` to check if the command can be run in the current environment. * 2. `initialize()` and `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. + * In between, a command implementing `PromptsForMissingInputInterface` is prompted for its missing + * required arguments when running interactively. * Note: the per-run interactive state is captured from `$options` before `initialize()` runs, so * mutating `--no-interaction` from within `initialize()` will not affect this invocation. Use * `setInteractive()` instead. @@ -442,6 +442,10 @@ final public function run(array $arguments, array $options): int $this->initialize($arguments, $options); if ($this->isInteractive()) { + if ($this instanceof PromptsForMissingInputInterface) { + $this->promptForMissingArguments($arguments, $options); + } + $this->interact($arguments, $options); } @@ -505,6 +509,30 @@ protected function interact(array &$arguments, array &$options): void { } + /** + * Map of argument name to the prompt label used when prompting for that missing argument. + * + * Consulted only when the command implements `PromptsForMissingInputInterface`. + * + * @return array + */ + protected function getArgumentPromptLabels(): array + { + return []; + } + + /** + * Hook called after at least one missing required argument has been prompted for. + * + * Called only when the command implements `PromptsForMissingInputInterface`. + * + * @param list $arguments Parsed arguments from command line. + * @param array|string|null> $options Parsed options from command line. + */ + protected function afterPrompting(array &$arguments, array &$options): void + { + } + /** * Executes the command with the bound arguments and options. * @@ -730,6 +758,35 @@ protected function provideDefaultOptions(): void ->addOption(new Option(name: 'no-interaction', shortcut: 'N', description: 'Do not ask any interactive questions.')); } + /** + * Prompts for each missing required argument and appends the answers to the raw arguments. + * + * @param list $arguments Parsed arguments from command line. + * @param array|string|null> $options Parsed options from command line. + */ + private function promptForMissingArguments(array &$arguments, array &$options): void + { + // A null reader cannot satisfy a required prompt, so leave the missing arguments to validation. + if (CLI::getInputOutput() instanceof NullInputOutput) { + return; + } + + $missing = array_slice($this->requiredArguments, count($arguments)); + + if ($missing === []) { + return; + } + + $labels = $this->getArgumentPromptLabels(); + + foreach ($missing as $name) { + $arguments[] = CLI::prompt($labels[$name] ?? lang('CLI.argumentPrompt', [$name]), null, 'required'); + CLI::newLine(); + } + + $this->afterPrompting($arguments, $options); + } + /** * Reconciles the caller's explicit intent (`$noInteractionOverride`) with * the parent command's own interactive state to produce the `$options` @@ -1054,22 +1111,31 @@ private function assertOptionIsDefined(string $name): void } /** + * Resolves a class-level attribute of this command, memoized per command class. + * + * @template T of object + * + * @param class-string $attributeClass + * + * @return T + * * @throws LogicException */ - private function getCommandAttribute(): Command + final protected function resolveClassAttribute(string $attributeClass): object { - $class = static::class; + $key = static::class . '@' . $attributeClass; - if (array_key_exists($class, self::$commandAttributeCache)) { - return self::$commandAttributeCache[$class]; - } + if (! array_key_exists($key, self::$classAttributeCache)) { + $attribute = (new ReflectionClass($this))->getAttributes($attributeClass)[0] + ?? throw new LogicException(lang('Commands.missingCommandAttribute', [static::class, $attributeClass])); - $attribute = (new ReflectionClass($this))->getAttributes(Command::class)[0] - ?? throw new LogicException(lang('Commands.missingCommandAttribute', [$class, Command::class])); + self::$classAttributeCache[$key] = $attribute->newInstance(); + } - self::$commandAttributeCache[$class] = $attribute->newInstance(); + $instance = self::$classAttributeCache[$key]; + assert($instance instanceof $attributeClass); - return self::$commandAttributeCache[$class]; + return $instance; } /** diff --git a/system/CLI/AbstractGeneratorCommand.php b/system/CLI/AbstractGeneratorCommand.php new file mode 100644 index 000000000000..8c91ad7e7614 --- /dev/null +++ b/system/CLI/AbstractGeneratorCommand.php @@ -0,0 +1,414 @@ + + * + * 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\GeneratorCommand; +use CodeIgniter\CLI\Input\Argument; +use CodeIgniter\CLI\Input\Option; +use CodeIgniter\Exceptions\LogicException; +use Config\Generators; +use Throwable; + +/** + * Base class for modern spark commands that generate files from templates. + */ +abstract class AbstractGeneratorCommand extends AbstractCommand implements PromptsForMissingInputInterface +{ + private const CLASS_NAME_PATTERN = '/^[a-zA-Z_][a-zA-Z0-9_]*(?:\\\\[a-zA-Z_][a-zA-Z0-9_]*)*$/'; + + /** + * @var non-empty-string + */ + protected readonly string $component; + + /** + * @var non-empty-string|null + */ + protected readonly ?string $directory; + + /** + * @var non-empty-string|null + */ + protected readonly ?string $namespace; + + protected readonly bool $sortImports; + protected readonly string $classNameLang; + + /** + * @var non-empty-string + */ + protected string $template; + + protected ?string $templatePath = null; + + /** + * @throws LogicException + */ + public function __construct(Commands $commands) + { + $attribute = $this->resolveClassAttribute(GeneratorCommand::class); + + $this->component = $attribute->component; + $this->directory = $attribute->directory; + $this->namespace = $attribute->namespace; + $this->sortImports = $attribute->sortImports; + $this->template = $attribute->template; + $this->classNameLang = $attribute->classNameLang; + + parent::__construct($commands); + } + + protected function configure(): void + { + $this->addArgument(new Argument( + name: 'name', + description: 'The name of the class to generate.', + required: true, + )); + } + + protected function provideDefaultOptions(): void + { + parent::provideDefaultOptions(); + + $this->provideGeneratorOptions(); + } + + /** + * Registers the common generator options. + */ + protected function provideGeneratorOptions(): void + { + $this->addNamespaceOption()->addSuffixOption()->addForceOption(); + } + + final protected function addNamespaceOption(): static + { + return $this->addOption(new Option( + name: 'namespace', + shortcut: 'n', + description: 'Set the root namespace.', + requiresValue: true, + default: APP_NAMESPACE, + )); + } + + final protected function addSuffixOption(): static + { + return $this->addOption(new Option( + name: 'suffix', + shortcut: 's', + description: sprintf('Append the "%s" suffix to the class name.', $this->component), + )); + } + + final protected function addForceOption(): static + { + return $this->addOption(new Option( + name: 'force', + shortcut: 'f', + description: 'Force overwrite existing file.', + )); + } + + protected function getArgumentPromptLabels(): array + { + return ['name' => lang($this->classNameLang)]; + } + + protected function execute(array $arguments, array $options): int + { + return $this->generateClass(); + } + + /** + * Generates a class file from the configured template. + */ + protected function generateClass(): int + { + return $this->generate($this->qualifyClassName()); + } + + /** + * Generates a view file from the configured template. + * + * @param string $view Namespaced view name that is generated. + */ + protected function generateView(string $view): int + { + return $this->generate($view); + } + + /** + * Additional template placeholder replacements, which take precedence over the core `{namespace}` / `{class}` pairs. + * + * @param string $class Namespaced classname or namespaced view. + * + * @return array + */ + protected function getReplacements(string $class): array + { + return []; + } + + /** + * View data passed to the generator view when rendering. + * + * @param string $class Namespaced classname or namespaced view. + * + * @return array + */ + protected function getTemplateData(string $class): array + { + return []; + } + + /** + * Changes the file basename before saving. + */ + protected function basename(string $filename): string + { + return basename($filename); + } + + /** + * Parses the class name and checks if it is already qualified. + */ + protected function qualifyClassName(): string + { + $class = $this->normalizeInputClassName(); + + $namespace = $this->getNamespace() . '\\'; + + if (str_starts_with($class, $namespace)) { + return $class; + } + + $directory = ($this->directory !== null) ? $this->directory . '\\' : ''; + + return $namespace . $directory . str_replace('/', '\\', $class); + } + + /** + * Whether the component suffix should be appended to the class name. + */ + protected function shouldAppendSuffix(): bool + { + return $this->hasOption('suffix') && $this->getValidatedOption('suffix') === true; + } + + /** + * Renders the generator view from `$templatePath`, `Config\Generators::$views`, or the `$template` fallback. + * + * @param array $data + */ + protected function renderTemplate(array $data = []): string + { + $fallback = sprintf('CodeIgniter\\Commands\\Generators\\Views\\%s', $this->template); + $view = $this->templatePath ?? config(Generators::class)->views[$this->getName()] ?? null; + + if (! is_string($view)) { + return view($fallback, $data, ['debug' => false]); + } + + try { + return view($view, $data, ['debug' => false]); + } catch (Throwable $e) { + log_message('error', (string) $e); + + return view($fallback, $data, ['debug' => false]); + } + } + + /** + * Builds the generated file contents, alphabetically sorting the imports when configured. + */ + protected function buildContent(string $class): string + { + $template = $this->parseTemplate($class); + + if ( + $this->sortImports + && preg_match('/(?P(?:^use [^;]+;$\n?)+)/m', $template, $match) === 1 + ) { + $imports = explode("\n", trim($match['imports'])); + sort($imports); + + return str_replace(trim($match['imports']), implode("\n", $imports), $template); + } + + return $template; + } + + /** + * Builds the file path from the class name. + * + * @param string $class Namespaced classname or namespaced view. + */ + protected function buildPath(string $class): string + { + $namespace = $this->getNamespace(); + + $bases = service('autoloader')->getNamespace($namespace); + $base = reset($bases); + + if ($base === false || $base === '') { + CLI::error(lang('CLI.namespaceNotDefined', [$namespace])); + + return ''; + } + + $realpath = realpath($base); + $base = ($realpath !== false) ? $realpath : $base; + + $prefix = $namespace . '\\'; + $relative = str_starts_with($class, $prefix) ? substr($class, strlen($prefix)) : $class; + + $file = $base . DIRECTORY_SEPARATOR + . str_replace('\\', DIRECTORY_SEPARATOR, trim($relative, '\\')) . '.php'; + + return dirname($file) . DIRECTORY_SEPARATOR . $this->basename($file); + } + + /** + * Gets the root namespace from the attribute override or the `--namespace` option. + */ + protected function getNamespace(): string + { + if ($this->namespace !== null) { + return $this->namespace; + } + + $namespace = $this->hasOption('namespace') ? $this->getValidatedOption('namespace') : APP_NAMESPACE; + assert(is_string($namespace)); + + return trim(str_replace('/', '\\', $namespace), '\\'); + } + + /** + * Builds the target path for the given class and writes the generated content to it. + */ + private function generate(string $class): int + { + if (preg_match(self::CLASS_NAME_PATTERN, $class) !== 1) { + CLI::error(lang('CLI.generator.invalidClassName', [$class])); + + return EXIT_ERROR; + } + + $target = $this->buildPath($class); + + if ($target === '') { + return EXIT_ERROR; + } + + return $this->generateFile($target, $this->buildContent($class)); + } + + /** + * Writes the generated file to disk with all the safety checks around that. + */ + private function generateFile(string $target, string $content): int + { + if ($this->getNamespace() === 'CodeIgniter') { + CLI::write(lang('CLI.generator.usingCINamespace'), 'yellow'); + + if ( + $this->isInteractive() + && CLI::prompt(lang('CLI.generator.confirmContinue'), ['y', 'n'], 'required') === 'n' + ) { + CLI::write(lang('CLI.generator.cancelOperation'), 'yellow'); + + return EXIT_SUCCESS; + } + } + + $isFile = is_file($target); + $force = $this->hasOption('force') && $this->getValidatedOption('force') === true; + + if (! $force && $isFile) { + CLI::error(lang('CLI.generator.fileExist', [clean_path($target)])); + + return EXIT_ERROR; + } + + $dir = dirname($target); + + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + helper('filesystem'); + + if (! write_file($target, $content)) { + // @codeCoverageIgnoreStart + CLI::error(lang('CLI.generator.fileError', [clean_path($target)])); + + return EXIT_ERROR; + // @codeCoverageIgnoreEnd + } + + if ($isFile) { + CLI::write(lang('CLI.generator.fileOverwrite', [clean_path($target)]), 'yellow'); + } else { + CLI::write(lang('CLI.generator.fileCreate', [clean_path($target)]), 'green'); + } + + return EXIT_SUCCESS; + } + + /** + * Performs the placeholder replacements on the rendered template. + * + * @param string $class Namespaced classname or namespaced view. + */ + private function parseTemplate(string $class): string + { + $segments = explode('\\', $class); + $className = array_pop($segments); + + $replacements = $this->getReplacements($class) + [ + '<@php' => ' trim(implode('\\', $segments), '\\'), + '{class}' => $className, + ]; + + return strtr($this->renderTemplate($this->getTemplateData($class)), $replacements); + } + + private function normalizeInputClassName(): string + { + $class = $this->getValidatedArgument('name'); + assert(is_string($class)); + + helper('inflector'); + + $component = singular($this->component); + + $pattern = sprintf('/((?:[a-z][a-z0-9_\/\\\\]*)?)(%s)$/i', preg_quote($component, '/')); + + if (preg_match($pattern, $class, $matches) === 1) { + $class = $matches[1] . ucfirst($component); + } elseif ($this->shouldAppendSuffix()) { + $class .= ucfirst($component); + } + + $segments = array_filter( + explode('\\', str_replace('/', '\\', trim($class))), + static fn (string $segment): bool => $segment !== '', + ); + + return implode('\\', array_map(pascalize(...), $segments)); + } +} diff --git a/system/CLI/Attributes/GeneratorCommand.php b/system/CLI/Attributes/GeneratorCommand.php new file mode 100644 index 000000000000..458f179e5ee2 --- /dev/null +++ b/system/CLI/Attributes/GeneratorCommand.php @@ -0,0 +1,97 @@ + + * + * 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 holding the code generation configuration of a generator command. + */ +#[Attribute(Attribute::TARGET_CLASS)] +final readonly class GeneratorCommand +{ + private const NAMESPACE_PATTERN = '/^[a-zA-Z_][a-zA-Z0-9_]*(?:\\\\[a-zA-Z_][a-zA-Z0-9_]*)*$/'; + + /** + * @var non-empty-string + */ + public string $component; + + /** + * @var non-empty-string + */ + public string $template; + + /** + * @var non-empty-string|null + */ + public ?string $directory; + + /** + * @var non-empty-string|null + */ + public ?string $namespace; + + /** + * @param string $component The component name appended as suffix to generated class names. + * @param string $template Basename of the fallback view under `CodeIgniter\Commands\Generators\Views`. + * @param string|null $directory Sub-namespace under the root namespace where classes are generated. + * @param string $classNameLang Lang key for the class name prompt. + * @param string|null $namespace Root namespace override, ignoring the `--namespace` option. + * + * @throws LogicException + */ + public function __construct( + string $component, + string $template, + ?string $directory = null, + public string $classNameLang = 'CLI.generator.className.default', + ?string $namespace = null, + public bool $sortImports = true, + ) { + if ($component === '') { + throw new LogicException(lang('Commands.generatorEmptyComponent')); + } + + if (preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $component) !== 1) { + throw new LogicException(lang('Commands.generatorInvalidComponent', [$component])); + } + + if ($template === '') { + throw new LogicException(lang('Commands.generatorEmptyTemplate')); + } + + if ($directory === '') { + throw new LogicException(lang('Commands.generatorEmptyDirectory')); + } + + if ($directory !== null && preg_match(self::NAMESPACE_PATTERN, $directory) !== 1) { + throw new LogicException(lang('Commands.generatorInvalidDirectory', [$directory])); + } + + if ($namespace === '') { + throw new LogicException(lang('Commands.generatorEmptyNamespace')); + } + + if ($namespace !== null && preg_match(self::NAMESPACE_PATTERN, $namespace) !== 1) { + throw new LogicException(lang('Commands.generatorInvalidNamespace', [$namespace])); + } + + $this->component = $component; + $this->template = $template; + $this->directory = $directory; + $this->namespace = $namespace; + } +} diff --git a/system/CLI/Commands.php b/system/CLI/Commands.php index 776e7fb7cc80..960caeaf4c87 100644 --- a/system/CLI/Commands.php +++ b/system/CLI/Commands.php @@ -15,6 +15,7 @@ use CodeIgniter\Autoloader\FileLocatorInterface; use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\Attributes\GeneratorCommand; use CodeIgniter\CLI\Exceptions\CommandNotFoundException; use CodeIgniter\Events\Events; use CodeIgniter\Exceptions\LogicException; @@ -429,6 +430,20 @@ private function registerModernCommand(ReflectionClass $class, string $file): vo return; } + // Vetted at discovery so the registry never advertises a command whose constructor throws. + if ($class->isSubclassOf(AbstractGeneratorCommand::class)) { + try { + $generatorAttribute = $class->getAttributes(GeneratorCommand::class)[0] + ?? throw new LogicException(lang('Commands.missingCommandAttribute', [$class->getName(), GeneratorCommand::class])); + + $generatorAttribute->newInstance(); + } catch (LogicException $e) { + $this->logger->error($e->getMessage()); + + return; + } + } + if ($attribute->group === '' || isset($this->modernCommands[$attribute->name])) { return; } diff --git a/system/CLI/GeneratorTrait.php b/system/CLI/GeneratorTrait.php index 96f5877c6407..fe878bcd74fa 100644 --- a/system/CLI/GeneratorTrait.php +++ b/system/CLI/GeneratorTrait.php @@ -157,7 +157,7 @@ private function generateFile(string $target, string $content): void if ( CLI::prompt( - 'Are you sure you want to continue?', + lang('CLI.generator.confirmContinue'), ['y', 'n'], 'required', ) === 'n' diff --git a/system/CLI/PromptsForMissingInputInterface.php b/system/CLI/PromptsForMissingInputInterface.php new file mode 100644 index 000000000000..4bcec0289076 --- /dev/null +++ b/system/CLI/PromptsForMissingInputInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +/** + * Marker interface for commands that prompt for missing required arguments when run interactively. + */ +interface PromptsForMissingInputInterface +{ +} diff --git a/system/Language/en/CLI.php b/system/Language/en/CLI.php index def36f4331f7..3d44dec060da 100644 --- a/system/Language/en/CLI.php +++ b/system/Language/en/CLI.php @@ -15,6 +15,7 @@ return [ 'altCommandPlural' => 'Did you mean one of these?', 'altCommandSingular' => 'Did you mean this?', + 'argumentPrompt' => 'Please provide a value for the "{0}" argument', 'commandAlias' => '[alias of {0}]', 'commandNotFound' => 'Command "{0}" not found.', 'generator' => [ @@ -36,11 +37,13 @@ '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', diff --git a/system/Language/en/Commands.php b/system/Language/en/Commands.php index 142138338d16..6bbc6e67bc8a 100644 --- a/system/Language/en/Commands.php +++ b/system/Language/en/Commands.php @@ -32,6 +32,13 @@ 'emptyOptionName' => 'Option name cannot be empty.', 'emptyShortcutName' => 'Shortcut name cannot be empty.', 'flagOptionPassedMultipleTimes' => 'Option "--{0}" is passed multiple times.', + 'generatorEmptyComponent' => 'Generator component cannot be empty.', + 'generatorEmptyDirectory' => 'Generator directory cannot be empty.', + 'generatorEmptyNamespace' => 'Generator namespace cannot be empty.', + 'generatorEmptyTemplate' => 'Generator template cannot be empty.', + 'generatorInvalidComponent' => 'Generator component "{0}" is not valid.', + 'generatorInvalidDirectory' => 'Generator directory "{0}" is not valid.', + 'generatorInvalidNamespace' => 'Generator namespace "{0}" is not valid.', 'invalidCommandAlias' => 'Command alias "{0}" is not valid.', 'invalidCommandName' => 'Command name "{0}" is not valid.', 'invalidArgumentName' => 'Argument name "{0}" is not valid.', diff --git a/tests/_support/Commands/Modern/GeneratorFixtureCommand.php b/tests/_support/Commands/Modern/GeneratorFixtureCommand.php new file mode 100644 index 000000000000..a653e888c5c2 --- /dev/null +++ b/tests/_support/Commands/Modern/GeneratorFixtureCommand.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\Commands\Modern; + +use CodeIgniter\CLI\AbstractGeneratorCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\Attributes\GeneratorCommand; + +#[Command(name: 'make:testwidget', description: 'Fixture generator command.', group: 'Fixtures')] +#[GeneratorCommand(component: 'Widget', template: 'config.tpl.php', directory: 'Widgets')] +final class GeneratorFixtureCommand extends AbstractGeneratorCommand +{ +} diff --git a/tests/_support/Commands/Modern/ImportSortingGeneratorCommand.php b/tests/_support/Commands/Modern/ImportSortingGeneratorCommand.php new file mode 100644 index 000000000000..2c6bf2b0b0ab --- /dev/null +++ b/tests/_support/Commands/Modern/ImportSortingGeneratorCommand.php @@ -0,0 +1,38 @@ + + * + * 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\AbstractGeneratorCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\Attributes\GeneratorCommand; + +#[Command(name: 'make:sortedwidget', description: 'Fixture generator command with unsorted imports in its content.', group: 'Fixtures')] +#[GeneratorCommand(component: 'Widget', template: 'config.tpl.php', directory: 'Widgets')] +class ImportSortingGeneratorCommand extends AbstractGeneratorCommand +{ + protected function renderTemplate(array $data = []): string + { + return <<<'PHP' + + * + * 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\Attributes\Command; +use CodeIgniter\CLI\Attributes\GeneratorCommand; + +#[Command(name: 'make:unsortedwidget', description: 'Fixture generator command that keeps its imports unsorted.', group: 'Fixtures')] +#[GeneratorCommand(component: 'Widget', template: 'config.tpl.php', directory: 'Widgets', namespace: 'App', sortImports: false)] +final class NoImportSortingGeneratorCommand extends ImportSortingGeneratorCommand +{ +} diff --git a/tests/_support/Commands/Modern/PromptingFixtureCommand.php b/tests/_support/Commands/Modern/PromptingFixtureCommand.php new file mode 100644 index 000000000000..d7eb2922310b --- /dev/null +++ b/tests/_support/Commands/Modern/PromptingFixtureCommand.php @@ -0,0 +1,60 @@ + + * + * 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\PromptsForMissingInputInterface; + +#[Command(name: 'test:prompting', description: 'Fixture that prompts for its missing required arguments.', group: 'Fixtures')] +final class PromptingFixtureCommand extends AbstractCommand implements PromptsForMissingInputInterface +{ + public static bool $afterHookCalled = false; + + /** + * @var array|string> + */ + public static array $receivedArguments = []; + + public static function reset(): void + { + self::$afterHookCalled = false; + self::$receivedArguments = []; + } + + protected function configure(): void + { + $this + ->addArgument(new Argument(name: 'first', required: true)) + ->addArgument(new Argument(name: 'second', required: true)); + } + + protected function getArgumentPromptLabels(): array + { + return ['second' => 'What is the second value?'] + parent::getArgumentPromptLabels(); + } + + protected function afterPrompting(array &$arguments, array &$options): void + { + self::$afterHookCalled = true; + } + + protected function execute(array $arguments, array $options): int + { + self::$receivedArguments = $arguments; + + return EXIT_SUCCESS; + } +} diff --git a/tests/_support/Commands/Modern/TrimmedOptionsGeneratorCommand.php b/tests/_support/Commands/Modern/TrimmedOptionsGeneratorCommand.php new file mode 100644 index 000000000000..dac9bcaf3e54 --- /dev/null +++ b/tests/_support/Commands/Modern/TrimmedOptionsGeneratorCommand.php @@ -0,0 +1,38 @@ + + * + * 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\AbstractGeneratorCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\Attributes\GeneratorCommand; + +#[Command(name: 'make:trimmedwidget', description: 'Fixture generator command with trimmed options and forced suffixing.', group: 'Fixtures')] +#[GeneratorCommand(component: 'Widget', template: 'config.tpl.php', directory: 'Widgets', classNameLang: 'CLI.generator.className.config')] +final class TrimmedOptionsGeneratorCommand extends AbstractGeneratorCommand +{ + protected function provideGeneratorOptions(): void + { + $this->addNamespaceOption(); + } + + protected function shouldAppendSuffix(): bool + { + return true; + } + + protected function getReplacements(string $class): array + { + return ['{namespace}' => 'App\Widgets\Custom']; + } +} diff --git a/tests/_support/InvalidCommands/InvalidComponentGeneratorCommand.php b/tests/_support/InvalidCommands/InvalidComponentGeneratorCommand.php new file mode 100644 index 000000000000..a2b435480953 --- /dev/null +++ b/tests/_support/InvalidCommands/InvalidComponentGeneratorCommand.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\AbstractGeneratorCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\Attributes\GeneratorCommand; + +#[Command(name: 'make:invalidcomponent', description: 'Fixture generator command with an invalid component.', group: 'Fixtures')] +#[GeneratorCommand(component: '(', template: 'config.tpl.php')] +final class InvalidComponentGeneratorCommand extends AbstractGeneratorCommand +{ +} diff --git a/tests/_support/InvalidCommands/NoAttributeGeneratorCommand.php b/tests/_support/InvalidCommands/NoAttributeGeneratorCommand.php new file mode 100644 index 000000000000..6fd67ca80352 --- /dev/null +++ b/tests/_support/InvalidCommands/NoAttributeGeneratorCommand.php @@ -0,0 +1,22 @@ + + * + * 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\AbstractGeneratorCommand; +use CodeIgniter\CLI\Attributes\Command; + +#[Command(name: 'make:noattribute', description: 'Fixture generator command missing the GeneratorCommand attribute.', group: 'Fixtures')] +final class NoAttributeGeneratorCommand extends AbstractGeneratorCommand +{ +} diff --git a/tests/system/CLI/AbstractCommandTest.php b/tests/system/CLI/AbstractCommandTest.php index bc7b673d19e0..43697dd4fb22 100644 --- a/tests/system/CLI/AbstractCommandTest.php +++ b/tests/system/CLI/AbstractCommandTest.php @@ -26,6 +26,7 @@ use CodeIgniter\Commands\Help; use CodeIgniter\Exceptions\LogicException; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockInputOutput; use CodeIgniter\Test\StreamFilterTrait; use Config\App; use Config\Services; @@ -40,6 +41,7 @@ use Tests\Support\Commands\Modern\InteractFixtureCommand; use Tests\Support\Commands\Modern\InteractiveStateProbeCommand; use Tests\Support\Commands\Modern\ParentCallsInteractFixtureCommand; +use Tests\Support\Commands\Modern\PromptingFixtureCommand; use Tests\Support\Commands\Modern\TestFixtureCommand; use Tests\Support\Commands\Modern\UnavailableFixtureCommand; use Throwable; @@ -62,6 +64,7 @@ protected function resetAll(): void CLI::reset(); InteractiveStateProbeCommand::reset(); + PromptingFixtureCommand::reset(); UnavailableFixtureCommand::reset(); } @@ -1166,4 +1169,82 @@ public function testGetValidatedOptionThrowsForUnknownName(): void $command->callGetValidatedOption('missing'); } + + public function testPromptsForMissingRequiredArgumentsWhenInteractive(): void + { + $io = new MockInputOutput(); + $io->setInputs(['alpha', 'beta']); + CLI::setInputOutput($io); + + $command = new PromptingFixtureCommand(new Commands()); + $command->setInteractive(true); + + $exitCode = $command->run([], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertSame(['first' => 'alpha', 'second' => 'beta'], PromptingFixtureCommand::$receivedArguments); + $this->assertTrue(PromptingFixtureCommand::$afterHookCalled); + + $output = $io->getOutput(); + $this->assertStringContainsString('Please provide a value for the "first" argument', $output); + $this->assertStringContainsString('What is the second value?', $output); + } + + public function testPromptsOnlyForMissingTailArguments(): void + { + $io = new MockInputOutput(); + $io->setInputs(['beta']); + CLI::setInputOutput($io); + + $command = new PromptingFixtureCommand(new Commands()); + $command->setInteractive(true); + + $command->run(['alpha'], []); + + $this->assertSame(['first' => 'alpha', 'second' => 'beta'], PromptingFixtureCommand::$receivedArguments); + $this->assertStringNotContainsString('"first" argument', $io->getOutput()); + } + + public function testAfterHookIsSkippedWhenNothingIsPrompted(): void + { + $command = new PromptingFixtureCommand(new Commands()); + $command->setInteractive(true); + + $command->run(['alpha', 'beta'], []); + + $this->assertFalse(PromptingFixtureCommand::$afterHookCalled); + } + + public function testMissingRequiredArgumentsStillThrowWhenNotInteractive(): void + { + $command = new PromptingFixtureCommand(new Commands()); + $command->setInteractive(false); + + $this->expectException(ArgumentCountMismatchException::class); + + $command->run([], []); + } + + public function testPromptingIsSkippedWithNullInputOutput(): void + { + CLI::setInputOutput(new NullInputOutput()); + + $command = new PromptingFixtureCommand(new Commands()); + $command->setInteractive(true); + + $this->expectException(ArgumentCountMismatchException::class); + + $command->run([], []); + } + + public function testNonImplementingCommandIsNotPrompted(): void + { + $command = new TestFixtureCommand(new Commands()); + $command->addArgument(new Argument(name: 'first', required: true)); + $command->setInteractive(true); + + $this->expectException(ArgumentCountMismatchException::class); + + $command->run([], []); + } } diff --git a/tests/system/CLI/AbstractGeneratorCommandTest.php b/tests/system/CLI/AbstractGeneratorCommandTest.php new file mode 100644 index 000000000000..80d2809d961a --- /dev/null +++ b/tests/system/CLI/AbstractGeneratorCommandTest.php @@ -0,0 +1,453 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\Exceptions\LogicException; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockInputOutput; +use CodeIgniter\Test\StreamFilterTrait; +use Config\Generators; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Commands\Modern\GeneratorFixtureCommand; +use Tests\Support\Commands\Modern\ImportSortingGeneratorCommand; +use Tests\Support\Commands\Modern\NoImportSortingGeneratorCommand; +use Tests\Support\Commands\Modern\TrimmedOptionsGeneratorCommand; +use Tests\Support\InvalidCommands\NoAttributeGeneratorCommand; + +/** + * @internal + */ +#[CoversClass(AbstractGeneratorCommand::class)] +#[Group('Others')] +final class AbstractGeneratorCommandTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + #[After] + #[Before] + protected function resetAll(): void + { + $this->resetServices(); + + CLI::reset(); + + $dir = APPPATH . 'Widgets'; + + if (is_dir($dir)) { + helper('filesystem'); + delete_files($dir, true, false, true); + rmdir($dir); + } + } + + public function testConstructorSeedsAttributeState(): void + { + $command = new GeneratorFixtureCommand(new Commands()); + + $this->assertSame('Widget', $this->getPrivateProperty($command, 'component')); + $this->assertSame('Widgets', $this->getPrivateProperty($command, 'directory')); + $this->assertSame('config.tpl.php', $this->getPrivateProperty($command, 'template')); + $this->assertSame('CLI.generator.className.default', $this->getPrivateProperty($command, 'classNameLang')); + $this->assertNull($this->getPrivateProperty($command, 'namespace')); + $this->assertNull($this->getPrivateProperty($command, 'templatePath')); + $this->assertTrue($this->getPrivateProperty($command, 'sortImports')); + } + + public function testCommandRequiresGeneratorCommandAttribute(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageMatches('/^Command class ".*" is missing the CodeIgniter\\\\CLI\\\\Attributes\\\\GeneratorCommand attribute\.$/'); + + new NoAttributeGeneratorCommand(new Commands()); + } + + public function testCommandDeclaresGeneratorDefinition(): void + { + $command = new GeneratorFixtureCommand(new Commands()); + + $arguments = $command->getArgumentsDefinition(); + + $this->assertSame(['name'], array_keys($arguments)); + $this->assertTrue($arguments['name']->required); + $this->assertSame( + ['help', 'no-header', 'no-interaction', 'namespace', 'suffix', 'force'], + array_keys($command->getOptionsDefinition()), + ); + $this->assertSame( + ['h' => 'help', 'N' => 'no-interaction', 'n' => 'namespace', 's' => 'suffix', 'f' => 'force'], + $command->getShortcuts(), + ); + $this->assertSame('make:testwidget [options] [--] ', $command->getUsages()[0]); + } + + public function testTrimmedCommandDeclaresReducedDefinition(): void + { + $command = new TrimmedOptionsGeneratorCommand(new Commands()); + + $this->assertSame( + ['help', 'no-header', 'no-interaction', 'namespace'], + array_keys($command->getOptionsDefinition()), + ); + } + + public function testGenerateClassCreatesFile(): void + { + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(false); + + $exitCode = $command->run(['foo'], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); + + $target = APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'Foo.php'; + $this->assertFileExists($target); + + $content = file_get_contents($target); + $this->assertIsString($content); + $this->assertStringContainsString('namespace App\Widgets;', $content); + $this->assertStringContainsString('class Foo extends BaseConfig', $content); + } + + public function testGenerateViewCreatesFile(): void + { + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(false); + + $this->assertSame(EXIT_SUCCESS, $command->run(['foo'], [])); + $this->assertSame(EXIT_SUCCESS, $this->getPrivateMethodInvoker($command, 'generateView')('App\Widgets\FooView')); + + $content = file_get_contents(APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'FooView.php'); + $this->assertIsString($content); + $this->assertStringContainsString('class FooView extends BaseConfig', $content); + } + + public function testAlreadyQualifiedInputIsPreserved(): void + { + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(false); + + $exitCode = $command->run(['App\Widgets\Foo'], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertFileExists(APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'Foo.php'); + } + + public function testInteriorRootNamespaceSegmentIsPreserved(): void + { + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(false); + + $exitCode = $command->run(['Sub/App/Foo'], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + + $target = APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'Sub' . DIRECTORY_SEPARATOR . 'App' . DIRECTORY_SEPARATOR . 'Foo.php'; + $this->assertFileExists($target); + $content = file_get_contents($target); + $this->assertIsString($content); + $this->assertStringContainsString('namespace App\Widgets\Sub\App;', $content); + } + + public function testTrailingSeparatorInNameIsIgnored(): void + { + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(false); + + $exitCode = $command->run(['foo/'], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $content = file_get_contents(APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'Foo.php'); + $this->assertIsString($content); + $this->assertStringContainsString('class Foo extends BaseConfig', $content); + } + + public function testUppercaseComponentSuffixIsCaseNormalized(): void + { + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(false); + + $command->run(['fooWIDGET'], []); + + $this->assertFileExists(APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'FooWidget.php'); + } + + public function testAttributeNamespaceOverridesNamespaceOption(): void + { + $command = new NoImportSortingGeneratorCommand(new Commands()); + $command->setInteractive(false); + + $exitCode = $command->run(['foo'], ['namespace' => 'Tests\Support']); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertFileExists(APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'Foo.php'); + } + + public function testGenerateClassRejectsExistingFileWithoutForce(): void + { + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(false); + + $this->assertSame(EXIT_SUCCESS, $command->run(['foo'], [])); + + $exitCode = $command->run(['foo'], []); + + $this->assertSame(EXIT_ERROR, $exitCode); + $this->assertStringContainsString('File exists: ', $this->getStreamFilterBuffer()); + } + + public function testGenerateClassOverwritesWithForce(): void + { + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(false); + + $this->assertSame(EXIT_SUCCESS, $command->run(['foo'], [])); + + $exitCode = $command->run(['foo'], ['force' => null]); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertStringContainsString('File overwritten: ', $this->getStreamFilterBuffer()); + } + + public function testSuffixOptionAppendsComponent(): void + { + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(false); + + $command->run(['foo'], ['suffix' => null]); + + $this->assertFileExists(APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'FooWidget.php'); + } + + public function testExistingComponentSuffixIsCaseNormalized(): void + { + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(false); + + $command->run(['Foowidget'], []); + + $this->assertFileExists(APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'FooWidget.php'); + } + + public function testForcedSuffixingWithoutSuffixOption(): void + { + $command = new TrimmedOptionsGeneratorCommand(new Commands()); + $command->setInteractive(false); + + $exitCode = $command->run(['foo'], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertFileExists(APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'FooWidget.php'); + } + + public function testCustomReplacementsTakePrecedenceOverCorePairs(): void + { + $command = new TrimmedOptionsGeneratorCommand(new Commands()); + $command->setInteractive(false); + + $command->run(['foo'], []); + + $content = file_get_contents(APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'FooWidget.php'); + $this->assertIsString($content); + $this->assertStringContainsString('namespace App\Widgets\Custom;', $content); + } + + public function testDotSegmentsInNameAreRejected(): void + { + $routes = APPPATH . 'Config' . DIRECTORY_SEPARATOR . 'Routes.php'; + $before = file_get_contents($routes); + + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(false); + + $exitCode = $command->run(['../Config/Routes'], ['force' => null]); + + $this->assertSame(EXIT_ERROR, $exitCode); + $this->assertStringContainsString('Class name "App\\Widgets\\..\\Config\\Routes" is not valid.', $this->getStreamFilterBuffer()); + $this->assertSame($before, file_get_contents($routes)); + } + + public function testInvalidClassNameSegmentIsRejected(): void + { + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(false); + + $exitCode = $command->run(['foo-bar'], []); + + $this->assertSame(EXIT_ERROR, $exitCode); + $this->assertStringContainsString('Class name "App\\Widgets\\Foo-bar" is not valid.', $this->getStreamFilterBuffer()); + $this->assertDirectoryDoesNotExist(APPPATH . 'Widgets'); + } + + public function testAlreadySuffixedShortNamesAreNotDoubled(): void + { + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(false); + + $command->run(['XWidget'], ['suffix' => null]); + $command->run(['Widget'], ['suffix' => null]); + + $this->assertFileExists(APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'XWidget.php'); + $this->assertFileExists(APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'Widget.php'); + } + + public function testUndefinedNamespaceFails(): void + { + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(false); + + $exitCode = $command->run(['foo'], ['namespace' => 'CodeIgnite']); + + $this->assertSame(EXIT_ERROR, $exitCode); + $this->assertStringContainsString('Namespace "CodeIgnite" is not defined.', $this->getStreamFilterBuffer()); + } + + public function testInteractiveRunPromptsForClassName(): void + { + $io = new MockInputOutput(); + $io->setInputs(['foo']); + CLI::setInputOutput($io); + + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(true); + + $exitCode = $command->run([], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertStringContainsString('Class name', $io->getOutput()); + $this->assertFileExists(APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'Foo.php'); + } + + public function testInteractivePromptUsesClassNameLang(): void + { + $io = new MockInputOutput(); + $io->setInputs(['foo']); + CLI::setInputOutput($io); + + $command = new TrimmedOptionsGeneratorCommand(new Commands()); + $command->setInteractive(true); + + $exitCode = $command->run([], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertStringContainsString('Config class name', $io->getOutput()); + $this->assertFileExists(APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'FooWidget.php'); + } + + public function testDecliningCodeIgniterNamespaceCancelsOperation(): void + { + $io = new MockInputOutput(); + $io->setInputs(['n']); + CLI::setInputOutput($io); + + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(true); + + $exitCode = $command->run(['foo'], ['namespace' => 'CodeIgniter']); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $this->assertStringContainsString('Operation has been cancelled.', $io->getOutput()); + $this->assertFileDoesNotExist(SYSTEMPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'Foo.php'); + } + + public function testAcceptingCodeIgniterNamespacePromptProceeds(): void + { + $io = new MockInputOutput(); + $io->setInputs(['y']); + CLI::setInputOutput($io); + + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(true); + + $exitCode = $command->run([CLI::class], ['namespace' => 'CodeIgniter']); + + $this->assertSame(EXIT_ERROR, $exitCode); + $this->assertStringContainsString('File exists: ', $io->getOutput()); + } + + public function testImportsAreSortedInGeneratedContent(): void + { + $command = new ImportSortingGeneratorCommand(new Commands()); + $command->setInteractive(false); + + $command->run(['foo'], []); + + $content = file_get_contents(APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'Foo.php'); + $this->assertIsString($content); + $this->assertStringContainsString("use App\\Alpha;\nuse App\\Zebra;", $content); + } + + public function testImportSortingCanBeDisabled(): void + { + $command = new NoImportSortingGeneratorCommand(new Commands()); + $command->setInteractive(false); + + $command->run(['foo'], []); + + $content = file_get_contents(APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'Foo.php'); + $this->assertIsString($content); + $this->assertStringContainsString("use App\\Zebra;\nuse App\\Alpha;", $content); + } + + public function testRenderTemplateFallsBackWhenConfigEntryIsNotString(): void + { + config(Generators::class)->views['make:testwidget'] = [ + 'class' => 'CodeIgniter\Commands\Generators\Views\config.tpl.php', + 'view' => 'CodeIgniter\Commands\Generators\Views\cell_view.tpl.php', + ]; + + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(false); + + $exitCode = $command->run(['foo'], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $content = file_get_contents(APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'Foo.php'); + $this->assertIsString($content); + $this->assertStringContainsString('class Foo extends BaseConfig', $content); + } + + public function testRenderTemplateFallsBackWhenConfiguredViewIsBroken(): void + { + config(Generators::class)->views['make:testwidget'] = 'App\Missing\widget.tpl.php'; + + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(false); + + $exitCode = $command->run(['foo'], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $content = file_get_contents(APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'Foo.php'); + $this->assertIsString($content); + $this->assertStringContainsString('class Foo extends BaseConfig', $content); + } + + public function testRenderTemplateUsesConfiguredView(): void + { + config(Generators::class)->views['make:testwidget'] = 'CodeIgniter\Commands\Generators\Views\seeder.tpl.php'; + + $command = new GeneratorFixtureCommand(new Commands()); + $command->setInteractive(false); + + $exitCode = $command->run(['foo'], []); + + $this->assertSame(EXIT_SUCCESS, $exitCode); + $content = file_get_contents(APPPATH . 'Widgets' . DIRECTORY_SEPARATOR . 'Foo.php'); + $this->assertIsString($content); + $this->assertStringContainsString('class Foo extends Seeder', $content); + } +} diff --git a/tests/system/CLI/Attributes/GeneratorCommandTest.php b/tests/system/CLI/Attributes/GeneratorCommandTest.php new file mode 100644 index 000000000000..1d67330e6338 --- /dev/null +++ b/tests/system/CLI/Attributes/GeneratorCommandTest.php @@ -0,0 +1,115 @@ + + * + * 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(GeneratorCommand::class)] +#[Group('Others')] +final class GeneratorCommandTest extends CIUnitTestCase +{ + public function testAttributeExposesProperties(): void + { + $generatorCommand = new GeneratorCommand( + component: 'Model', + template: 'model.tpl.php', + directory: 'Models', + classNameLang: 'CLI.generator.className.model', + namespace: 'App', + sortImports: false, + ); + + $this->assertSame('Model', $generatorCommand->component); + $this->assertSame('model.tpl.php', $generatorCommand->template); + $this->assertSame('Models', $generatorCommand->directory); + $this->assertSame('CLI.generator.className.model', $generatorCommand->classNameLang); + $this->assertSame('App', $generatorCommand->namespace); + $this->assertFalse($generatorCommand->sortImports); + } + + public function testAttributeProvidesDefaults(): void + { + $generatorCommand = new GeneratorCommand(component: 'Model', template: 'model.tpl.php'); + + $this->assertNull($generatorCommand->directory); + $this->assertSame('CLI.generator.className.default', $generatorCommand->classNameLang); + $this->assertNull($generatorCommand->namespace); + $this->assertTrue($generatorCommand->sortImports); + } + + /** + * @param array $parameters + */ + #[DataProvider('provideInvalidDefinitionsAreRejected')] + public function testInvalidDefinitionsAreRejected(string $message, array $parameters): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage($message); + + new GeneratorCommand(...$parameters); + } + + /** + * @return iterable}> + */ + public static function provideInvalidDefinitionsAreRejected(): iterable + { + yield 'empty component' => [ + 'Generator component cannot be empty.', + ['component' => '', 'template' => 'model.tpl.php'], + ]; + + yield 'component with regex metacharacter' => [ + 'Generator component "(" is not valid.', + ['component' => '(', 'template' => 'model.tpl.php'], + ]; + + yield 'component with space' => [ + 'Generator component "Widget Factory" is not valid.', + ['component' => 'Widget Factory', 'template' => 'model.tpl.php'], + ]; + + yield 'empty template' => [ + 'Generator template cannot be empty.', + ['component' => 'Model', 'template' => ''], + ]; + + yield 'empty directory' => [ + 'Generator directory cannot be empty.', + ['component' => 'Model', 'template' => 'model.tpl.php', 'directory' => ''], + ]; + + yield 'empty namespace' => [ + 'Generator namespace cannot be empty.', + ['component' => 'Model', 'template' => 'model.tpl.php', 'namespace' => ''], + ]; + + yield 'directory with dot segment' => [ + 'Generator directory "..\\Models" is not valid.', + ['component' => 'Model', 'template' => 'model.tpl.php', 'directory' => '..\\Models'], + ]; + + yield 'namespace with slash separator' => [ + 'Generator namespace "App/Models" is not valid.', + ['component' => 'Model', 'template' => 'model.tpl.php', 'namespace' => 'App/Models'], + ]; + } +} diff --git a/tests/system/CLI/CommandsTest.php b/tests/system/CLI/CommandsTest.php index 3332259f1934..e79522173269 100644 --- a/tests/system/CLI/CommandsTest.php +++ b/tests/system/CLI/CommandsTest.php @@ -39,7 +39,9 @@ use Tests\Support\InvalidCommands\AliasSecondClashCommand; use Tests\Support\InvalidCommands\AliasTargetCommand; use Tests\Support\InvalidCommands\EmptyCommandName; +use Tests\Support\InvalidCommands\InvalidComponentGeneratorCommand; use Tests\Support\InvalidCommands\NoAttributeCommand; +use Tests\Support\InvalidCommands\NoAttributeGeneratorCommand; /** * @internal @@ -486,6 +488,62 @@ public function testDiscoveryLogsErrorWhenCommandAttributeFailsToInstantiate(): $this->assertSame([], $commands->getModernCommands()); } + public function testDiscoveryLogsErrorForGeneratorCommandWithoutGeneratorAttribute(): void + { + $path = SUPPORTPATH . 'InvalidCommands/NoAttributeGeneratorCommand.php'; + + $locator = $this->createMock(FileLocatorInterface::class); + $locator + ->expects($this->once()) + ->method('listFiles') + ->with('Commands/') + ->willReturn([$path]); + $locator + ->expects($this->once()) + ->method('findQualifiedNameFromPath') + ->with($path) + ->willReturn(NoAttributeGeneratorCommand::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 testDiscoveryLogsErrorWhenGeneratorAttributeFailsToInstantiate(): void + { + $path = SUPPORTPATH . 'InvalidCommands/InvalidComponentGeneratorCommand.php'; + + $locator = $this->createMock(FileLocatorInterface::class); + $locator + ->expects($this->once()) + ->method('listFiles') + ->with('Commands/') + ->willReturn([$path]); + $locator + ->expects($this->once()) + ->method('findQualifiedNameFromPath') + ->with($path) + ->willReturn(InvalidComponentGeneratorCommand::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); diff --git a/tests/system/Commands/HelpCommandTest.php b/tests/system/Commands/HelpCommandTest.php index 07c50913a897..d42c94ad40cf 100644 --- a/tests/system/Commands/HelpCommandTest.php +++ b/tests/system/Commands/HelpCommandTest.php @@ -206,6 +206,62 @@ public function testDescribeCommandViaAliasResolvesToCanonical(): void ); } + public function testDescribeGeneratorCommand(): void + { + command('help make:testwidget'); + + $this->assertSame( + <<<'EOT' + + Usage: + make:testwidget [options] [--] + + Description: + Fixture generator command. + + Arguments: + name The name of the class to generate. + + 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. + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testDescribeGeneratorCommandWithTrimmedOptions(): void + { + command('help make:trimmedwidget'); + + $this->assertSame( + <<<'EOT' + + Usage: + make:trimmedwidget [options] [--] + + Description: + Fixture generator command with trimmed options and forced suffixing. + + Arguments: + name The name of the class to generate. + + 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"] + + EOT, + $this->getUndecoratedBuffer(), + ); + } + public function testDescribeUnavailableCommand(): void { command('help test:unavailable'); diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index ea39987d4240..c82859d711d0 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -225,6 +225,12 @@ Commands - Added ``make:request`` generator command to scaffold :ref:`Form Request ` classes. - Added ``key:rotate`` command to demote the current ``encryption.key`` to ``encryption.previousKeys`` in **.env** and generate a new key. See :ref:`spark-key-rotate`. - Added ``AbstractCommand::callSilently()`` to invoke another command with its output discarded, restoring the prior IO afterwards. See :ref:`modern-commands-call-silently`. +- Added :php:class:`AbstractGeneratorCommand ` and the ``#[GeneratorCommand]`` attribute, the modern + counterpart of ``GeneratorTrait`` for commands that generate files from templates. See :doc:`../cli/cli_modern_generators`. +- 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. + See :ref:`prompts-for-missing-input`. - The ``migrate``, ``migrate:rollback``, ``migrate:refresh``, and ``migrate:status`` commands now accept long option names (``--namespace``, ``--group``, ``--batch``, ``--force``) alongside their existing short forms (``-n``, ``-g``, ``-b``, ``-f``). - The ``namespaces`` command now accepts long option names (``--config-only``, ``--raw``, ``--max-length``) alongside its existing short forms (``-c``, ``-r``, ``-m``). - The ``worker:install`` and ``worker:uninstall`` commands now accept the ``-f`` short option alongside ``--force``. diff --git a/user_guide_src/source/cli/cli_generators.rst b/user_guide_src/source/cli/cli_generators.rst index e2710411069d..3372a97f6b49 100644 --- a/user_guide_src/source/cli/cli_generators.rst +++ b/user_guide_src/source/cli/cli_generators.rst @@ -354,6 +354,10 @@ GeneratorTrait All generator commands must use the ``GeneratorTrait`` to fully utilize its methods that are used in code generation. +.. note:: ``GeneratorTrait`` is the legacy way of building generator commands, tied to ``BaseCommand``. + New generator commands should extend ``AbstractGeneratorCommand`` instead. + See :doc:`cli_modern_generators`. + ************************************************************* Declaring the Location of a Custom Generator Command Template ************************************************************* diff --git a/user_guide_src/source/cli/cli_modern_commands.rst b/user_guide_src/source/cli/cli_modern_commands.rst index 52e17ee5f55f..ec21403842a7 100644 --- a/user_guide_src/source/cli/cli_modern_commands.rst +++ b/user_guide_src/source/cli/cli_modern_commands.rst @@ -194,6 +194,35 @@ snapshot taken right after ``interact()`` returns and before bind and validate. Any change you make to ``$arguments`` or ``$options`` inside ``interact()`` carries through to bind, validate, and ``execute()``. +.. _prompts-for-missing-input: + +Prompting for Missing Arguments +=============================== + +A command can let the framework prompt for its missing required arguments +instead of hand-rolling that logic in ``interact()``. Opt in by implementing +the ``CodeIgniter\CLI\PromptsForMissingInputInterface`` marker interface: + +.. literalinclude:: cli_modern_commands/015.php + +On an interactive run, right before ``interact()`` is called, the framework +compares the provided positional tokens against the declared required arguments +and prompts (with the ``required`` validation rule) for each one still missing, +in declaration order. Two hooks refine the behavior: + +- ``getArgumentPromptLabels(): array`` returns a map of argument name to + prompt label. Arguments without an entry fall back to a generic label naming + the argument. +- ``afterPrompting(array &$arguments, array &$options): void`` + runs after at least one prompt occurred. Useful to derive further input from + the answers. + +On a non-interactive run nothing is prompted, and a missing required argument +fails validation with the usual missing-arguments error. Since the prompted +values are appended before the raw input is snapshotted, they are also visible +to ``interact()``, the unbound accessors, and anything forwarded through +``$this->call(...)``. + .. _non-interactive-mode: Non-Interactive Mode diff --git a/user_guide_src/source/cli/cli_modern_commands/015.php b/user_guide_src/source/cli/cli_modern_commands/015.php new file mode 100644 index 000000000000..297e3b2eeb4f --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_commands/015.php @@ -0,0 +1,34 @@ +addArgument(new Argument( + name: 'name', + description: 'Who to greet.', + required: true, + )); + } + + protected function getArgumentPromptLabels(): array + { + return ['name' => 'Who should be greeted?']; + } + + protected function execute(array $arguments, array $options): int + { + CLI::write(sprintf('Hello, %s!', $arguments['name']), 'green'); + + return EXIT_SUCCESS; + } +} diff --git a/user_guide_src/source/cli/cli_modern_generators.rst b/user_guide_src/source/cli/cli_modern_generators.rst new file mode 100644 index 000000000000..a095e309f0b6 --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_generators.rst @@ -0,0 +1,354 @@ +######################### +Modern Generator Commands +######################### + +.. versionadded:: 4.8.0 + +Generator commands are spark commands that create files from templates. The +built-in ``make:*`` commands documented in :doc:`cli_generators` are the +canonical examples. The modern counterpart of the legacy ``GeneratorTrait`` is +the ``AbstractGeneratorCommand`` base class paired with a ``#[GeneratorCommand]`` +attribute: the attribute describes *what* to generate, and the base class carries +the whole generation pipeline so that a trivial generator needs no method body +at all. + +.. note:: + + ``GeneratorTrait`` continues to work for legacy ``BaseCommand`` generators. + +.. contents:: + :local: + :depth: 2 + +**************************** +Creating a Generator Command +**************************** + +A modern generator command is a :doc:`modern spark command ` that: + +- extends ``CodeIgniter\CLI\AbstractGeneratorCommand`` (which itself extends ``AbstractCommand``); +- declares the usual ``#[Command]`` attribute **and** a ``#[GeneratorCommand]`` attribute. + +A minimal generator needs nothing else: + +.. literalinclude:: cli_modern_generators/001.php + +Out of the box this command: + +- declares a required ``name`` argument, and prompts for it when omitted on an + interactive run (see :ref:`prompts-for-missing-input`); +- declares the ``--namespace`` / ``-n``, ``--suffix`` / ``-s``, and + ``--force`` / ``-f`` options on top of the framework defaults; +- ships a default ``execute()`` that renders the template and writes the class + file, returning ``EXIT_SUCCESS`` or ``EXIT_ERROR``. + +The ``#[GeneratorCommand]`` Attribute +===================================== + +The attribute holds the generation configuration: + +- ``component`` is the component noun (``Controller``, ``Model``, ...). It drives suffix handling: + an input whose trailing component is spelled with the wrong case is normalized + (``make:widget foowidget`` generates ``FooWidget``), and passing ``--suffix`` appends the + component to inputs that do not already end with it. +- ``template`` is the basename of the fallback view under ``CodeIgniter\Commands\Generators\Views`` + (see :ref:`generator-command-templates` for the lookup order). +- ``directory`` is the optional sub-namespace (and thus subdirectory) the class is generated into, + e.g. ``Widgets`` places classes under ``App\Widgets``. +- ``classNameLang`` is the language string key used as the prompt label when the ``name`` + argument must be asked for interactively. Defaults to the generic + ``CLI.generator.className.default`` label. +- ``namespace`` optionally pins the root namespace, ignoring the ``--namespace`` option. +- ``sortImports`` (default ``true``) controls whether the first contiguous ``use`` block of the + generated content is sorted alphabetically. + +Like ``#[Command]``, the attribute validates its inputs at construction time: +``component`` must be a class-name fragment (a letter followed by letters, digits, +or underscores), ``template`` must not be empty, and ``directory`` and +``namespace``, when given, must be valid backslash-separated namespace fragments. + +.. _generator-command-templates: + +********* +Templates +********* + +Templates are regular view files. Because they are rendered through ``view()``, +a template cannot open with a literal PHP tag, so the pipeline recognizes a few +pseudo-placeholders that are substituted after rendering: + +- ``<@php`` becomes ``generateClass()``, which walks +through these steps: + +1. The ``name`` argument is normalized: segments are converted to PascalCase, a + miscased trailing component is fixed, and the component suffix is appended + when applicable. The result is qualified against the root namespace and the + attribute's ``directory``. A result that is not a valid class name (for example, + one containing ``..`` segments) aborts with ``EXIT_ERROR``. +2. The target path is derived from the autoloader's mapping for the root + namespace. An unknown namespace aborts with ``EXIT_ERROR`` and an error + message. +3. The template is rendered with the view data, placeholders are replaced, and + the imports are sorted when ``sortImports`` is enabled. +4. Safety checks run before writing: generating into the ``CodeIgniter`` + namespace asks for confirmation on interactive runs (and proceeds with a + warning on non-interactive ones), and an existing target file aborts with + ``EXIT_ERROR`` unless ``--force`` is passed. + +Generators that produce more than one artifact can call ``generateView()`` for +non-class files, and may reassign the mutable ``$template`` / ``$templatePath`` +properties between generation calls. + +******************* +Customization Hooks +******************* + +Placeholders and View Data +========================== + +Most real generators need more than ``{namespace}`` and ``{class}``. Override +``getReplacements()`` to add placeholder substitutions and ``getTemplateData()`` +to pass variables into the view: + +.. literalinclude:: cli_modern_generators/003.php + +Entries returned by ``getReplacements()`` take precedence over the core pairs, +so a generator can even override how ``{namespace}`` or ``{class}`` is derived. + +Trimming Options and Forcing the Suffix +======================================= + +The common generator options are registered through ``provideGeneratorOptions()``. +Override it to drop options that make no sense for your generator, and override +``shouldAppendSuffix()`` when suffixing is not driven by the ``--suffix`` flag: + +.. literalinclude:: cli_modern_generators/004.php + +The base class guards every read of ``--suffix`` and ``--force``, so omitting +them is safe. + +Other Hooks +=========== + +- ``basename(string $filename): string`` changes the file basename before saving. + Useful for components whose file name carries a date, like migrations. +- ``getNamespace(): string`` resolves the root namespace. The default returns the attribute's + ``namespace`` or the ``--namespace`` option. +- ``buildPath(string $class): string`` maps the qualified class to a file path through the + autoloader. Override for components with special file locations, like tests. +- ``renderTemplate(array $data = []): string`` renders the resolved view. + +**************************** +Prompting for the Class Name +**************************** + +``AbstractGeneratorCommand`` implements ``PromptsForMissingInputInterface``, so +running a generator interactively without a class name prompts for it instead of +failing validation. The prompt label comes from the attribute's ``classNameLang`` +language key. Non-interactive runs (piped input, ``--no-interaction``) fail fast +with the usual missing-arguments error. + +The mechanism is available to every modern command, not just generators. See +:ref:`prompts-for-missing-input`. + +********************************* +Migrating From ``GeneratorTrait`` +********************************* + +The base class covers everything the trait did, but the configuration moves +from mutable properties and the ``$params`` array to the attribute and the +validated input accessors. + +**Configuration properties** + +``protected $component``, ``protected $template``, ``protected $directory``, ``protected $classNameLang``, ``protected ?string $namespace`` + Move to the corresponding parameters of the ``#[GeneratorCommand]`` attribute. + +``protected ?string $templatePath`` + Stays a runtime property on the base class. Assign it only when switching + templates mid-run (multi-artifact generators). + +``$this->setSortImports(false)`` + Becomes ``sortImports: false`` on the attribute. + +``$this->setEnabledSuffixing(false)`` + Gone. Override ``provideGeneratorOptions()`` to not register ``--suffix``, and + ``shouldAppendSuffix()`` to control suffixing directly. Note that a user-passed + ``--suffix``, which the trait silently ignored, then fails validation as an + unknown option. + +``$this->setHasClassName(false)`` + Gone. Override ``configure()`` without calling ``parent::configure()`` and declare + whatever arguments your generator actually needs. Also override ``execute()``, + since the default one calls ``generateClass()``, which requires the ``name`` + argument. + +**Input handling** + +``run(array $params)`` + Not used by modern commands. The default ``execute()`` already calls + ``generateClass()``. Override ``execute()`` only when the run involves more + than one generation step. + +``$params[0]`` + The class name is now the declared ``name`` argument: ``$this->getValidatedArgument('name')``. + +``$this->getOption('foo')`` / ``CLI::getOption('foo')`` + Become ``$this->getValidatedOption('foo')``. Extra options must be declared in + ``configure()`` (after calling ``parent::configure()``). + +Class-name prompting + Automatic. The trait prompted inside its name normalization. The base class + prompts through :ref:`prompts-for-missing-input` before binding, so the + argument is guaranteed present by the time your code runs. + +**Content hooks** + +``prepare(string $class)`` override + Gone. Return extra placeholder pairs from ``getReplacements()`` and view data + from ``getTemplateData()`` instead of calling ``parseTemplate()`` with arrays. + +``basename(string $filename)`` override + Same hook name and signature on the base class. + +``getNamespace()`` / ``buildPath()`` overrides + Same hook names and signatures on the base class. + +A typical ``GeneratorTrait`` generator: + +.. literalinclude:: cli_modern_generators/005.php + +…becomes, as a modern generator command: + +.. literalinclude:: cli_modern_generators/001.php + +Behavioural changes we need to be aware of when migrating: + +- **Failures produce failing exit codes.** The trait's ``generateClass()`` returned ``void``, so a + generator reported ``EXIT_SUCCESS`` even when the target file already existed. The base class + returns ``EXIT_ERROR`` for an existing file without ``--force``, a write failure, and an undefined + namespace. Declining the ``CodeIgniter`` namespace confirmation still exits with ``EXIT_SUCCESS``. +- **The** ``CodeIgniter`` **namespace confirmation only prompts on interactive runs.** Non-interactive + runs print the warning and proceed instead of blocking on input that will never arrive. +- **The** ``CodeIgniter`` **namespace confirmation keys off the resolved namespace.** The trait compared + the raw ``--namespace`` option, so an attribute-pinned ``CodeIgniter`` namespace or a spelling like + ``--namespace CodeIgniter/`` did not warn. The base class resolves through ``getNamespace()`` first. +- **Placeholder replacement is single-pass.** Replacements are applied with ``strtr()``, so a + replacement value that happens to contain another placeholder is no longer substituted again. + +************************ +AbstractGeneratorCommand +************************ + +.. php:namespace:: CodeIgniter\CLI + +.. php:class:: AbstractGeneratorCommand + + All hooks below are ``protected``: they are called or overridden from within + your own generator, never from the outside. + + .. php:method:: generateClass(): int + + Runs the full pipeline for the qualified class name and returns an + ``EXIT_*`` status. This is what the default ``execute()`` calls. + + .. php:method:: generateView(string $view): int + + :param string $view: Namespaced view name to generate. + + Like :php:meth:`generateClass`, but for non-class artifacts. The name is + used as-is, without qualification or suffix handling. + + .. php:method:: getReplacements(string $class): array + + :param string $class: The namespaced class (or view) being generated. + + Returns extra placeholder replacements. Entries take precedence over the + core ``{namespace}`` / ``{class}`` pairs. Defaults to ``[]``. + + .. php:method:: getTemplateData(string $class): array + + :param string $class: The namespaced class (or view) being generated. + + Returns view data passed to the template when rendering. Defaults to ``[]``. + + .. php:method:: shouldAppendSuffix(): bool + + Whether the component suffix should be appended to the class name. The + default reads the ``--suffix`` flag when it is declared. + + .. php:method:: basename(string $filename): string + + :param string $filename: The computed target file path. + + Returns the file basename to save under. Override to decorate it, for + example with a timestamp. + + .. php:method:: getNamespace(): string + + Resolves the root namespace from the attribute override or the + ``--namespace`` option. + + .. php:method:: buildPath(string $class): string + + :param string $class: The namespaced class (or view) being generated. + + Maps the class to its target file path through the autoloader. Returns + an empty string (after printing an error) when the namespace is not + registered. + + .. php:method:: renderTemplate(array $data = []): string + + :param array $data: View data for the template. + + Renders the resolved generator view (see + :ref:`generator-command-templates` for the resolution order). + + .. php:method:: provideGeneratorOptions(): void + + Registers the common generator options. The default registers all three + of ``--namespace``, ``--suffix``, and ``--force`` through the ``final`` + helpers :php:meth:`addNamespaceOption`, :php:meth:`addSuffixOption`, and + :php:meth:`addForceOption`. Override to register a subset. + + .. php:method:: addNamespaceOption(): static + + Registers the ``--namespace`` / ``-n`` option, defaulting to ``APP_NAMESPACE``. + + .. php:method:: addSuffixOption(): static + + Registers the ``--suffix`` / ``-s`` flag. + + .. php:method:: addForceOption(): static + + Registers the ``--force`` / ``-f`` flag. diff --git a/user_guide_src/source/cli/cli_modern_generators/001.php b/user_guide_src/source/cli/cli_modern_generators/001.php new file mode 100644 index 000000000000..0a7c8f11edbb --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_generators/001.php @@ -0,0 +1,13 @@ + 'App\Commands\Generators\Views\widget.tpl.php', + ]; +} diff --git a/user_guide_src/source/cli/cli_modern_generators/003.php b/user_guide_src/source/cli/cli_modern_generators/003.php new file mode 100644 index 000000000000..63a95b607e36 --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_generators/003.php @@ -0,0 +1,38 @@ +addOption(new Option( + name: 'table', + description: 'Table name to use.', + requiresValue: true, + default: 'widgets', + )); + } + + protected function getReplacements(string $class): array + { + // Replaces the {table} placeholder in the template. + return ['{table}' => (string) $this->getValidatedOption('table')]; + } + + protected function getTemplateData(string $class): array + { + // Available as a plain $sortable variable inside the template. + return ['sortable' => true]; + } +} diff --git a/user_guide_src/source/cli/cli_modern_generators/004.php b/user_guide_src/source/cli/cli_modern_generators/004.php new file mode 100644 index 000000000000..69812a0e6a27 --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_generators/004.php @@ -0,0 +1,24 @@ +addNamespaceOption()->addForceOption(); + } + + protected function shouldAppendSuffix(): bool + { + // The "Widget" suffix is always appended. + return true; + } +} diff --git a/user_guide_src/source/cli/cli_modern_generators/005.php b/user_guide_src/source/cli/cli_modern_generators/005.php new file mode 100644 index 000000000000..d2a77b6c698a --- /dev/null +++ b/user_guide_src/source/cli/cli_modern_generators/005.php @@ -0,0 +1,33 @@ + [options]'; + protected $arguments = ['name' => 'The widget class name.']; + protected $options = [ + '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".', + '--suffix' => 'Append the component title to the class name.', + '--force' => 'Force overwrite existing file.', + ]; + + public function run(array $params) + { + $this->component = 'Widget'; + $this->directory = 'Widgets'; + $this->template = 'widget.tpl.php'; + + $this->generateClass($params); + + return EXIT_SUCCESS; + } +} diff --git a/user_guide_src/source/cli/index.rst b/user_guide_src/source/cli/index.rst index d6cf47d998e8..0487bfd61a21 100644 --- a/user_guide_src/source/cli/index.rst +++ b/user_guide_src/source/cli/index.rst @@ -13,6 +13,7 @@ CodeIgniter 4 can also be used with command line programs. cli_commands cli_modern_commands cli_generators + cli_modern_generators cli_library cli_signals cli_request