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'));
+ }
+}