Skip to content

Commit 47125f8

Browse files
committed
Apply fixed Coderabbitai review.
1 parent c44d8b2 commit 47125f8

5 files changed

Lines changed: 249 additions & 26 deletions

File tree

src/StubFilesExtension.php

Lines changed: 70 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,18 @@
99

1010
use function file_exists;
1111
use function file_put_contents;
12+
use function getmypid;
1213
use function ltrim;
1314
use function md5;
15+
use function rename;
1416
use function sprintf;
1517
use function strrpos;
1618
use function substr;
1719
use function sys_get_temp_dir;
20+
use function unlink;
21+
22+
use const DIRECTORY_SEPARATOR;
23+
use const LOCK_EX;
1824

1925
/**
2026
* Provides dynamic stub file generation for PHPStan analysis based on the configured Yii Application type.
@@ -23,8 +29,8 @@
2329
* type specified in the project configuration. This enables PHPStan to infer the correct application class for web,
2430
* console, or custom application contexts without requiring separate static stub files.
2531
*
26-
* The generated stub is written to a deterministic temporary file path, cached across PHPStan runs for the same
27-
* application type.
32+
* The generated stub is written atomically to a deterministic temporary file path, cached across PHPStan runs for the
33+
* same content.
2834
*
2935
* @see ServiceMap for service and component map for Yii Application static analysis.
3036
*
@@ -35,8 +41,12 @@ final class StubFilesExtension implements \PHPStan\PhpDoc\StubFilesExtension
3541
{
3642
/**
3743
* @param ServiceMap $serviceMap Service and component map for Yii Application static analysis.
44+
* @param string $stubDirectory Directory for generated stub files (default: system temporary directory).
3845
*/
39-
public function __construct(private readonly ServiceMap $serviceMap) {}
46+
public function __construct(
47+
private readonly ServiceMap $serviceMap,
48+
private readonly string $stubDirectory = '',
49+
) {}
4050

4151
/**
4252
* Retrieves the dynamically generated stub file path for PHPStan analysis.
@@ -98,33 +108,20 @@ class {$className} extends \yii\base\Application {}
98108
}
99109

100110
/**
101-
* Generates a stub file for the specified application type.
102-
*
103-
* Creates a PHP stub that overrides the `BaseYii::$app` property type annotation to match the configured
104-
* application type. Includes necessary class declarations for PHPStan stub type resolution. The stub is written to
105-
* a deterministic temporary file path based on the application type hash, providing natural caching across PHPStan
106-
* runs.
111+
* Builds the full stub content for the specified application type.
107112
*
108-
* @param string $applicationType Fully qualified class name of the application type.
113+
* Assembles the PHP stub content including class declarations for PHPStan stub type resolution, the `BaseYii`
114+
* class with the `$app` property type annotation, and the `Yii` class extending `BaseYii`.
109115
*
110-
* @throws RuntimeException If the stub file can't be written to the temporary directory.
116+
* @param string $applicationType Fully qualified class name of the application type (without leading backslash).
111117
*
112-
* @return string Absolute path to the generated stub file.
118+
* @return string Complete PHP stub file content.
113119
*/
114-
private function generateStub(string $applicationType): string
120+
private function buildStubContent(string $applicationType): string
115121
{
116-
$ds = DIRECTORY_SEPARATOR;
117-
118-
$stubPath = sys_get_temp_dir() . "{$ds}yii2-phpstan-stub-" . md5($applicationType) . '.stub';
119-
120-
if (file_exists($stubPath)) {
121-
return $stubPath;
122-
}
123-
124-
$escapedType = ltrim($applicationType, '\\');
125-
$typeDeclaration = $this->buildApplicationTypeDeclaration($escapedType);
122+
$typeDeclaration = $this->buildApplicationTypeDeclaration($applicationType);
126123

127-
$content = <<<PHP
124+
return <<<PHP
128125
<?php
129126
130127
{$typeDeclaration}
@@ -133,7 +130,7 @@ private function generateStub(string $applicationType): string
133130
class BaseYii
134131
{
135132
/**
136-
* @var \\{$escapedType}
133+
* @var \\{$applicationType}
137134
*/
138135
public static \$app;
139136
}
@@ -143,12 +140,59 @@ class BaseYii
143140
class Yii extends \yii\BaseYii {}
144141
}
145142
PHP;
143+
}
144+
145+
/**
146+
* Generates a stub file for the specified application type.
147+
*
148+
* Creates a PHP stub that overrides the `BaseYii::$app` property type annotation to match the configured
149+
* application type. Includes necessary class declarations for PHPStan stub type resolution. The cache key is
150+
* derived from the generated content, preventing stale files after generator changes. The stub is written
151+
* atomically using a temporary file and `rename()` to prevent concurrent PHPStan runs from reading half-written
152+
* files.
153+
*
154+
* @param string $applicationType Fully qualified class name of the application type.
155+
*
156+
* @throws RuntimeException If the stub file can't be written to the temporary directory.
157+
*
158+
* @return string Absolute path to the generated stub file.
159+
*/
160+
private function generateStub(string $applicationType): string
161+
{
162+
$escapedType = ltrim($applicationType, '\\');
163+
164+
$content = $this->buildStubContent($escapedType);
165+
166+
$directory = $this->stubDirectory !== '' ? $this->stubDirectory : sys_get_temp_dir();
167+
$stubPath = $directory . DIRECTORY_SEPARATOR . 'yii2-phpstan-stub-' . md5($content) . '.stub';
168+
169+
if (file_exists($stubPath)) {
170+
return $stubPath;
171+
}
172+
173+
$temporaryPath = $stubPath . '.' . getmypid() . '.tmp';
174+
175+
if (@file_put_contents($temporaryPath, $content, LOCK_EX) === false) {
176+
throw new RuntimeException(
177+
sprintf("Failed to write stub file to '%s'. Ensure the temporary directory is writable.", $stubPath),
178+
);
179+
}
180+
181+
// @codeCoverageIgnoreStart
182+
// atomic publish: rename within the same filesystem is atomic on POSIX. This fallback handles Windows (where
183+
// rename fails if target exists) and other non-POSIX edge cases during concurrent PHPStan runs.
184+
if (!@rename($temporaryPath, $stubPath)) {
185+
@unlink($temporaryPath);
186+
187+
if (file_exists($stubPath)) {
188+
return $stubPath;
189+
}
146190

147-
if (file_put_contents($stubPath, $content) === false) {
148191
throw new RuntimeException(
149192
sprintf("Failed to write stub file to '%s'. Ensure the temporary directory is writable.", $stubPath),
150193
);
151194
}
195+
// @codeCoverageIgnoreEnd
152196

153197
return $stubPath;
154198
}

tests/StubFilesExtensionTest.php

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,14 @@
55
namespace yii2\extensions\phpstan\tests;
66

77
use PHPUnit\Framework\TestCase;
8+
use RuntimeException;
89
use yii2\extensions\phpstan\ServiceMap;
910
use yii2\extensions\phpstan\StubFilesExtension;
1011

1112
use function file_get_contents;
13+
use function glob;
14+
use function sys_get_temp_dir;
15+
use function unlink;
1216

1317
/**
1418
* Unit tests for {@see StubFilesExtension} dynamic stub file generation.
@@ -18,6 +22,13 @@
1822
*/
1923
final class StubFilesExtensionTest extends TestCase
2024
{
25+
/**
26+
* Tracked stub file paths generated during tests, cleaned up in tearDown.
27+
*
28+
* @phpstan-var string[]
29+
*/
30+
private array $generatedStubs = [];
31+
2132
public function testGeneratedStubIsCached(): void
2233
{
2334
$stubFilesExtension = new StubFilesExtension(new ServiceMap());
@@ -32,8 +43,54 @@ public function testGeneratedStubIsCached(): void
3243
);
3344
}
3445

46+
public function testGetFilesReturnsStubForBaseApplicationType(): void
47+
{
48+
$this->cleanGeneratedStubs();
49+
50+
$ds = DIRECTORY_SEPARATOR;
51+
$configPath = __DIR__ . "{$ds}config{$ds}phpstan-base-app-config.php";
52+
53+
$stubFilesExtension = new StubFilesExtension(new ServiceMap($configPath));
54+
55+
$files = $stubFilesExtension->getFiles();
56+
57+
self::assertCount(
58+
1,
59+
$files,
60+
'Should return exactly one stub file.',
61+
);
62+
63+
$stubPath = $files[0] ?? null;
64+
65+
self::assertNotNull(
66+
$stubPath,
67+
"Stub file path should not be 'null'.",
68+
);
69+
self::assertFileExists(
70+
$stubPath,
71+
'Generated stub file should exist on disk.',
72+
);
73+
74+
$stubContent = (string) file_get_contents($stubPath);
75+
76+
self::assertStringContainsString(
77+
'@var \yii\base\Application',
78+
$stubContent,
79+
"Stub should declare 'Yii::\$app' as '\yii\base\Application' for base configuration.",
80+
);
81+
self::assertStringNotContainsString(
82+
'class Application extends',
83+
$stubContent,
84+
'Stub should not declare a child application class for the base application type.',
85+
);
86+
87+
$this->generatedStubs[] = $stubPath;
88+
}
89+
3590
public function testGetFilesReturnsStubForConsoleApplicationType(): void
3691
{
92+
$this->cleanGeneratedStubs();
93+
3794
$ds = DIRECTORY_SEPARATOR;
3895
$configPath = __DIR__ . "{$ds}config{$ds}phpstan-console-config.php";
3996

@@ -62,10 +119,14 @@ public function testGetFilesReturnsStubForConsoleApplicationType(): void
62119
(string) file_get_contents($stubPath),
63120
"Stub should declare 'Yii::\$app' as '\yii\console\Application' for console configuration.",
64121
);
122+
123+
$this->generatedStubs[] = $stubPath;
65124
}
66125

