diff --git a/system/CLI/AbstractGeneratorCommand.php b/system/CLI/AbstractGeneratorCommand.php index ecb1da28f821..f6fb4e0dd3de 100644 --- a/system/CLI/AbstractGeneratorCommand.php +++ b/system/CLI/AbstractGeneratorCommand.php @@ -93,14 +93,14 @@ protected function provideGeneratorOptions(): void $this->addNamespaceOption()->addSuffixOption()->addForceOption(); } - final protected function addNamespaceOption(): static + final protected function addNamespaceOption(string $default = APP_NAMESPACE): static { return $this->addOption(new Option( name: 'namespace', shortcut: 'n', description: 'Set the root namespace.', requiresValue: true, - default: APP_NAMESPACE, + default: $default, )); } @@ -259,28 +259,34 @@ protected function buildContent(string $class): string protected function buildPath(string $class): string { $namespace = $this->getNamespace(); + $basePath = $this->getBasePath($namespace); - $bases = service('autoloader')->getNamespace($namespace); - $base = reset($bases); - - if ($base === false || $base === '') { + if ($basePath === null) { CLI::error(lang('CLI.namespaceNotDefined', [$namespace])); return ''; } - $realpath = realpath($base); - $base = ($realpath !== false) ? $realpath : $base; + $realpath = realpath($basePath); + $basePath = ($realpath !== false) ? $realpath : $basePath; $prefix = $namespace . '\\'; $relative = str_starts_with($class, $prefix) ? substr($class, strlen($prefix)) : $class; - $file = $base . DIRECTORY_SEPARATOR + $file = $basePath . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, trim($relative, '\\')) . '.php'; return dirname($file) . DIRECTORY_SEPARATOR . $this->basename($file); } + /** + * Returns the directory registered for the namespace in the autoloader, or `null` when it is not defined. + */ + protected function getBasePath(string $namespace): ?string + { + return service('autoloader')->getNamespace($namespace)[0] ?? null; + } + /** * Gets the root namespace from the attribute override or the `--namespace` option. */ @@ -321,7 +327,7 @@ private function generate(string $class): int */ private function generateFile(string $target, string $content): int { - if ($this->getNamespace() === 'CodeIgniter') { + if (str_starts_with($target, SYSTEMPATH)) { CLI::write(lang('CLI.generator.usingCINamespace'), 'yellow'); if ( diff --git a/system/Commands/Generators/TestGenerator.php b/system/Commands/Generators/TestGenerator.php index 3ff8e39d17a4..2454b51ccf03 100644 --- a/system/Commands/Generators/TestGenerator.php +++ b/system/Commands/Generators/TestGenerator.php @@ -13,177 +13,65 @@ namespace CodeIgniter\Commands\Generators; -use CodeIgniter\CLI\BaseCommand; -use CodeIgniter\CLI\CLI; -use CodeIgniter\CLI\GeneratorTrait; - -/** - * Generates a skeleton command file. - */ -class TestGenerator extends BaseCommand +use CodeIgniter\CLI\AbstractGeneratorCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\Attributes\GeneratorCommand; + +#[Command(name: 'make:test', description: 'Generates a new test file.', group: 'Generators')] +#[GeneratorCommand( + component: 'Test', + template: 'test.tpl.php', + classNameLang: 'CLI.generator.className.test', +)] +class TestGenerator extends AbstractGeneratorCommand { - use GeneratorTrait; - - /** - * The Command's Group - * - * @var string - */ - protected $group = 'Generators'; - - /** - * The Command's Name - * - * @var string - */ - protected $name = 'make:test'; - - /** - * The Command's Description - * - * @var string - */ - protected $description = 'Generates a new test file.'; - - /** - * The Command's Usage - * - * @var string - */ - protected $usage = 'make:test [options]'; - - /** - * The Command's Arguments - * - * @var array - */ - protected $arguments = [ - 'name' => 'The test class name.', - ]; - - /** - * The Command's Options - * - * @var array - */ - protected $options = [ - '--namespace' => 'Set root namespace. Default: "Tests".', - '--force' => 'Force overwrite existing file.', - ]; - - /** - * Actually execute a command. - */ - public function run(array $params) - { - // Ensure tests are always suffixed with 'Test' - $params['suffix'] = null; - - $this->component = 'Test'; - $this->template = 'test.tpl.php'; - - $this->classNameLang = 'CLI.generator.className.test'; + private const DEFAULT_NAMESPACE = 'Tests'; - $autoload = service('autoloader'); - $autoload->addNamespace('CodeIgniter', TESTPATH . 'system'); - $autoload->addNamespace('Tests', ROOTPATH . 'tests'); + protected function provideGeneratorOptions(): void + { + $this->addNamespaceOption(self::DEFAULT_NAMESPACE)->addForceOption(); + } - $this->generateClass($params); + protected function initialize(array &$arguments, array &$options): void + { + $autoloader = service('autoloader'); + $autoloader->addNamespace('CodeIgniter', TESTPATH . 'system'); + $autoloader->addNamespace(self::DEFAULT_NAMESPACE, ROOTPATH . 'tests'); + } - return EXIT_SUCCESS; + protected function shouldAppendSuffix(): bool + { + return true; } - /** - * Gets the namespace from input or the default namespace. - */ protected function getNamespace(): string { - if ($this->namespace !== null) { - return $this->namespace; + if ($this->hasUnboundOption('namespace')) { + return parent::getNamespace(); } - if ($this->getOption('namespace') !== null) { - return trim( - str_replace( - '/', - '\\', - $this->getOption('namespace'), - ), - '\\', - ); - } + helper('inflector'); - $class = $this->normalizeInputClassName(); - $classPaths = explode('\\', $class); + $name = $this->getValidatedArgument('name'); + $segments = array_map(pascalize(...), explode('\\', str_replace('/', '\\', $name))); + $autoloader = service('autoloader'); - $namespaces = service('autoloader')->getNamespace(); + while ($segments !== []) { + array_pop($segments); - while ($classPaths !== []) { - array_pop($classPaths); - $namespace = implode('\\', $classPaths); + $namespace = implode('\\', $segments); - foreach (array_keys($namespaces) as $prefix) { - if ($prefix === $namespace) { - // The input classname is FQCN, and use the namespace. - return $namespace; - } + if ($namespace !== '' && $autoloader->getNamespace($namespace) !== []) { + return $namespace; } } - return 'Tests'; - } - - /** - * Builds the test file path from the class name. - * - * @param string $class namespaced classname. - */ - protected function buildPath(string $class): string - { - $namespace = $this->getNamespace(); - - $base = $this->searchTestFilePath($namespace); - - if ($base === null) { - CLI::error( - lang('CLI.namespaceNotDefined', [$namespace]), - 'light_gray', - 'red', - ); - CLI::newLine(); - - return ''; - } - - $realpath = realpath($base); - $base = ($realpath !== false) ? $realpath : $base; - - $file = $base . DIRECTORY_SEPARATOR - . str_replace( - '\\', - DIRECTORY_SEPARATOR, - trim(str_replace($namespace . '\\', '', $class), '\\'), - ) . '.php'; - - return implode( - DIRECTORY_SEPARATOR, - array_slice( - explode(DIRECTORY_SEPARATOR, $file), - 0, - -1, - ), - ) . DIRECTORY_SEPARATOR . $this->basename($file); + return self::DEFAULT_NAMESPACE; } - /** - * Returns test file path for the namespace. - */ - private function searchTestFilePath(string $testNamespace): ?string + protected function getBasePath(string $namespace): ?string { - /** @var list $testPaths */ - $testPaths = service('autoloader')->getNamespace($testNamespace); - - foreach ($testPaths as $candidate) { + foreach (service('autoloader')->getNamespace($namespace) as $candidate) { if (str_contains($candidate, DIRECTORY_SEPARATOR . 'tests' . DIRECTORY_SEPARATOR)) { return $candidate; } diff --git a/tests/system/Commands/Generators/TestGeneratorTest.php b/tests/system/Commands/Generators/TestGeneratorTest.php index 3cad6b214307..fbb5b2778c1f 100644 --- a/tests/system/Commands/Generators/TestGeneratorTest.php +++ b/tests/system/Commands/Generators/TestGeneratorTest.php @@ -32,88 +32,86 @@ protected function setUp(): void { parent::setUp(); - $this->resetStreamFilterBuffer(); + CLI::reset(); } protected function tearDown(): void { parent::tearDown(); - $this->clearTestFiles(); - $this->resetStreamFilterBuffer(); - } - - private function getUndecoratedBuffer(): string - { - return preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()); - } - - private function clearTestFiles(): void - { - preg_match('/File created: (.*)/', $this->getUndecoratedBuffer(), $result); + CLI::reset(); - $file = str_replace('ROOTPATH' . DIRECTORY_SEPARATOR, ROOTPATH, $result[1] ?? ''); - if (is_file($file)) { - unlink($file); + foreach (['FooTest.php', 'system/FooTest.php', '_support/FooTest.php'] as $file) { + if (is_file(ROOTPATH . 'tests/' . $file)) { + unlink(ROOTPATH . 'tests/' . $file); + } } - $dir = dirname($file) . DIRECTORY_SEPARATOR; - if (is_dir($dir) && ! in_array($dir, ['/', TESTPATH, TESTPATH . 'system/', TESTPATH . '_support/'], true)) { - rmdir($dir); + if (is_dir(ROOTPATH . 'tests/Foo')) { + helper('filesystem'); + delete_files(ROOTPATH . 'tests/Foo', true, false, true); + rmdir(ROOTPATH . 'tests/Foo'); } } + private function getUndecoratedBuffer(): string + { + return preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()) ?? ''; + } + #[DataProvider('provideGenerateTestFiles')] - public function testGenerateTestFiles(string $name, string $expectedClass): void + public function testGenerateTestFiles(string $name, string $expectedFile, string $expectedNamespace): void { - command(sprintf('make:test %s', $name)); + command('make:test ' . $name); + + $this->assertSame(sprintf("\nFile created: ROOTPATH/tests/%s\n", $expectedFile), $this->getUndecoratedBuffer()); - $expectedTestFile = str_replace('/', DIRECTORY_SEPARATOR, sprintf('%stests/%s.php', ROOTPATH, $expectedClass)); - $expectedMessage = sprintf('File created: %s', str_replace(ROOTPATH, 'ROOTPATH' . DIRECTORY_SEPARATOR, $expectedTestFile)); - $this->assertStringContainsString($expectedMessage, $this->getUndecoratedBuffer()); - $this->assertFileExists($expectedTestFile); + $contents = file_get_contents(ROOTPATH . 'tests/' . $expectedFile); + $this->assertIsString($contents); + $this->assertStringContainsString(sprintf('namespace %s;', $expectedNamespace), $contents); + $this->assertStringContainsString(sprintf('class %s extends CIUnitTestCase', basename($expectedFile, '.php')), $contents); } /** - * @return iterable + * @return iterable */ public static function provideGenerateTestFiles(): iterable { - yield 'simple class name' => ['Foo', 'FooTest']; + yield 'simple class name' => ['Foo', 'FooTest.php', 'Tests']; + + yield 'namespaced class name' => ['Foo/Bar', 'Foo/BarTest.php', 'Tests\Foo']; + + yield 'class with suffix' => ['Foo/BarTest', 'Foo/BarTest.php', 'Tests\Foo']; + + yield 'namespace style class name' => ['Foo\\\\Bar', 'Foo/BarTest.php', 'Tests\Foo']; + + yield 'fully qualified framework class' => ['CodeIgniter\\\\Foo', 'system/FooTest.php', 'CodeIgniter']; - yield 'namespaced class name' => ['Foo/Bar', 'Foo/BarTest']; + yield 'fully qualified support class' => ['Tests\\\\Support\\\\Foo', '_support/FooTest.php', 'Tests\Support']; - yield 'class with suffix' => ['Foo/BarTest', 'Foo/BarTest']; + yield 'explicit namespace' => ['Foo --namespace Tests\\\\Support', '_support/FooTest.php', 'Tests\Support']; + } + + public function testUndefinedNamespaceFails(): void + { + command('make:test Foo --namespace Bogus'); - // the 4 slashes are needed to escape here and in the command - yield 'namespace style class name' => ['Foo\\\\Bar', 'Foo/BarTest']; + $this->assertSame("\nNamespace \"Bogus\" is not defined.\n", $this->getUndecoratedBuffer()); + $this->assertFileDoesNotExist(ROOTPATH . 'tests/FooTest.php'); } public function testGenerateTestWithEmptyClassName(): void { - $expectedFile = ROOTPATH . 'tests/FooTest.php'; - CLI::reset(); + $io = new MockInputOutput(); + $io->setInputs(['', 'Foo']); + CLI::setInputOutput($io); - try { - $io = new MockInputOutput(); - CLI::setInputOutput($io); - - // Simulate running `make:test` with no input followed by entering `Foo` - $io->setInputs(['', 'Foo']); - command('make:test'); - - $expectedOutput = 'Test class name : ' . PHP_EOL; - $expectedOutput .= 'The "Test class name" field is required.' . PHP_EOL; - $expectedOutput .= 'Test class name : Foo' . PHP_EOL . PHP_EOL; - $expectedOutput .= 'File created: ROOTPATH/tests/FooTest.php' . PHP_EOL . PHP_EOL; - $this->assertSame($expectedOutput, preg_replace('/\e\[[^m]+m/', '', $io->getOutput())); - $this->assertFileExists($expectedFile); - } finally { - if (is_file($expectedFile)) { - unlink($expectedFile); - } + command('make:test'); - CLI::resetInputOutput(); - } + $this->assertSame( + "Test class name : \nThe \"Test class name\" field is required.\nTest class name : Foo\n\nFile created: ROOTPATH/tests/FooTest.php\n", + preg_replace('/\e\[[^m]+m/', '', $io->getOutput()), + ); + $this->assertFileExists(ROOTPATH . 'tests/FooTest.php'); } } diff --git a/user_guide_src/source/cli/cli_generators.rst b/user_guide_src/source/cli/cli_generators.rst index a5b1283fa02f..506dcabc6bbf 100644 --- a/user_guide_src/source/cli/cli_generators.rst +++ b/user_guide_src/source/cli/cli_generators.rst @@ -272,8 +272,8 @@ Argument: Options: ======== -* ``--namespace``: Set the root namespace. Defaults to value of ``Tests``. -* ``--force``: Set this flag to overwrite existing files on destination. +* ``--namespace`` (``-n``): Set the root namespace. Defaults to value of ``Tests``. +* ``--force`` (``-f``): Set this flag to overwrite existing files on destination. make:transformer ---------------- diff --git a/user_guide_src/source/cli/cli_modern_generators.rst b/user_guide_src/source/cli/cli_modern_generators.rst index a095e309f0b6..775bfbcedfb8 100644 --- a/user_guide_src/source/cli/cli_modern_generators.rst +++ b/user_guide_src/source/cli/cli_modern_generators.rst @@ -166,6 +166,9 @@ Other Hooks ``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. +- ``getBasePath(string $namespace): ?string`` picks the directory registered for the namespace. + The default takes the first autoloader entry. Override when the namespace maps to several + directories and a specific one is wanted, like the ``tests/`` copy of ``CodeIgniter``. - ``renderTemplate(array $data = []): string`` renders the resolved view. **************************** @@ -260,9 +263,10 @@ Behavioural changes we need to be aware of when migrating: 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** ``CodeIgniter`` **namespace confirmation keys off the target path.** 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. + ``--namespace CodeIgniter/`` did not warn. The base class warns exactly when the file would be written + under ``SYSTEMPATH``, so a ``CodeIgniter\Foo`` test class headed for ``tests/system`` is left alone. - **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. @@ -327,6 +331,13 @@ AbstractGeneratorCommand an empty string (after printing an error) when the namespace is not registered. + .. php:method:: getBasePath(string $namespace): ?string + + :param string $namespace: The resolved root namespace. + + Returns the directory registered for the namespace, or ``null`` when it + is not registered. The default takes the first autoloader entry. + .. php:method:: renderTemplate(array $data = []): string :param array $data: View data for the template. @@ -341,9 +352,11 @@ AbstractGeneratorCommand helpers :php:meth:`addNamespaceOption`, :php:meth:`addSuffixOption`, and :php:meth:`addForceOption`. Override to register a subset. - .. php:method:: addNamespaceOption(): static + .. php:method:: addNamespaceOption(string $default = APP_NAMESPACE): static + + :param string $default: The namespace used when the option is omitted. - Registers the ``--namespace`` / ``-n`` option, defaulting to ``APP_NAMESPACE``. + Registers the ``--namespace`` / ``-n`` option. .. php:method:: addSuffixOption(): static