• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

tempestphp / tempest-framework / 14346656354

08 Apr 2025 01:08PM UTC coverage: 81.323% (+0.06%) from 81.259%
14346656354

push

github

web-flow
ci: fix isolated testing

11491 of 14130 relevant lines covered (81.32%)

105.9 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

52.34
/src/Tempest/Core/src/PublishesFiles.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Tempest\Core;
6

7
use Closure;
8
use Exception;
9
use Tempest\Console\Exceptions\ConsoleException;
10
use Tempest\Console\HasConsole;
11
use Tempest\Container\Inject;
12
use Tempest\Discovery\DoNotDiscover;
13
use Tempest\Generation\ClassManipulator;
14
use Tempest\Generation\DataObjects\StubFile;
15
use Tempest\Generation\Enums\StubFileType;
16
use Tempest\Generation\Exceptions\FileGenerationAbortedException;
17
use Tempest\Generation\Exceptions\FileGenerationFailedException;
18
use Tempest\Generation\StubFileGenerator;
19
use Tempest\Reflection\FunctionReflector;
20
use Tempest\Support\Str\ImmutableString;
21
use Tempest\Validation\Rules\EndsWith;
22
use Tempest\Validation\Rules\NotEmpty;
23
use Throwable;
24

25
use function strlen;
26
use function Tempest\root_path;
27
use function Tempest\src_path;
28
use function Tempest\Support\Namespace\to_base_class_name;
29
use function Tempest\Support\path;
30
use function Tempest\Support\Path\to_absolute_path;
31
use function Tempest\Support\Path\to_relative_path;
32
use function Tempest\Support\str;
33
use function Tempest\Support\Str\class_basename;
34

35
use const JSON_PRETTY_PRINT;
36
use const JSON_UNESCAPED_SLASHES;
37

38
/**
39
 * Provides a bunch of methods to publish and generate files and work with common user input.
40
 */