67126
public function testGetFilesReturnsStubForCustomApplicationType(): void
68127
{
128+
$this->cleanGeneratedStubs();
129+
69130
$ds = DIRECTORY_SEPARATOR;
70131
$configPath = __DIR__ . "{$ds}config{$ds}phpstan-custom-app-config.php";
71132

@@ -95,10 +156,14 @@ public function testGetFilesReturnsStubForCustomApplicationType(): void
95156
"Stub should declare 'Yii::\$app' as '\yii2\extensions\phpstan\tests\support\stub\ApplicationCustom'"
96157
. ' for custom configuration.',
97158
);
159+
160+
$this->generatedStubs[] = $stubPath;
98161
}
99162

100163
public function testGetFilesReturnsStubForDefaultApplicationType(): void
101164
{
165+
$this->cleanGeneratedStubs();
166+
102167
$stubFilesExtension = new StubFilesExtension(new ServiceMap());
103168

104169
$files = $stubFilesExtension->getFiles();
@@ -124,10 +189,58 @@ public function testGetFilesReturnsStubForDefaultApplicationType(): void
124189
(string) file_get_contents($stubPath),
125190
"Stub should default to '\yii\web\Application' when no configuration is provided.",
126191
);
192+
193+
$this->generatedStubs[] = $stubPath;
194+
}
195+
196+
public function testGetFilesReturnsStubForGlobalNamespaceApplicationType(): void
197+
{
198+
$this->cleanGeneratedStubs();
199+
200+
$ds = DIRECTORY_SEPARATOR;
201+
$configPath = __DIR__ . "{$ds}config{$ds}phpstan-global-class-app-config.php";
202+
203+
$stubFilesExtension = new StubFilesExtension(new ServiceMap($configPath));
204+
205+
$files = $stubFilesExtension->getFiles();
206+
207+
self::assertCount(
208+
1,
209+
$files,
210+
'Should return exactly one stub file.',
211+
);
212+
213+
$stubPath = $files[0] ?? null;
214+
215+
self::assertNotNull(
216+
$stubPath,
217+
"Stub file path should not be 'null'.",
218+
);
219+
self::assertFileExists(
220+
$stubPath,
221+
'Generated stub file should exist on disk.',
222+
);
223+
224+
$stubContent = (string) file_get_contents($stubPath);
225+
226+
self::assertStringContainsString(
227+
'@var \GlobalApplication',
228+
$stubContent,
229+
"Stub should declare 'Yii::\$app' as '\GlobalApplication' for global namespace configuration.",
230+
);
231+
self::assertStringContainsString(
232+
'class GlobalApplication extends \yii\base\Application {}',
233+
$stubContent,
234+
'Stub should declare the global namespace application class extending base Application.',
235+
);
236+
237+
$this->generatedStubs[] = $stubPath;
127238
}
128239

129240
public function testGetFilesReturnsStubForWebApplicationType(): void
130241
{
242+
$this->cleanGeneratedStubs();
243+
131244
$ds = DIRECTORY_SEPARATOR;
132245
$configPath = __DIR__ . "{$ds}config{$ds}phpstan-config.php";
133246

@@ -156,5 +269,46 @@ public function testGetFilesReturnsStubForWebApplicationType(): void
156269
(string) file_get_contents($stubPath),
157270
"Stub should declare 'Yii::\$app' as '\yii\web\Application' for web configuration.",
158271
);
272+
273+
$this->generatedStubs[] = $stubPath;
274+
}
275+
276+
public function testThrowExceptionWhenStubFileCannotBeWritten(): void
277+
{
278+
$ds = DIRECTORY_SEPARATOR;
279+
$nonWritableDir = $ds . 'nonexistent' . $ds . 'directory' . $ds . 'path';
280+
281+
$stubFilesExtension = new StubFilesExtension(new ServiceMap(), $nonWritableDir);
282+
283+
$this->expectException(RuntimeException::class);
284+
$this->expectExceptionMessage('Ensure the temporary directory is writable.');
285+
286+
$stubFilesExtension->getFiles();
287+
}
288+
289+
protected function tearDown(): void
290+
{
291+
foreach ($this->generatedStubs as $path) {
292+
if (file_exists($path)) {
293+
unlink($path);
294+
}
295+
}
296+
297+
$this->generatedStubs = [];
298+
}
299+
300+
/**
301+
* Removes all generated PHPStan stub files from the temporary directory.
302+
*/
303+
private function cleanGeneratedStubs(): void
304+
{
305+
$ds = DIRECTORY_SEPARATOR;
306+
$pattern = sys_get_temp_dir() . "{$ds}yii2-phpstan-stub-*.stub";
307+
308+
$files = glob($pattern);
309+
310+
foreach ($files !== false ? $files : [] as $file) {
311+
unlink($file);
312+
}
159313
}
160314
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use yii\base\Application;
6+
7+
return [
8+
'phpstan' => [
9+
'application_type' => Application::class,
10+
],
11+
];
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
return [
6+
'phpstan' => [
7+
'application_type' => 'GlobalApplication',
8+
],
9+
];

0 commit comments

Comments
 (0)