diff --git a/composer.json b/composer.json index 2bbacf874..18d7ecf2a 100644 --- a/composer.json +++ b/composer.json @@ -150,5 +150,10 @@ "composer phpunit", "composer phpstan" ] + }, + "config": { + "allow-plugins": { + "php-http/discovery": true + } } } diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 0523ffa3c..4d7917c2f 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,39 +1,42 @@ includes: - - phpstan-baseline.neon - - vendor/phpat/phpat/extension.neon - - vendor/spaze/phpstan-disallowed-calls/extension.neon + - phpstan-baseline.neon + - vendor/phpat/phpat/extension.neon + - vendor/spaze/phpstan-disallowed-calls/extension.neon services: - - - class: Tests\Tempest\Architecture\ArchitectureTestCase - tags: - - phpat.test + - + class: Tests\Tempest\Architecture\ArchitectureTestCase + tags: + - phpat.test parameters: - level: 5 - tmpDir: .cache/phpstan - excludePaths: - - tests/Integration/View/blade/cache/**.php - paths: - - src - - tests - ignoreErrors: + level: 5 + tmpDir: .cache/phpstan + excludePaths: + - tests/Integration/View/blade/cache/**.php + paths: + - src + - tests + ignoreErrors: - - - message: '#.*#' - path: src/Tempest/Http/src/Exceptions/exception.php - - - message: '#.*exec*#' - path: src/Tempest/Console/src/Terminal/Terminal.php + - + message: '#.*#' + path: src/Tempest/Http/src/Exceptions/exception.php + - + message: '#.*exec*#' + path: src/Tempest/Console/src/Terminal/Terminal.php + - + message: "#^Tempest\\\\Console\\\\ConsoleCommand should be final$#" + path: src/Tempest/Console/src/ConsoleCommand.php - disallowedFunctionCalls: - - - function: 'exec()' - - - function: 'eval()' - - - function: 'dd()' - - - function: 'dump()' - - - function: 'phpinfo()' - - - function: 'var_dump()' + disallowedFunctionCalls: + - + function: 'exec()' + - + function: 'eval()' + - + function: 'dd()' + - + function: 'dump()' + - + function: 'phpinfo()' + - + function: 'var_dump()' diff --git a/src/Tempest/Console/src/Actions/ExecuteConsoleCommand.php b/src/Tempest/Console/src/Actions/ExecuteConsoleCommand.php index ad08dff3b..d70300547 100644 --- a/src/Tempest/Console/src/Actions/ExecuteConsoleCommand.php +++ b/src/Tempest/Console/src/Actions/ExecuteConsoleCommand.php @@ -5,12 +5,15 @@ namespace Tempest\Console\Actions; use Closure; +use RuntimeException; use Tempest\Console\ConsoleConfig; use Tempest\Console\ConsoleInputBuilder; use Tempest\Console\ExitCode; +use Tempest\Console\GeneratorCommand; use Tempest\Console\Initializers\Invocation; use Tempest\Console\Input\ConsoleArgumentBag; use Tempest\Container\Container; +use Tempest\Reflection\MethodReflector; final readonly class ExecuteConsoleCommand { @@ -36,17 +39,19 @@ private function getCallable(array $commandMiddleware): Closure { $callable = function (Invocation $invocation) { $consoleCommand = $invocation->consoleCommand; - - $handler = $consoleCommand->handler; - - $consoleCommandClass = $this->container->get($handler->getDeclaringClass()->getName()); - $inputBuilder = new ConsoleInputBuilder($consoleCommand, $invocation->argumentBag); - - $consoleCommand->handler->invokeArgs( - $consoleCommandClass, - $inputBuilder->build(), - ); + $handler = ($consoleCommand instanceof GeneratorCommand) + ? $consoleCommand->makeHandler() + : $consoleCommand->handler; + + match (true) { + is_callable($handler) => $handler($inputBuilder->build()), + ($handler instanceof MethodReflector) => $handler->invokeArgs( + $this->container->get($handler->getDeclaringClass()->getName()), + $inputBuilder->build() + ), + default => throw new RuntimeException('Command handler cannot be resolved.'), // @phpstan-ignore-line + }; return ExitCode::SUCCESS; }; diff --git a/src/Tempest/Console/src/Commands/Generators/MakeControllerCommand.php b/src/Tempest/Console/src/Commands/Generators/MakeControllerCommand.php new file mode 100644 index 000000000..305b64664 --- /dev/null +++ b/src/Tempest/Console/src/Commands/Generators/MakeControllerCommand.php @@ -0,0 +1,48 @@ +getSuggestedPath( + className : $className, + pathPrefix : 'Controllers', + classSuffix: 'Controller', + ); + $targetPath = $this->promptTargetPath($suggestedPath); + $shouldOverride = $this->askForOverride($targetPath); + + return new StubFileGenerator( + stubFile : ControllerStub::class, + targetPath : $targetPath, + shouldOverride: $shouldOverride, + replacements : [ + 'dummy-path' => $controllerPath, + 'dummy-view' => $controllerView, + ], + ); + } +} diff --git a/src/Tempest/Console/src/Commands/Generators/MakeModelCommand.php b/src/Tempest/Console/src/Commands/Generators/MakeModelCommand.php new file mode 100644 index 000000000..7e7ba7b0d --- /dev/null +++ b/src/Tempest/Console/src/Commands/Generators/MakeModelCommand.php @@ -0,0 +1,47 @@ +getSuggestedPath( + className : $className, + pathPrefix : 'Models', + classSuffix: 'Model', + ); + $targetPath = $this->promptTargetPath($suggestedPath); + $shouldOverride = $this->askForOverride($targetPath); + + return new StubFileGenerator( + stubFile : $isDatabaseModel ? DatabaseModelStub::class : ModelStub::class, + targetPath : $targetPath, + shouldOverride: $shouldOverride, + ); + } +} diff --git a/src/Tempest/Console/src/ConsoleCommand.php b/src/Tempest/Console/src/ConsoleCommand.php index 40cf1127f..9a46e2113 100644 --- a/src/Tempest/Console/src/ConsoleCommand.php +++ b/src/Tempest/Console/src/ConsoleCommand.php @@ -9,7 +9,7 @@ use Tempest\Reflection\MethodReflector; #[Attribute] -final class ConsoleCommand +class ConsoleCommand { public MethodReflector $handler; diff --git a/src/Tempest/Console/src/Discovery/ConsoleCommandDiscovery.php b/src/Tempest/Console/src/Discovery/ConsoleCommandDiscovery.php index 975776bea..1f1dac3dc 100644 --- a/src/Tempest/Console/src/Discovery/ConsoleCommandDiscovery.php +++ b/src/Tempest/Console/src/Discovery/ConsoleCommandDiscovery.php @@ -6,6 +6,7 @@ use Tempest\Console\ConsoleCommand; use Tempest\Console\ConsoleConfig; +use Tempest\Console\GeneratorCommand; use Tempest\Container\Container; use Tempest\Core\Discovery; use Tempest\Core\HandlesDiscoveryCache; @@ -41,7 +42,7 @@ public function createCachePayload(): string public function restoreCachePayload(Container $container, string $payload): void { - $commands = unserialize($payload, ['allowed_classes' => [ConsoleCommand::class, MethodReflector::class]]); + $commands = unserialize($payload, ['allowed_classes' => [GeneratorCommand::class, ConsoleCommand::class, MethodReflector::class]]); $this->consoleConfig->commands = $commands; } diff --git a/src/Tempest/Console/src/GeneratorCommand.php b/src/Tempest/Console/src/GeneratorCommand.php new file mode 100644 index 000000000..7ddce42cb --- /dev/null +++ b/src/Tempest/Console/src/GeneratorCommand.php @@ -0,0 +1,39 @@ + $params) The command handler. + */ + public function makeHandler(): Closure + { + return function (array $params): void { + // Resolve all generators and run them. + arr( + $this->handler->invokeArgs( + get($this->handler->getDeclaringClass()->getName()), + $params + ) + ) + ->filter(fn ($generator) => $generator instanceof StubFileGenerator) + ->each(fn (StubFileGenerator $generator) => $generator->generate()); + }; + } +} diff --git a/src/Tempest/Console/src/Stubs/ControllerStub.php b/src/Tempest/Console/src/Stubs/ControllerStub.php new file mode 100644 index 000000000..2bb13684a --- /dev/null +++ b/src/Tempest/Console/src/Stubs/ControllerStub.php @@ -0,0 +1,18 @@ +replace($inputClassName, '')->toString(); + $className = str($inputClassName) + ->pascal() + ->finish($classSuffix) + ->toString(); + + // Prepare the suggested path from the project namespace + $suggestedPath = str(PathHelper::make( + $this->composer->mainNamespace->path, + $pathPrefix, + $inputPath, + )) + ->finish(DIRECTORY_SEPARATOR) + ->append($className . '.php') + ->toString(); + + return $suggestedPath; + } + + /** + * Prompt the user for the target path to save the generated file. + * + * @param string $suggestedPath The suggested path to show to the user. + * + * @return string The target path that the user has chosen. + */ + protected function promptTargetPath(string $suggestedPath): string + { + $className = PathHelper::toClassName($suggestedPath); + + return $this->console->ask( + question : sprintf('Where do you want to save the file "%s"?', $className), + default : $suggestedPath, + validation: [new NotEmpty(), new EndsWith('.php')], + ); + } + + /** + * Ask the user if they want to override the file if it already exists. + * + * @param string $targetPath The target path to check for existence. + * + * @return bool Whether the user wants to override the file. + */ + protected function askForOverride(string $targetPath): bool + { + if (! file_exists($targetPath)) { + return false; + } + + return $this->console->confirm( + question: sprintf('The file "%s" already exists. Do you want to override it?', $targetPath), + default : false, + ); + } +} diff --git a/src/Tempest/Generation/src/StubFileGenerator.php b/src/Tempest/Generation/src/StubFileGenerator.php new file mode 100644 index 000000000..40ed42d06 --- /dev/null +++ b/src/Tempest/Generation/src/StubFileGenerator.php @@ -0,0 +1,107 @@ + $replacements An array of key-value pairs to replace in the stub file. + * The keys are the placeholders in the stub file (e.g. 'DummyNamespace') + * The values are the replacements for the placeholders (e.g. 'App\Models') + * @param bool $shouldOverride Whether the generator should override the file if it already exists. + */ + public function __construct( + private readonly string $targetPath, + private readonly string $stubFile, + private readonly array $replacements = [], + private readonly bool $shouldOverride = false, + ) { + $this->console = get(Console::class); + } + + public function generate(): void + { + if (! $this->prepareFilesystem()) { + $this->console->error('The operation has been aborted.'); + + return; + } + + if (! $this->writeFile()) { + $this->console->error('The file could not be written.'); + + return; + } + + $this->console->success(sprintf('File successfully created at "%s".', $this->targetPath)); + } + + /** + * Write the file to the target path. + * + * @return bool Whether the file was written successfully. + */ + private function writeFile(): bool + { + // Transform stub to class + $namespace = PathHelper::toRegisteredNamespace($this->targetPath); + $classname = PathHelper::toClassName($this->targetPath); + $classManipulator = (new ClassManipulator($this->stubFile)) + ->setNamespace($namespace) + ->setClassName($classname); + + foreach ($this->replacements as $placeholder => $replacement) { + if (! is_string($replacement)) { + continue; + } + + $classManipulator->manipulate(fn (StringHelper $code) => $code->replace($placeholder, $replacement)); + } + + // Write the file + return (bool) file_put_contents( + $this->targetPath, + $classManipulator->print() + ); + } + + /** + * Prepare the directory structure for the new file. + * It will delete the target file if it exists and we force the override. + * + * @return bool Whether the filesystem is ready to write the file. + */ + private function prepareFilesystem(): bool + { + // Delete the file if it exists and we force the override + if (file_exists($this->targetPath)) { + if (! $this->shouldOverride) { + return false; + } + + @unlink($this->targetPath); + } + + // Recursively create directories before writing the file + if (! file_exists(dirname($this->targetPath))) { + mkdir(dirname($this->targetPath), recursive: true); + } + + return true; + } +} diff --git a/src/Tempest/Generation/tests/ClassGeneratorTest.php b/src/Tempest/Generation/tests/ClassGeneratorTest.php index 0f6375a6e..3bd6da994 100644 --- a/src/Tempest/Generation/tests/ClassGeneratorTest.php +++ b/src/Tempest/Generation/tests/ClassGeneratorTest.php @@ -37,22 +37,6 @@ public function creates_class_from_scratch(): void $this->assertMatchesSnapshot($class->print()); } - #[Test] - public function creates_methods_with_parameters(): void - { - $class = new ClassGenerator('UserService', namespace: 'App\\Services'); - - $class->simplifyImplements(true); - $class->setFinal(); - $class->setReadOnly(); - - $class->addMethod('findById', body: << 'int'], returnType: '?App\\Models\\User'); - - $this->assertMatchesSnapshot($class->print()); - } - #[Test] public function simplifies_implements(): void { diff --git a/src/Tempest/Reflection/src/ClassReflector.php b/src/Tempest/Reflection/src/ClassReflector.php index 69d8077b0..a99dea704 100644 --- a/src/Tempest/Reflection/src/ClassReflector.php +++ b/src/Tempest/Reflection/src/ClassReflector.php @@ -72,6 +72,11 @@ public function getName(): string return $this->reflectionClass->getName(); } + public function getFilePath(): string|false + { + return $this->reflectionClass->getFileName(); + } + public function getShortName(): string { return $this->reflectionClass->getShortName(); diff --git a/src/Tempest/Reflection/src/TypeReflector.php b/src/Tempest/Reflection/src/TypeReflector.php index 3b6d5ad23..8fac9cf0c 100644 --- a/src/Tempest/Reflection/src/TypeReflector.php +++ b/src/Tempest/Reflection/src/TypeReflector.php @@ -42,7 +42,7 @@ private bool $isNullable; public function __construct( - private PHPReflector|PHPReflectionType|string $reflector, + private PHPReflector|PHPReflectionType|string|null $reflector, ) { $this->definition = $this->resolveDefinition($this->reflector); $this->isNullable = $this->resolveIsNullable($this->reflector); @@ -172,8 +172,12 @@ public function split(): array ); } - private function resolveDefinition(PHPReflector|PHPReflectionType|string $reflector): string + private function resolveDefinition(PHPReflector|PHPReflectionType|string|null $reflector): string { + if (is_null($reflector)) { + return 'null'; + } + if (is_string($reflector)) { return $reflector; } diff --git a/src/Tempest/Reflection/tests/ClassReflectorTest.php b/src/Tempest/Reflection/tests/ClassReflectorTest.php index c4d3905ea..d312c7839 100644 --- a/src/Tempest/Reflection/tests/ClassReflectorTest.php +++ b/src/Tempest/Reflection/tests/ClassReflectorTest.php @@ -38,6 +38,14 @@ public function test_getting_short_name(): void $this->assertSame($reflector->getShortName(), $reflection->getShortName()); } + public function test_getting_file_path(): void + { + $reflector = new ClassReflector(TestClassA::class); + $reflection = new ReflectionClass(TestClassA::class); + + $this->assertSame($reflector->getFilePath(), $reflection->getFileName()); + } + public function test_nullable_property_type(): void { $reflector = new ClassReflector(TestClassB::class); diff --git a/src/Tempest/Support/src/PathHelper.php b/src/Tempest/Support/src/PathHelper.php index c0cc9ce49..aad1ed611 100644 --- a/src/Tempest/Support/src/PathHelper.php +++ b/src/Tempest/Support/src/PathHelper.php @@ -4,8 +4,67 @@ namespace Tempest\Support; +use Exception; +use Tempest\Core\Composer; +use Tempest\Core\Kernel; +use function Tempest\get; + final readonly class PathHelper { + public static function root(string ...$paths): string + { + return static::make(get(Kernel::class)->root, ...$paths); + } + + private static function prepareStringForNamespace(string $path, string $root = ''): StringHelper + { + $normalized = str($path) + ->replaceStart($root, '') + ->replaceStart('/', '') + ->replace(['/', '//'], '\\'); + + // If the path is a to a PHP file, we exclude the file name. Otherwise, + // it's a path to a directory, which should be included in the namespace. + if ($normalized->endsWith('.php')) { + return $normalized->beforeLast(['/', '\\']); + } + + return $normalized; + } + + public static function toNamespace(string $path, string $root = ''): string + { + $path = static::prepareStringForNamespace($path, $root)->replaceEnd('\\', ''); + + return arr(explode('\\', (string) $path)) + ->map(fn (string $segment) => (string) str($segment)->pascal()) + ->implode('\\') + ->toString(); + } + + public static function toRegisteredNamespace(string $path): string + { + $composer = get(Composer::class); + $kernel = get(Kernel::class); + + $relativePath = static::prepareStringForNamespace($path, $kernel->root) + ->replaceEnd('\\', '') + ->replace('\\', '/') + ->finish('/'); + + foreach ($composer->namespaces as $namespace) { + if ($relativePath->startsWith($namespace->path)) { + return (string) $relativePath + ->replace($namespace->path, $namespace->namespace) + ->replace(['/', '//'], '\\') + ->replaceEnd('.php', '') + ->replaceEnd('\\', ''); + } + } + + throw new Exception(sprintf('No registered namespace matches the specified path [%s].', $path)); + } + /** * Returns a valid path from the specified portions. */ @@ -36,4 +95,20 @@ public static function make(string ...$paths): string return $path; } + + /** + * Convert a path to a class name. + * + * @param string $path The path to convert. + */ + public static function toClassName(string $path): string + { + return str($path) + ->replace(['/', '\\'], DIRECTORY_SEPARATOR) + ->replaceEnd(DIRECTORY_SEPARATOR, '') + ->replaceEnd('.php', '') + ->afterLast(DIRECTORY_SEPARATOR) + ->classBasename() + ->toString(); + } } diff --git a/tests/Integration/Framework/Commands/MigrateDownCommandTest.php b/tests/Integration/Framework/Commands/MigrateDownCommandTest.php index 5179fd583..ff1158aa8 100644 --- a/tests/Integration/Framework/Commands/MigrateDownCommandTest.php +++ b/tests/Integration/Framework/Commands/MigrateDownCommandTest.php @@ -4,6 +4,8 @@ namespace Tests\Tempest\Integration\Framework\Commands; +use Tempest\Database\DatabaseConfig; +use Tempest\Database\Migrations\CreateMigrationsTable; use Tempest\Database\Migrations\MigrationException; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -14,6 +16,8 @@ final class MigrateDownCommandTest extends FrameworkIntegrationTestCase { public function test_migrate_rollback_command(): void { + $this->container->get(DatabaseConfig::class)->addMigration(CreateMigrationsTable::class); + $this->console ->call('migrate:up --force') ->assertContains('create_migrations_table'); diff --git a/tests/Integration/Support/PathHelperTest.php b/tests/Integration/Support/PathHelperTest.php new file mode 100644 index 000000000..f380441ea --- /dev/null +++ b/tests/Integration/Support/PathHelperTest.php @@ -0,0 +1,48 @@ +assertSame('Tempest\\Auth', PathHelper::toRegisteredNamespace('src/Tempest/Auth/src/SomeNewClass.php')); + $this->assertSame('Tempest\\Auth\\SomeDirectory', PathHelper::toRegisteredNamespace('src/Tempest/Auth/src/SomeDirectory')); + $this->assertSame('Tempest\\Auth', PathHelper::toRegisteredNamespace($this->root.'/src/Tempest/Auth/src/SomeNewClass.php')); + $this->assertSame('Tempest\\Auth\\SomeDirectory', PathHelper::toRegisteredNamespace($this->root.'/src/Tempest/Auth/src/SomeDirectory')); + } + + #[Test] + public function paths_to_non_registered_namespace_throw(): void + { + $this->expectException(Exception::class); + PathHelper::toRegisteredNamespace('app/SomeNewClass.php'); + } + + #[Test] + public function path_to_namespace(): void + { + $this->assertSame('App', PathHelper::toNamespace('app/SomeNewClass.php')); + $this->assertSame('App\\Foo\\Bar', PathHelper::toNamespace('app/Foo/Bar/SomeNewClass.php')); + $this->assertSame('App\\Foo\\Bar\\Baz', PathHelper::toNamespace('app/Foo/Bar/Baz')); + $this->assertSame('App\\FooBar', PathHelper::toNamespace('app\\FooBar\\')); + $this->assertSame('App\\FooBar', PathHelper::toNamespace('app\\FooBar\\File.php')); + + $this->assertSame('App\\Foo', PathHelper::toNamespace('/home/project-name/app/Foo/Bar.php', root: '/home/project-name')); + $this->assertSame('App\\Foo', PathHelper::toNamespace('/home/project-name/app/Foo/Bar.php', root: '/home/project-name/')); + + // we don't support skill issues + $this->assertSame('Home\ProjectName\App\Foo', PathHelper::toNamespace('/home/project-name/app/Foo/Bar.php')); + } +}