41
trait PublishesFiles
42
{
43
    use HasConsole;
44

45
    #[Inject]
46
    private readonly Composer $composer;
47

48
    #[Inject]
49
    private readonly StubFileGenerator $stubFileGenerator;
50

51
    private array $publishedFiles = [];
52

53
    private array $publishedClasses = [];
54

55
    /**
56
     * Publishes a file from a source to a destination.
57
     * @param string $source The path to the source file.
58
     * @param string $destination The path to the destination file.
59
     * @param Closure(string $source, string $destination): void|null $callback A callback to run after the file is published.
60
     */
61
    public function publish(string $source, string $destination, ?Closure $callback = null): string|false
5✔
62
    {
63
        try {
64
            if (! $this->console->confirm(
5✔
65
                question: sprintf('Do you want to create <file="%s" />?', $this->friendlyFileName($destination)),
5✔
66
                default: true,
5✔
67
            )) {
5✔
68
                throw new FileGenerationAbortedException('Skipped.');
×
69
            }
70

71
            if (! $this->askForOverride($destination)) {
4✔
72
                throw new FileGenerationAbortedException('Skipped.');
×
73
            }
74

75
            $stubFile = StubFile::from($source);
4✔
76

77
            // Handle class files
78
            if ($stubFile->type === StubFileType::CLASS_FILE) {
4✔
79
                $oldClass = new ClassManipulator($source);
2✔
80

81
                $this->stubFileGenerator->generateClassFile(
2✔
82
                    stubFile: $stubFile,
2✔
83
                    targetPath: $destination,
2✔
84
                    shouldOverride: true,
2✔
85
                    manipulations: [
2✔
86
                        fn (ClassManipulator $class) => $class->removeClassAttribute(DoNotDiscover::class),
2✔
87
                    ],
2✔
88
                );
2✔
89

90
                $newClass = new ClassManipulator($destination);
2✔
91

92
                $this->publishedClasses[$oldClass->getClassName()] = $newClass->getClassName();
2✔
93
            }
94

95
            // Handle raw files
96
            if ($stubFile->type === StubFileType::RAW_FILE) {
4✔
97
                $this->stubFileGenerator->generateRawFile(
3✔
98
                    stubFile: $stubFile,
3✔
99
                    targetPath: $destination,
3✔
100
                    shouldOverride: true,
3✔
101
                );
3✔
102
            }
103

104
            $this->publishedFiles[] = $destination;
4✔
105

106
            if ($callback !== null) {
4✔
107
                $callback($source, $destination);
2✔
108
            }
109

110
            return $destination;
4✔
111
        } catch (FileGenerationAbortedException) {
×
112
            return false;
×
113
        } catch (Throwable $throwable) {
×
114
            if ($throwable instanceof ConsoleException) {
×
115
                throw $throwable;
×
116
            }
117

118
            throw new FileGenerationFailedException(
×
119
                message: 'The file could not be published.',
×
120
                previous: $throwable,
×
121
            );
×
122
        }
123
    }
124

125
    /**
126
     * Publishes the imports of the published classes.
127
     * Any published class that is imported in another published class will have its import updated.
128
     */
129
    public function publishImports(): void
1✔
130
    {
131
        foreach ($this->publishedFiles as $file) {
1✔
132
            $contents = str(file_get_contents($file));
1✔
133

134
            foreach ($this->publishedClasses as $old => $new) {
1✔
135
                $contents = $contents->replace($old, $new);
1✔
136
            }
137

138
            file_put_contents($file, $contents);
1✔
139
        }
140
    }
141

142
    /**
143
     * Gets a suggested path for the given class name.
144
     * This will use the user's main namespace as the base path.
145
     * @param string $className The class name to generate the path for, can include path parts (e.g. 'Models/User').
146
     * @param string|null $pathPrefix The prefix to add to the path (e.g. 'Models').
147
     * @param string|null $classSuffix The suffix to add to the class name (e.g. 'Model').
148
     * @return string The fully suggested path including the filename and extension.
149
     */
150
    public function getSuggestedPath(string $className, ?string $pathPrefix = null, ?string $classSuffix = null): string
61✔
151
    {
152
        // Separate input path and classname
153
        $inputClassName = to_base_class_name($className);
61✔
154
        $inputPath = path($className)->stripEnd(class_basename($className));
61✔
155
        $className = str($inputClassName)
61✔
156
            ->pascal()
61✔
157
            ->finish($classSuffix ?? '')
61✔
158
            ->toString();
61✔
159

160
        // Prepare the suggested path from the project namespace
161
        return src_path($pathPrefix ?? '', $inputPath, $className . '.php');
61✔
162
    }
163

164
    /**
165
     * Prompt the user for the target path to save the generated file.
166
     * @param string $suggestedPath The suggested path to show to the user.
167
     * @param \Tempest\Validation\Rule[]|null $rules Rules to use instead of the default ones.
168
     *
169
     * @return string The target path that the user has chosen.
170
     */
171
    public function promptTargetPath(string $suggestedPath, ?array $rules = null): string
46✔
172
    {
173
        $className = to_base_class_name($suggestedPath);
46✔
174

175
        $targetPath = $this->console->ask(
46✔
176
            question: sprintf('Where do you want to save the file <em>%s</em>?', $className),
46✔
177
            default: to_relative_path(root_path(), $suggestedPath),
46✔
178
            validation: $rules ?? [new NotEmpty(), new EndsWith('.php')],
46✔
179
        );
46✔
180

181
        return to_absolute_path(root_path(), $targetPath);
46✔
182
    }
183

184
    /**
185
     * Ask the user if they want to override the file if it already exists.
186
     * @param string $targetPath The target path to check for existence.
187
     * @return bool Whether the user wants to override the file.
188
     */
189
    public function askForOverride(string $targetPath): bool
50✔
190
    {
191
        if (! file_exists($targetPath)) {
50✔
192
            return true;
50✔
193
        }
194

195
        return $this->console->confirm(
×
196
            question: sprintf('The file <file="%s" /> already exists. Do you want to override it?', $this->friendlyFileName($targetPath)),
×
197
        );
×
198
    }
199

200
    /**
201
     * Updates the contents of a file at the given path.
202
     *
203
     * @param string $path The absolute path to the file to update.
204
     * @param Closure(string|ImmutableString $contents): mixed $callback A callback that accepts the file contents and must return updated contents.
205
     * @param bool $ignoreNonExisting Whether to throw an exception if the file does not exist.
206
     */
207
    public function update(string $path, Closure $callback, bool $ignoreNonExisting = false): void
×
208
    {
209
        if (! is_file($path)) {
×
210
            if ($ignoreNonExisting) {
×
211
                return;
×
212
            }
213

214
            throw new Exception("The file at path [{$path}] does not exist.");
×
215
        }
216

217
        $contents = file_get_contents($path);
×
218

219
        $reflector = new FunctionReflector($callback);
×
220
        $type = $reflector->getParameters()->current()->getType();
×
221

222
        $contents = match (true) {
×
223
            is_null($type), $type->equals(ImmutableString::class) => (string) $callback(new ImmutableString($contents)),
×
224
            $type->accepts('string') => (string) $callback($contents),
×
225
            default => throw new Exception('The callback must accept a string or ImmutableString.'),
×
226
        };
×
227

228
        file_put_contents($path, $contents);
×
229
    }
230

231
    /**
232
     * Updates a JSON file, preserving indentation.
233
     *
234
     * @param string $path The absolute path to the file to update.
235
     * @param Closure(array): array $callback
236
     * @param bool $ignoreNonExisting Whether to throw an exception if the file does not exist.
237
     */
238
    public function updateJson(string $path, Closure $callback, bool $ignoreNonExisting = false): void
×
239
    {
240
        $this->update(
×
241
            $path,
×
242
            function (string $content) use ($callback) {
×
243
                $indent = $this->detectIndent($content);
×
244

245
                $json = json_decode($content, associative: true);
×
246
                $json = $callback($json);
×
247

248
                // PHP will output empty arrays for empty dependencies,
249
                // which is invalid and will make package managers crash.
250
                foreach (['dependencies', 'devDependencies', 'peerDependencies'] as $key) {
×
251
                    if (isset($json[$key]) && ! $json[$key]) {
×
252
                        unset($json[$key]);
×
253
                    }
254
                }
255

256
                $content = preg_replace_callback(
×
257
                    '/^ +/m',
×
258
                    fn ($m) => str_repeat($indent, strlen($m[0]) / 4),
×
259
                    json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
×
260
                );
×
261

262
                return "{$content}\n";
×
263
            },
×
264
            $ignoreNonExisting,
×
265
        );
×
266
    }
267

268
    private function friendlyFileName(string $path): string
5✔
269
    {
270
        return to_relative_path(root_path(), $path);
5✔
271
    }
272

273
    private function detectIndent(string $raw): string
×
274
    {
275
        try {
276
            return explode('"', explode("\n", $raw)[1])[0] ?: '';
×
277
        } catch (Throwable) {
×
278
            return '    ';
×
279
        }
280
    }
281
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc