diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 859dcbbc1..714593328 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -25,6 +25,10 @@ parameters: path: src/Tempest/Console/src/Terminal/Terminal.php - message: '#.*uninitialized readonly property \$console*#' + - + message: '#.*uninitialized readonly property \$composer*#' + - + message: '#.*uninitialized readonly property \$stubFileGenerator*#' disallowedFunctionCalls: - diff --git a/src/Tempest/Auth/src/Install/Permission.php b/src/Tempest/Auth/src/Install/Permission.php index 303aeaea7..e758fcd5b 100644 --- a/src/Tempest/Auth/src/Install/Permission.php +++ b/src/Tempest/Auth/src/Install/Permission.php @@ -18,7 +18,7 @@ public function __construct( ) { } - public function matches(string|UnitEnum|Permission $match): bool + public function matches(string|UnitEnum|self $match): bool { $match = match(true) { is_string($match) => $match, diff --git a/src/Tempest/Console/src/Components/Static/StaticTextBoxComponent.php b/src/Tempest/Console/src/Components/Static/StaticTextBoxComponent.php index 6fc502f9c..5a255572d 100644 --- a/src/Tempest/Console/src/Components/Static/StaticTextBoxComponent.php +++ b/src/Tempest/Console/src/Components/Static/StaticTextBoxComponent.php @@ -21,7 +21,7 @@ public function render(Console $console): ?string return $this->default; } - $console->write("{$this->label} "); + $console->write("{$this->label} ({$this->default})"); return trim($console->readln()) ?: $this->default; } diff --git a/src/Tempest/Core/src/PublishesFiles.php b/src/Tempest/Core/src/PublishesFiles.php index c9d640e2a..d5b6db3de 100644 --- a/src/Tempest/Core/src/PublishesFiles.php +++ b/src/Tempest/Core/src/PublishesFiles.php @@ -5,65 +5,110 @@ namespace Tempest\Core; use Closure; -use Nette\InvalidStateException; use Tempest\Console\HasConsole; +use Tempest\Container\Inject; use Tempest\Generation\ClassManipulator; -use function Tempest\src_namespace; -use function Tempest\src_path; +use Tempest\Generation\DataObjects\StubFile; +use Tempest\Generation\Enums\StubFileType; +use Tempest\Generation\Exceptions\FileGenerationAbortedException; +use Tempest\Generation\Exceptions\FileGenerationFailedException; +use Tempest\Generation\StubFileGenerator; +use Tempest\Support\PathHelper; use function Tempest\Support\str; +use Tempest\Validation\Rules\EndsWith; +use Tempest\Validation\Rules\NotEmpty; +use Throwable; +/** + * Provides a bunch of methods to publish and generate files and work with common user input. + */ trait PublishesFiles { use HasConsole; + #[Inject] + private readonly Composer $composer; + + #[Inject] + private readonly StubFileGenerator $stubFileGenerator; + private array $publishedFiles = []; private array $publishedClasses = []; /** - * @param Closure(string $source, string $destination): void|null $callback + * Publishes a file from a source to a destination. + * @param string $source The path to the source file. + * @param string $destination The path to the destination file. + * @param Closure(string $source, string $destination): void|null $callback A callback to run after the file is published. */ public function publish( string $source, string $destination, ?Closure $callback = null, ): void { - if (file_exists($destination)) { - if (! $this->confirm( - question: "{$destination} already exists Do you want to overwrite it?", - )) { - return; - } - } else { - if (! $this->confirm( - question: "Do you want to create {$destination}?", + try { + if (! $this->console->confirm( + question: sprintf('Do you want to create "%s"', $destination), default: true, )) { - $this->writeln('Skipped'); + throw new FileGenerationAbortedException('Skipped.'); + } - return; + if (! $this->askForOverride($destination)) { + throw new FileGenerationAbortedException('Skipped.'); } - } - $dir = pathinfo($destination, PATHINFO_DIRNAME); + $stubFile = StubFile::from($source); - if (! is_dir($dir)) { - mkdir($dir, recursive: true); - } + // Handle class files + if ($stubFile->type === StubFileType::CLASS_FILE) { + $oldClass = new ClassManipulator($source); - copy($source, $destination); + $this->stubFileGenerator->generateClassFile( + stubFile: $stubFile, + targetPath: $destination, + shouldOverride: true, + manipulations: [ + fn (ClassManipulator $class) => $class->removeClassAttribute(DoNotDiscover::class), + ], + ); - $this->updateClass($destination); + $newClass = new ClassManipulator($destination); - $this->publishedFiles[] = $destination; + $this->publishedClasses[$oldClass->getClassName()] = $newClass->getClassName(); + } - if ($callback !== null) { - $callback($source, $destination); - } + // Handle raw files + if ($stubFile->type === StubFileType::RAW_FILE) { + $this->stubFileGenerator->generateRawFile( + stubFile: $stubFile, + targetPath: $destination, + shouldOverride: true, + ); + } + + $this->publishedFiles[] = $destination; - $this->success("{$destination} created"); + if ($callback !== null) { + $callback($source, $destination); + } + + $this->console->success(sprintf('File successfully created at "%s".', $destination)); + } catch (FileGenerationAbortedException $exception) { + $this->console->info($exception->getMessage()); + } catch (Throwable $throwable) { + throw new FileGenerationFailedException( + message: 'The file could not be published.', + previous: $throwable, + ); + } } + /** + * Publishes the imports of the published classes. + * Any published class that is imported in another published class will have its import updated. + */ public function publishImports(): void { foreach ($this->publishedFiles as $file) { @@ -77,30 +122,64 @@ public function publishImports(): void } } - private function updateClass(string $destination): void + /** + * Gets a suggested path for the given class name. + * This will use the user's main namespace as the base path. + * @param string $className The class name to generate the path for, can include path parts (e.g. 'Models/User'). + * @param string|null $pathPrefix The prefix to add to the path (e.g. 'Models'). + * @param string|null $classSuffix The suffix to add to the class name (e.g. 'Model'). + * @return string The fully suggested path including the filename and extension. + */ + public function getSuggestedPath(string $className, ?string $pathPrefix = null, ?string $classSuffix = null): string { - try { - $class = new ClassManipulator($destination); - } catch (InvalidStateException) { - return; - } + // Separate input path and classname + $inputClassName = PathHelper::toClassName($className); + $inputPath = str(PathHelper::make($className))->replaceLast($inputClassName, '')->toString(); + $className = str($inputClassName) + ->pascal() + ->finish($classSuffix ?? '') + ->toString(); - $namespace = str($destination) - ->replaceStart(rtrim(src_path(), '/'), src_namespace()) - ->replaceEnd('.php', '') - ->replace('/', '\\') - ->explode('\\') - ->pop($value) - ->implode('\\') + // Prepare the suggested path from the project namespace + return str(PathHelper::make( + $this->composer->mainNamespace->path, + $pathPrefix ?? '', + $inputPath, + )) + ->finish('/') + ->append($className . '.php') ->toString(); + } + + /** + * 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. + */ + public function promptTargetPath(string $suggestedPath): string + { + $className = PathHelper::toClassName($suggestedPath); - $oldClassName = $class->getClassName(); + return $this->console->ask( + question: sprintf('Where do you want to save the file "%s"?', $className), + default: $suggestedPath, + validation: [new NotEmpty(), new EndsWith('.php')], + ); + } - $class - ->setNamespace($namespace) - ->removeClassAttribute(DoNotDiscover::class) - ->save($destination); + /** + * 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. + */ + public function askForOverride(string $targetPath): bool + { + if (! file_exists($targetPath)) { + return true; + } - $this->publishedClasses[$oldClassName] = $class->getClassName(); + return $this->console->confirm( + question: sprintf('The file "%s" already exists. Do you want to override it?', $targetPath), + ); } } diff --git a/src/Tempest/Database/src/Commands/MakeModelCommand.php b/src/Tempest/Database/src/Commands/MakeModelCommand.php new file mode 100644 index 000000000..c28ec57f4 --- /dev/null +++ b/src/Tempest/Database/src/Commands/MakeModelCommand.php @@ -0,0 +1,47 @@ +getSuggestedPath($className); + $targetPath = $this->promptTargetPath($suggestedPath); + $shouldOverride = $this->askForOverride($targetPath); + + try { + + $this->stubFileGenerator->generateClassFile( + stubFile: StubFile::from(DatabaseModelStub::class), + targetPath: $targetPath, + shouldOverride: $shouldOverride, + ); + + $this->console->success(sprintf('File successfully created at "%s".', $targetPath)); + } catch (FileGenerationAbortedException|FileGenerationFailedException $e) { + $this->console->error($e->getMessage()); + } + } +} diff --git a/src/Tempest/Database/src/Stubs/DatabaseModelStub.php b/src/Tempest/Database/src/Stubs/DatabaseModelStub.php new file mode 100644 index 000000000..3fb93a350 --- /dev/null +++ b/src/Tempest/Database/src/Stubs/DatabaseModelStub.php @@ -0,0 +1,20 @@ +namespace = $this->classType->getNamespace()->getName(); } + /** + * Save the class to a target file. + * + * @param string $path the path to save the class to. + * + * @throws FileGenerationFailedException if the file could not be written. + */ public function save(string $path): self { $dir = pathinfo($path, PATHINFO_DIRNAME); @@ -37,7 +45,11 @@ public function save(string $path): self mkdir($dir, recursive: true); } - file_put_contents($path, $this->print()); + $isSuccess = (bool) file_put_contents($path, $this->print()); + + if (! $isSuccess) { + throw new FileGenerationFailedException(sprintf('The file "%s" could not be written.', $path)); + } return $this; } diff --git a/src/Tempest/Generation/src/DataObjects/StubFile.php b/src/Tempest/Generation/src/DataObjects/StubFile.php new file mode 100644 index 000000000..a928c54c0 --- /dev/null +++ b/src/Tempest/Generation/src/DataObjects/StubFile.php @@ -0,0 +1,46 @@ + $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 array $manipulations An array of manipulations to apply to the generated class. + * + * @throws FileGenerationFailedException + */ + public function generateClassFile( + StubFile $stubFile, + string $targetPath, + bool $shouldOverride = false, + array $replacements = [], + array $manipulations = [], + ): void { + try { + if ($stubFile->type !== StubFileType::CLASS_FILE) { + throw new FileGenerationFailedException(sprintf('The stub file must be of type CLASS_FILE, "%s" given.', $stubFile->type->name)); + } + + if (file_exists($targetPath) && ! $shouldOverride) { + throw new FileGenerationAbortedException(sprintf('The file "%s" already exists and the operation has been aborted.', $targetPath)); + } + + $this->prepareFilesystem($targetPath); + + // Transform stub to class + $namespace = PathHelper::toMainNamespace($targetPath); + $classname = PathHelper::toClassName($targetPath); + $classManipulator = (new ClassManipulator($stubFile->filePath)) + ->setNamespace($namespace) + ->setClassName($classname); + + foreach ($replacements as $placeholder => $replacement) { + if (! is_string($replacement)) { + continue; + } + + $classManipulator->manipulate(fn (StringHelper $code) => $code->replace($placeholder, $replacement)); + } + + // Run all manipulations + $classManipulator = array_reduce( + array: $manipulations, + initial: $classManipulator, + callback: fn (ClassManipulator $manipulator, Closure $manipulation) => $manipulation($manipulator) + ); + + if (file_exists($targetPath) && $shouldOverride) { + @unlink($targetPath); + } + + $classManipulator->save($targetPath); + } catch (Throwable $throwable) { + throw new FileGenerationFailedException(sprintf('The file could not be written. %s', $throwable->getMessage())); + } + } + + /** + * @param StubFile $stubFile The stub file to use for the generation. It must be of type RAW_FILE. + * @param string $targetPath The path where the generated file will be saved including the filename and extension. + * @param bool $shouldOverride Whether the generator should override the file if it already exists. + * @param array $replacements An array of key-value pairs to replace in the stub file. + * The keys are the placeholders in the stub file (e.g. 'dummy-content') + * The values are the replacements for the placeholders (e.g. 'real content') + * + * @param array $manipulations An array of manipulations to apply to the generated file raw content. + * + * @throws FileGenerationFailedException + */ + public function generateRawFile( + StubFile $stubFile, + string $targetPath, + bool $shouldOverride = false, + array $replacements = [], + array $manipulations = [], + ): void { + try { + if ($stubFile->type !== StubFileType::RAW_FILE) { + throw new FileGenerationFailedException(sprintf('The stub file must be of type RAW_FILE, "%s" given.', $stubFile->type->name)); + } + + if (file_exists($targetPath) && ! $shouldOverride) { + throw new FileGenerationAbortedException(sprintf('The file "%s" already exists and the operation has been aborted.', $targetPath)); + } + + $this->prepareFilesystem($targetPath); + $fileContent = file_get_contents($stubFile->filePath); + + foreach ($replacements as $placeholder => $replacement) { + if (! is_string($replacement)) { + continue; + } + + $fileContent = str($fileContent)->replace($placeholder, $replacement); + } + + // Run all manipulations + $fileContent = array_reduce( + array: $manipulations, + initial: $fileContent, + callback: fn (StringHelper $content, Closure $manipulation) => $manipulation($content) + ); + + if (file_exists($targetPath) && $shouldOverride) { + @unlink($targetPath); + } + + file_put_contents($targetPath, $fileContent); + } catch (Throwable $throwable) { + throw new FileGenerationFailedException(sprintf('The file could not be written. %s', $throwable->getMessage())); + } + } + + /** + * Prepare the directory structure for the new file. + * It will delete the target file if it exists and we force the override. + * + * @param string $targetPath The path where the generated file will be saved including the filename and extension. + */ + private function prepareFilesystem(string $targetPath): void + { + // Recursively create directories before writing the file + $directory = dirname($targetPath); + if (! is_dir($directory)) { + mkdir($directory, recursive: true); + } + } +} diff --git a/src/Tempest/Http/src/Commands/MakeControllerCommand.php b/src/Tempest/Http/src/Commands/MakeControllerCommand.php new file mode 100644 index 000000000..1375881f2 --- /dev/null +++ b/src/Tempest/Http/src/Commands/MakeControllerCommand.php @@ -0,0 +1,58 @@ +getSuggestedPath($className); + $targetPath = $this->promptTargetPath($suggestedPath); + $shouldOverride = $this->askForOverride($targetPath); + + try { + $this->stubFileGenerator->generateClassFile( + stubFile: StubFile::from(ControllerStub::class), + targetPath: $targetPath, + shouldOverride: $shouldOverride, + replacements: [ + 'dummy-path' => $controllerPath, + 'dummy-view' => $controllerView, + ], + ); + + $this->success(sprintf('File successfully created at "%s".', $targetPath)); + } catch (FileGenerationAbortedException|FileGenerationFailedException $e) { + $this->error($e->getMessage()); + } + } +} diff --git a/src/Tempest/Http/src/Stubs/ControllerStub.php b/src/Tempest/Http/src/Stubs/ControllerStub.php new file mode 100644 index 000000000..ebd05b942 --- /dev/null +++ b/src/Tempest/Http/src/Stubs/ControllerStub.php @@ -0,0 +1,18 @@ +reflectionClass->getShortName(); } + public function getFileName(): string + { + return $this->reflectionClass->getFileName(); + } + public function getType(): TypeReflector { return new TypeReflector($this->reflectionClass); diff --git a/src/Tempest/Support/src/PathHelper.php b/src/Tempest/Support/src/PathHelper.php index c0cc9ce49..428710184 100644 --- a/src/Tempest/Support/src/PathHelper.php +++ b/src/Tempest/Support/src/PathHelper.php @@ -4,6 +4,13 @@ namespace Tempest\Support; +use Exception; +use Tempest\Core\Composer; +use Tempest\Core\Kernel; +use function Tempest\get; +use function Tempest\src_namespace; +use function Tempest\src_path; + final readonly class PathHelper { /** @@ -20,20 +27,95 @@ public static function make(string ...$paths): string $parts = array_filter($parts); // Glue parts together - $path = implode(DIRECTORY_SEPARATOR, $parts); + $path = implode('/', $parts); - // Add DIRECTORY_SEPARATOR if first entry starts with forward- or backward slash + // Add / if first entry starts with forward- or backward slash $firstEntry = $paths[0]; if (str_starts_with($firstEntry, '/') || str_starts_with($firstEntry, '\\')) { - $path = DIRECTORY_SEPARATOR . $path; + $path = '/' . $path; } - // Add DIRECTORY_SEPARATOR if last entry ends with forward- or backward slash + // Add / if last entry ends with forward- or backward slash $lastEntry = $paths[count($paths) - 1]; if ((count($paths) > 1 || strlen($lastEntry) > 1) && (str_ends_with($lastEntry, '/') || str_ends_with($lastEntry, '\\'))) { - $path .= DIRECTORY_SEPARATOR; + $path .= '/'; } return $path; } + + 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 toMainNamespace(string $path): string + { + return self::toNamespace( + src_namespace() . '/' . str($path) + ->replaceStart(src_path(), '') + ->trim('/') + ->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)); + } + + /** + * 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(['/', '\\'], '/') + ->replaceEnd('/', '') + ->replaceEnd('.php', '') + ->afterLast('/') + ->classBasename() + ->toString(); + } } diff --git a/src/Tempest/Support/tests/PathHelperTest.php b/src/Tempest/Support/tests/PathHelperTest.php index 1adff1f05..ba2046804 100644 --- a/src/Tempest/Support/tests/PathHelperTest.php +++ b/src/Tempest/Support/tests/PathHelperTest.php @@ -6,6 +6,7 @@ use Generator; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Tempest\Support\PathHelper; @@ -28,42 +29,42 @@ public static function paths(): Generator { yield 'single path' => [ 'paths' => ['/foo/'], - 'expected' => DIRECTORY_SEPARATOR . 'foo' . DIRECTORY_SEPARATOR, + 'expected' => '/foo/', ]; yield 'single path with forward slash' => [ 'paths' => ['/foo/bar/'], - 'expected' => DIRECTORY_SEPARATOR . 'foo' . DIRECTORY_SEPARATOR . 'bar' . DIRECTORY_SEPARATOR, + 'expected' => '/foo/bar/', ]; yield 'single path with backward slash' => [ 'paths' => ['\\foo\\bar\\'], - 'expected' => DIRECTORY_SEPARATOR . 'foo' . DIRECTORY_SEPARATOR . 'bar' . DIRECTORY_SEPARATOR, + 'expected' => '/foo/bar/', ]; yield 'multiple paths' => [ 'paths' => ['foo', 'bar'], - 'expected' => 'foo' . DIRECTORY_SEPARATOR . 'bar', + 'expected' => 'foo/bar', ]; yield 'multiple paths with forward slash' => [ 'paths' => ['/foo/bar/', '/baz/qux/'], - 'expected' => DIRECTORY_SEPARATOR . 'foo' . DIRECTORY_SEPARATOR . 'bar' . DIRECTORY_SEPARATOR . 'baz' . DIRECTORY_SEPARATOR . 'qux' . DIRECTORY_SEPARATOR, + 'expected' => '/foo/bar/baz/qux/', ]; yield 'multiple paths with backward slash' => [ 'paths' => ['\\foo\\bar\\', '\\baz\\qux\\'], - 'expected' => DIRECTORY_SEPARATOR . 'foo' . DIRECTORY_SEPARATOR . 'bar' . DIRECTORY_SEPARATOR . 'baz' . DIRECTORY_SEPARATOR . 'qux' . DIRECTORY_SEPARATOR, + 'expected' => '/foo/bar/baz/qux/', ]; yield 'single foward slash' => [ 'paths' => ['/'], - 'expected' => DIRECTORY_SEPARATOR, + 'expected' => '/', ]; yield 'single backward slash' => [ 'paths' => ['\\'], - 'expected' => DIRECTORY_SEPARATOR, + 'expected' => '/', ]; yield 'no slash' => [ @@ -73,42 +74,62 @@ public static function paths(): Generator yield 'starts with forward slash' => [ 'paths' => ['/foo'], - 'expected' => DIRECTORY_SEPARATOR . 'foo', + 'expected' => '/foo', ]; yield 'starts with backward slash' => [ 'paths' => ['\\foo'], - 'expected' => DIRECTORY_SEPARATOR . 'foo', + 'expected' => '/foo', ]; yield 'ends with forward slash' => [ 'paths' => ['foo/'], - 'expected' => 'foo' . DIRECTORY_SEPARATOR, + 'expected' => 'foo/', ]; yield 'ends with backward slash' => [ 'paths' => ['foo\\'], - 'expected' => 'foo' . DIRECTORY_SEPARATOR, + 'expected' => 'foo/', ]; yield 'first path is forward slash' => [ 'paths' => ['/', '/foo'], - 'expected' => DIRECTORY_SEPARATOR . 'foo', + 'expected' => '/foo', ]; yield 'first path is backward slash' => [ 'paths' => ['\\', '\\foo'], - 'expected' => DIRECTORY_SEPARATOR . 'foo', + 'expected' => '/foo', ]; yield 'last path is forward slash' => [ 'paths' => ['foo/', '/'], - 'expected' => 'foo' . DIRECTORY_SEPARATOR, + 'expected' => 'foo/', ]; yield 'last path is backward slash' => [ 'paths' => ['foo\\', '\\'], - 'expected' => 'foo' . DIRECTORY_SEPARATOR, + 'expected' => 'foo/', + ]; + } + + #[Test] + #[DataProvider('toClassNameProvider')] + public function toClassName(string $path, string $expected): void + { + $this->assertSame( + actual: PathHelper::toClassName($path), + expected: $expected, + ); + } + + public static function toClassNameProvider(): array + { + return [ + 'single path' => ['/Foo/Bar', 'Bar'], + 'single path end with forward slash' => ['Foo/Bar/', 'Bar'], + 'single path end with backward slash' => ['Foo/Bar\\', 'Bar'], + 'path with extension' => ['Foo/Bar.php', 'Bar'], ]; } } diff --git a/tests/Fixtures/Core/PublishesFilesConcreteClass.php b/tests/Fixtures/Core/PublishesFilesConcreteClass.php new file mode 100644 index 000000000..8f7691dfb --- /dev/null +++ b/tests/Fixtures/Core/PublishesFilesConcreteClass.php @@ -0,0 +1,12 @@ +installer->configure( + __DIR__ . '/install', + new ComposerNamespace('App\\', __DIR__ . '/install/App') + ); + } + + protected function tearDown(): void + { + $this->installer->clean(); + + parent::tearDown(); + } + + #[Test] + #[DataProvider('suggested_path_provider')] + public function get_suggested_path( + string $className, + ?string $pathPrefix, + ?string $classSuffix, + string $expected + ): void { + $composer = $this->container->get(Composer::class); + $concreteClass = $this->container->get(PublishesFilesConcreteClass::class); + $appPath = str_replace('\\', '/', $composer->mainNamespace->path); // Normalize windows path + + $this->assertSame( + actual: $concreteClass->getSuggestedPath( + className: $className, + pathPrefix: $pathPrefix, + classSuffix: $classSuffix + ), + expected: path($appPath, $expected) + ); + } + + public static function suggested_path_provider(): array + { + return [ + 'Basic' => [ + 'className' => 'Hello', + 'pathPrefix' => null, + 'classSuffix' => null, + 'expected' => 'Hello.php', + ], + 'Path prefix' => [ + 'className' => 'Hello', + 'pathPrefix' => 'World', + 'classSuffix' => null, + 'expected' => 'World/Hello.php', + ], + 'Class suffix' => [ + 'className' => 'Hello', + 'pathPrefix' => null, + 'classSuffix' => 'World', + 'expected' => 'HelloWorld.php', + ], + 'Path prefix and class suffix' => [ + 'className' => 'Hello', + 'pathPrefix' => 'World', + 'classSuffix' => 'Universe', + 'expected' => 'World/HelloUniverse.php', + ], + 'Class suffix duplicated in input' => [ + 'className' => 'HelloWorld', + 'pathPrefix' => null, + 'classSuffix' => 'World', + 'expected' => 'HelloWorld.php', + ], + 'Class suffix duplicated in input and path prefix' => [ + 'className' => 'HelloWorld', + 'pathPrefix' => 'World', + 'classSuffix' => 'World', + 'expected' => 'World/HelloWorld.php', + ], + 'ClassName with multiple parts' => [ + 'className' => 'Hello/World', + 'pathPrefix' => null, + 'classSuffix' => null, + 'expected' => 'Hello/World.php', + ], + 'ClassName with multiple parts and path prefix' => [ + 'className' => 'Hello/World', + 'pathPrefix' => 'Universe', + 'classSuffix' => null, + 'expected' => 'Universe/Hello/World.php', + ], + 'ClassName with multiple parts and class suffix' => [ + 'className' => 'Hello/World', + 'pathPrefix' => null, + 'classSuffix' => 'Universe', + 'expected' => 'Hello/WorldUniverse.php', + ], + 'ClassName with multiple parts, path prefix and class suffix' => [ + 'className' => 'Hello/World', + 'pathPrefix' => 'Universe', + 'classSuffix' => 'Galaxy', + 'expected' => 'Universe/Hello/WorldGalaxy.php', + ], + 'ClassName with multiple parts namespaced' => [ + 'className' => 'Hello\\World', + 'pathPrefix' => null, + 'classSuffix' => null, + 'expected' => 'Hello/World.php', + ], + 'ClassName with multiple parts namespaced and path prefix' => [ + 'className' => 'Hello\\World', + 'pathPrefix' => 'Universe', + 'classSuffix' => null, + 'expected' => 'Universe/Hello/World.php', + ], + 'ClassName with multiple parts namespaced and class suffix' => [ + 'className' => 'Hello\\World', + 'pathPrefix' => null, + 'classSuffix' => 'Universe', + 'expected' => 'Hello/WorldUniverse.php', + ], + 'ClassName with multiple parts namespaced, path prefix and class suffix' => [ + 'className' => 'Hello\\World', + 'pathPrefix' => 'Universe', + 'classSuffix' => 'Galaxy', + 'expected' => 'Universe/Hello/WorldGalaxy.php', + ], + 'ClassName with multiple parts and classname contained in path' => [ + 'className' => 'Books/Book', + 'pathPrefix' => null, + 'classSuffix' => null, + 'expected' => 'Books/Book.php', + ], + ]; + } +} diff --git a/tests/Integration/Database/Commands/MakeModelCommandTest.php b/tests/Integration/Database/Commands/MakeModelCommandTest.php new file mode 100644 index 000000000..bfb44b072 --- /dev/null +++ b/tests/Integration/Database/Commands/MakeModelCommandTest.php @@ -0,0 +1,70 @@ +installer->configure( + __DIR__ . '/install', + new ComposerNamespace('App\\', __DIR__ . '/install/App') + ); + } + + protected function tearDown(): void + { + $this->installer->clean(); + + parent::tearDown(); + } + + #[Test] + #[DataProvider('command_input_provider')] + public function make_command( + string $commandArgs, + string $expectedPath, + string $expectedNamespace + ): void { + $this->console + ->call("make:model {$commandArgs}") + ->submit(); + + $this->installer + ->assertFileExists($expectedPath) + ->assertFileContains($expectedPath, 'namespace ' . $expectedNamespace . ';'); + } + + public static function command_input_provider(): array + { + return [ + 'make_with_defaults' => [ + 'commandArgs' => 'Book', + 'expectedPath' => 'App/Book.php', + 'expectedNamespace' => 'App', + ], + 'make_with_other_namespace' => [ + 'commandArgs' => 'Books\\BookModel', + 'expectedPath' => 'App/Books/BookModel.php', + 'expectedNamespace' => 'App\\Books', + ], + 'make_with_input_path' => [ + 'commandArgs' => 'Books/BookModel', + 'expectedPath' => 'App/Books/BookModel.php', + 'expectedNamespace' => 'App\\Books', + ], + ]; + } +} diff --git a/tests/Integration/Http/MakeControllerCommandTest.php b/tests/Integration/Http/MakeControllerCommandTest.php new file mode 100644 index 000000000..0f8c87956 --- /dev/null +++ b/tests/Integration/Http/MakeControllerCommandTest.php @@ -0,0 +1,70 @@ +installer->configure( + __DIR__ . '/install', + new ComposerNamespace('App\\', __DIR__ . '/install/App') + ); + } + + protected function tearDown(): void + { + $this->installer->clean(); + + parent::tearDown(); + } + + #[Test] + #[DataProvider('command_input_provider')] + public function make_command( + string $commandArgs, + string $expectedPath, + string $expectedNamespace + ): void { + $this->console + ->call("make:controller {$commandArgs}") + ->submit(); + + $this->installer + ->assertFileExists($expectedPath) + ->assertFileContains($expectedPath, 'namespace ' . $expectedNamespace . ';'); + } + + public static function command_input_provider(): array + { + return [ + 'make_with_defaults' => [ + 'commandArgs' => 'BookController', + 'expectedPath' => 'App/BookController.php', + 'expectedNamespace' => 'App', + ], + 'make_with_other_namespace' => [ + 'commandArgs' => 'Books\\BookController', + 'expectedPath' => 'App/Books/BookController.php', + 'expectedNamespace' => 'App\\Books', + ], + 'make_with_input_path' => [ + 'commandArgs' => 'Books/BookController', + 'expectedPath' => 'App/Books/BookController.php', + 'expectedNamespace' => 'App\\Books', + ], + ]; + } +} 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')); + } +}