Skip to content

Commit 0bdee91

Browse files
gturpin-devinnocenzibrendt
authored
feat(console): add make:controller and make:model commands (#647)
Co-authored-by: Enzo Innocenzi <[email protected]> Co-authored-by: Brent Roose <[email protected]>
1 parent a97014c commit 0bdee91

File tree

23 files changed

+1025
-70
lines changed

23 files changed

+1025
-70
lines changed

phpstan.neon.dist

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ parameters:
2525
path: src/Tempest/Console/src/Terminal/Terminal.php
2626
-
2727
message: '#.*uninitialized readonly property \$console*#'
28+
-
29+
message: '#.*uninitialized readonly property \$composer*#'
30+
-
31+
message: '#.*uninitialized readonly property \$stubFileGenerator*#'
2832

2933
disallowedFunctionCalls:
3034
-

src/Tempest/Auth/src/Install/Permission.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public function __construct(
1818
) {
1919
}
2020

21-
public function matches(string|UnitEnum|Permission $match): bool
21+
public function matches(string|UnitEnum|self $match): bool
2222
{
2323
$match = match(true) {
2424
is_string($match) => $match,

src/Tempest/Console/src/Components/Static/StaticTextBoxComponent.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public function render(Console $console): ?string
2121
return $this->default;
2222
}
2323

24-
$console->write("<question>{$this->label}</question> ");
24+
$console->write("<question>{$this->label}</question> ({$this->default})");
2525

2626
return trim($console->readln()) ?: $this->default;
2727
}

src/Tempest/Core/src/PublishesFiles.php

Lines changed: 125 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5,65 +5,110 @@
55
namespace Tempest\Core;
66

77
use Closure;
8-
use Nette\InvalidStateException;
98
use Tempest\Console\HasConsole;
9+
use Tempest\Container\Inject;
1010
use Tempest\Generation\ClassManipulator;
11-
use function Tempest\src_namespace;
12-
use function Tempest\src_path;
11+
use Tempest\Generation\DataObjects\StubFile;
12+
use Tempest\Generation\Enums\StubFileType;
13+
use Tempest\Generation\Exceptions\FileGenerationAbortedException;
14+
use Tempest\Generation\Exceptions\FileGenerationFailedException;
15+
use Tempest\Generation\StubFileGenerator;
16+
use Tempest\Support\PathHelper;
1317
use function Tempest\Support\str;
18+
use Tempest\Validation\Rules\EndsWith;
19+
use Tempest\Validation\Rules\NotEmpty;
20+
use Throwable;
1421

22+
/**
23+
* Provides a bunch of methods to publish and generate files and work with common user input.
24+
*/
1525
trait PublishesFiles
1626
{
1727
use HasConsole;
1828

29+
#[Inject]
30+
private readonly Composer $composer;
31+
32+
#[Inject]
33+
private readonly StubFileGenerator $stubFileGenerator;
34+
1935
private array $publishedFiles = [];
2036

2137
private array $publishedClasses = [];
2238

2339
/**
24-
* @param Closure(string $source, string $destination): void|null $callback
40+
* Publishes a file from a source to a destination.
41+
* @param string $source The path to the source file.
42+
* @param string $destination The path to the destination file.
43+
* @param Closure(string $source, string $destination): void|null $callback A callback to run after the file is published.
2544
*/
2645
public function publish(
2746
string $source,
2847
string $destination,
2948
?Closure $callback = null,
3049
): void {
31-
if (file_exists($destination)) {
32-
if (! $this->confirm(
33-
question: "{$destination} already exists Do you want to overwrite it?",
34-
)) {
35-
return;
36-
}
37-
} else {
38-
if (! $this->confirm(
39-
question: "Do you want to create {$destination}?",
50+
try {
51+
if (! $this->console->confirm(
52+
question: sprintf('Do you want to create "%s"', $destination),
4053
default: true,
4154
)) {
42-
$this->writeln('Skipped');
55+
throw new FileGenerationAbortedException('Skipped.');
56+
}
4357

44-
return;
58+
if (! $this->askForOverride($destination)) {
59+
throw new FileGenerationAbortedException('Skipped.');
4560
}
46-
}
4761

48-
$dir = pathinfo($destination, PATHINFO_DIRNAME);
62+
$stubFile = StubFile::from($source);
4963

50-
if (! is_dir($dir)) {
51-
mkdir($dir, recursive: true);
52-
}
64+
// Handle class files
65+
if ($stubFile->type === StubFileType::CLASS_FILE) {
66+
$oldClass = new ClassManipulator($source);
5367

54-
copy($source, $destination);
68+
$this->stubFileGenerator->generateClassFile(
69+
stubFile: $stubFile,
70+
targetPath: $destination,
71+
shouldOverride: true,
72+
manipulations: [
73+
fn (ClassManipulator $class) => $class->removeClassAttribute(DoNotDiscover::class),
74+
],
75+
);
5576

56-
$this->updateClass($destination);
77+
$newClass = new ClassManipulator($destination);
5778

58-
$this->publishedFiles[] = $destination;
79+
$this->publishedClasses[$oldClass->getClassName()] = $newClass->getClassName();
80+
}
5981

60-
if ($callback !== null) {
61-
$callback($source, $destination);
62-
}
82+
// Handle raw files
83+
if ($stubFile->type === StubFileType::RAW_FILE) {
84+
$this->stubFileGenerator->generateRawFile(
85+
stubFile: $stubFile,
86+
targetPath: $destination,
87+
shouldOverride: true,
88+
);
89+
}
90+
91+
$this->publishedFiles[] = $destination;
6392

64-
$this->success("{$destination} created");
93+
if ($callback !== null) {
94+
$callback($source, $destination);
95+
}
96+
97+
$this->console->success(sprintf('File successfully created at "%s".', $destination));
98+
} catch (FileGenerationAbortedException $exception) {
99+
$this->console->info($exception->getMessage());
100+
} catch (Throwable $throwable) {
101+
throw new FileGenerationFailedException(
102+
message: 'The file could not be published.',
103+
previous: $throwable,
104+
);
105+
}
65106
}
66107

108+
/**
109+
* Publishes the imports of the published classes.
110+
* Any published class that is imported in another published class will have its import updated.
111+
*/
67112
public function publishImports(): void
68113
{
69114
foreach ($this->publishedFiles as $file) {
@@ -77,30 +122,64 @@ public function publishImports(): void
77122
}
78123
}
79124

80-
private function updateClass(string $destination): void
125+
/**
126+
* Gets a suggested path for the given class name.
127+
* This will use the user's main namespace as the base path.
128+
* @param string $className The class name to generate the path for, can include path parts (e.g. 'Models/User').
129+
* @param string|null $pathPrefix The prefix to add to the path (e.g. 'Models').
130+
* @param string|null $classSuffix The suffix to add to the class name (e.g. 'Model').
131+
* @return string The fully suggested path including the filename and extension.
132+
*/
133+
public function getSuggestedPath(string $className, ?string $pathPrefix = null, ?string $classSuffix = null): string
81134
{
82-
try {
83-
$class = new ClassManipulator($destination);
84-
} catch (InvalidStateException) {
85-
return;
86-
}
135+
// Separate input path and classname
136+
$inputClassName = PathHelper::toClassName($className);
137+
$inputPath = str(PathHelper::make($className))->replaceLast($inputClassName, '')->toString();
138+
$className = str($inputClassName)
139+
->pascal()
140+
->finish($classSuffix ?? '')
141+
->toString();
87142

88-
$namespace = str($destination)
89-
->replaceStart(rtrim(src_path(), '/'), src_namespace())
90-
->replaceEnd('.php', '')
91-
->replace('/', '\\')
92-
->explode('\\')
93-
->pop($value)
94-
->implode('\\')
143+
// Prepare the suggested path from the project namespace
144+
return str(PathHelper::make(
145+
$this->composer->mainNamespace->path,
146+
$pathPrefix ?? '',
147+
$inputPath,
148+
))
149+
->finish('/')
150+
->append($className . '.php')
95151
->toString();
152+
}
153+
154+
/**
155+
* Prompt the user for the target path to save the generated file.
156+
* @param string $suggestedPath The suggested path to show to the user.
157+
* @return string The target path that the user has chosen.
158+
*/
159+
public function promptTargetPath(string $suggestedPath): string
160+
{
161+
$className = PathHelper::toClassName($suggestedPath);
96162

97-
$oldClassName = $class->getClassName();
163+
return $this->console->ask(
164+
question: sprintf('Where do you want to save the file "%s"?', $className),
165+
default: $suggestedPath,
166+
validation: [new NotEmpty(), new EndsWith('.php')],
167+
);
168+
}
98169

99-
$class
100-
->setNamespace($namespace)
101-
->removeClassAttribute(DoNotDiscover::class)
102-
->save($destination);
170+
/**
171+
* Ask the user if they want to override the file if it already exists.
172+
* @param string $targetPath The target path to check for existence.
173+
* @return bool Whether the user wants to override the file.
174+
*/
175+
public function askForOverride(string $targetPath): bool
176+
{
177+
if (! file_exists($targetPath)) {
178+
return true;
179+
}
103180

104-
$this->publishedClasses[$oldClassName] = $class->getClassName();
181+
return $this->console->confirm(
182+
question: sprintf('The file "%s" already exists. Do you want to override it?', $targetPath),
183+
);
105184
}
106185
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database\Commands;
6+
7+
use Tempest\Console\ConsoleArgument;
8+
use Tempest\Console\ConsoleCommand;
9+
use Tempest\Core\PublishesFiles;
10+
use Tempest\Database\Stubs\DatabaseModelStub;
11+
use Tempest\Generation\DataObjects\StubFile;
12+
use Tempest\Generation\Exceptions\FileGenerationAbortedException;
13+
use Tempest\Generation\Exceptions\FileGenerationFailedException;
14+
15+
final class MakeModelCommand
16+
{
17+
use PublishesFiles;
18+
19+
#[ConsoleCommand(
20+
name: 'make:model',
21+
description: 'Creates a new model class',
22+
aliases: ['model:make', 'model:create', 'create:model'],
23+
)]
24+
public function __invoke(
25+
#[ConsoleArgument(
26+
help: 'The name of the model class to create',
27+
)]
28+
string $className,
29+
): void {
30+
$suggestedPath = $this->getSuggestedPath($className);
31+
$targetPath = $this->promptTargetPath($suggestedPath);
32+
$shouldOverride = $this->askForOverride($targetPath);
33+
34+
try {
35+
36+
$this->stubFileGenerator->generateClassFile(
37+
stubFile: StubFile::from(DatabaseModelStub::class),
38+
targetPath: $targetPath,
39+
shouldOverride: $shouldOverride,
40+
);
41+
42+
$this->console->success(sprintf('File successfully created at "%s".', $targetPath));
43+
} catch (FileGenerationAbortedException|FileGenerationFailedException $e) {
44+
$this->console->error($e->getMessage());
45+
}
46+
}
47+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database\Stubs;
6+
7+
use Tempest\Database\DatabaseModel;
8+
use Tempest\Database\IsDatabaseModel;
9+
use Tempest\Validation\Rules\Length;
10+
11+
final class DatabaseModelStub implements DatabaseModel
12+
{
13+
use IsDatabaseModel;
14+
15+
public function __construct(
16+
#[Length(min: 1, max: 120)]
17+
public string $title
18+
) {
19+
}
20+
}

src/Tempest/Generation/src/ClassManipulator.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Nette\PhpGenerator\ClassType;
88
use Nette\PhpGenerator\PhpFile;
99
use ReflectionClass;
10+
use Tempest\Generation\Exceptions\FileGenerationFailedException;
1011

1112
final class ClassManipulator
1213
{
@@ -29,6 +30,13 @@ public function __construct(string|ReflectionClass $source)
2930
$this->namespace = $this->classType->getNamespace()->getName();
3031
}
3132

33+
/**
34+
* Save the class to a target file.
35+
*
36+
* @param string $path the path to save the class to.
37+
*
38+
* @throws FileGenerationFailedException if the file could not be written.
39+
*/
3240
public function save(string $path): self
3341
{
3442
$dir = pathinfo($path, PATHINFO_DIRNAME);
@@ -37,7 +45,11 @@ public function save(string $path): self
3745
mkdir($dir, recursive: true);
3846
}
3947

40-
file_put_contents($path, $this->print());
48+
$isSuccess = (bool) file_put_contents($path, $this->print());
49+
50+
if (! $isSuccess) {
51+
throw new FileGenerationFailedException(sprintf('The file "%s" could not be written.', $path));
52+
}
4153

4254
return $this;
4355
}

0 commit comments

Comments
 (0)