Skip to content

Commit de9b734

Browse files
staabmphpstan-bot
authored andcommitted
Improve phpdoc parsing performance of huge stub files
- Skip PHPDoc string parsing during FileTypeMapper name scope map creation for doc comments that don't contain @template or type-alias tags - During createPhpDocNodeMap(), only template tags and type alias names are extracted from PHPDocs, so comments without these tags don't need parsing - Uses fast str_contains() checks on the raw doc comment string to decide whether to invoke the full PHPDoc parser - For a file with 10,000 function stubs, reduces processing time from ~4s to ~2s by avoiding ~10,000 unnecessary PhpDocStringResolver::resolve() calls - Added performance regression test in FileTypeMapperTest
1 parent de68533 commit de9b734

2 files changed

Lines changed: 88 additions & 1 deletion

File tree

src/Type/FileTypeMapper.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,7 @@ function (Node $node) use ($fileName, $lookForTrait, &$traitFound, $traitMethodA
485485
|| ($node instanceof Node\PropertyHook && $node->getAttribute('propertyName') !== null)
486486
) {
487487
$docComment = GetLastDocComment::forNode($node);
488-
if ($docComment !== null) {
488+
if ($docComment !== null && $this->shouldParseDocComment($docComment)) {
489489
$phpDocNode = $this->phpDocStringResolver->resolve($docComment);
490490
}
491491
}
@@ -751,6 +751,16 @@ private function chooseTemplateTagValueNodesByPriority(array $tags): array
751751
return $resolved;
752752
}
753753

754+
private function shouldParseDocComment(string $docComment): bool
755+
{
756+
return str_contains($docComment, '@template')
757+
|| str_contains($docComment, '-template')
758+
|| str_contains($docComment, '@phpstan-type')
759+
|| str_contains($docComment, '@psalm-type')
760+
|| str_contains($docComment, '@phan-type')
761+
|| str_contains($docComment, 'import-type');
762+
}
763+
754764
/**
755765
* @return array<string, true>
756766
*/

tests/PHPStan/Type/FileTypeMapperTest.php

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
use PHPStan\ShouldNotHappenException;
88
use PHPStan\Testing\PHPStanTestCase;
99
use RuntimeException;
10+
use function file_put_contents;
11+
use function microtime;
1012
use function realpath;
13+
use function sys_get_temp_dir;
14+
use function tempnam;
15+
use function unlink;
1116

1217
class FileTypeMapperTest extends PHPStanTestCase
1318
{
@@ -196,6 +201,78 @@ public function testFileWithCyclicPhpDocs(): void
196201
$this->assertSame('CyclicPhpDocs\Foo|iterable<CyclicPhpDocs\Foo>', $returnTag->getType()->describe(VerbosityLevel::precise()));
197202
}
198203

204+
public function testLargeStubFileLazyPhpDocParsing(): void
205+
{
206+
/** @var FileTypeMapper $fileTypeMapper */
207+
$fileTypeMapper = self::getContainer()->getByType(FileTypeMapper::class);
208+
209+
// Generate a large stub file with many PHPDoc comments, simulating
210+
// WordPress-style stubs where a single file declares many functions.
211+
$tmpFile = tempnam(sys_get_temp_dir(), 'phpstan_stub_perf_');
212+
if ($tmpFile === false) {
213+
throw new ShouldNotHappenException();
214+
}
215+
$tmpFile .= '.php';
216+
217+
try {
218+
$code = "<?php declare(strict_types = 1);\n\nnamespace StubPhpDocPerformance;\n\n";
219+
220+
// 10000 functions with complex PHPDocs but no template/type-alias tags
221+
for ($i = 1; $i <= 10000; $i++) {
222+
$code .= "/**\n";
223+
$code .= " * @param array<string, array<int, string>> \$param1\n";
224+
$code .= " * @param callable(int, string): array<string, mixed> \$param2\n";
225+
$code .= " * @param list<array{id: int, name: string, data: array<string, mixed>}> \$param3\n";
226+
$code .= " * @return array<int, array{key: string, value: mixed, metadata: array<string, string>}>\n";
227+
$code .= " */\n";
228+
$code .= "function stub_{$i}(array \$param1, callable \$param2, array \$param3): array {}\n\n";
229+
}
230+
231+
// A class with @template - this PHPDoc must still be parsed
232+
$code .= "/**\n * @template T\n */\n";
233+
$code .= "class GenericContainer\n{\n";
234+
$code .= " /** @var T */\n private \$value;\n\n";
235+
$code .= " /** @param T \$value */\n";
236+
$code .= " public function __construct(\$value) { \$this->value = \$value; }\n\n";
237+
$code .= " /** @return T */\n";
238+
$code .= " public function getValue() { return \$this->value; }\n";
239+
$code .= "}\n";
240+
241+
file_put_contents($tmpFile, $code);
242+
243+
$start = microtime(true);
244+
245+
// Resolve the template class - should work despite 10000 other PHPDocs in file
246+
$resolved = $fileTypeMapper->getResolvedPhpDoc(
247+
$tmpFile,
248+
'StubPhpDocPerformance\\GenericContainer',
249+
null,
250+
'getValue',
251+
'/** @return T */',
252+
);
253+
254+
$elapsed = microtime(true) - $start;
255+
256+
$returnTag = $resolved->getReturnTag();
257+
$this->assertNotNull($returnTag);
258+
$this->assertSame(
259+
'T (class StubPhpDocPerformance\GenericContainer, parameter)',
260+
$returnTag->getType()->describe(VerbosityLevel::precise()),
261+
);
262+
263+
// With lazy PHPDoc parsing, only PHPDocs with @template or type-alias
264+
// tags are parsed during name scope map creation. Without the optimization,
265+
// all 10000+ PHPDocs must be parsed, taking >2 seconds on typical hardware.
266+
$this->assertLessThan(
267+
3.0,
268+
$elapsed,
269+
'FileTypeMapper should skip PHPDoc parsing for entries without template/type-alias tags',
270+
);
271+
} finally {
272+
@unlink($tmpFile);
273+
}
274+
}
275+
199276
public function testFilesWithIdenticalPhpDocsUsingDifferentAliases(): void
200277
{
201278
/** @var FileTypeMapper $fileTypeMapper */

0 commit comments

Comments
 (0)