From 819af4982d473521469a072e28da0a51cb902fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radoslav=20Gren=C4=8D=C3=ADk?= Date: Thu, 24 Sep 2026 09:39:19 +0200 Subject: [PATCH 1/8] fix(reflection): resolve short docblock names in getIterableType --- packages/reflection/src/PropertyReflector.php | 151 +++++++++++++++++- .../Fixtures/DocblockCollectionModel.php | 43 +++++ .../tests/Fixtures/DocblockRelativeTarget.php | 7 + .../Fixtures/Imports/DocblockGroupTargetA.php | 7 + .../Fixtures/Imports/DocblockGroupTargetB.php | 7 + .../Fixtures/Imports/DocblockImportTarget.php | 7 + .../tests/PropertyReflectorTest.php | 51 ++++++ .../Fixtures/Modules/Books/Models/Author.php | 2 +- tests/Fixtures/Requests/BookRequest.php | 3 +- .../Mapper/Fixtures/Children/LibraryItem.php | 10 ++ tests/Integration/Mapper/Fixtures/Library.php | 13 ++ .../Mappers/ArrayToObjectMapperTest.php | 12 ++ 12 files changed, 309 insertions(+), 4 deletions(-) create mode 100644 packages/reflection/tests/Fixtures/DocblockCollectionModel.php create mode 100644 packages/reflection/tests/Fixtures/DocblockRelativeTarget.php create mode 100644 packages/reflection/tests/Fixtures/Imports/DocblockGroupTargetA.php create mode 100644 packages/reflection/tests/Fixtures/Imports/DocblockGroupTargetB.php create mode 100644 packages/reflection/tests/Fixtures/Imports/DocblockImportTarget.php create mode 100644 packages/reflection/tests/PropertyReflectorTest.php create mode 100644 tests/Integration/Mapper/Fixtures/Children/LibraryItem.php create mode 100644 tests/Integration/Mapper/Fixtures/Library.php diff --git a/packages/reflection/src/PropertyReflector.php b/packages/reflection/src/PropertyReflector.php index f98a6330d9..bfd42cb3a3 100644 --- a/packages/reflection/src/PropertyReflector.php +++ b/packages/reflection/src/PropertyReflector.php @@ -102,16 +102,163 @@ public function getIterableType(): ?TypeReflector } if (preg_match('/@var\s+([\\\\\w]+)\[\]/', $doc, $match)) { - return new TypeReflector(ltrim($match[1], '\\')); + return new TypeReflector($this->resolveDocblockClassName($match[1])); } if (preg_match('/@var\s+(?:list|array)<([\\\\\w]+)>/', $doc, $match)) { - return new TypeReflector(ltrim($match[1], '\\')); + return new TypeReflector($this->resolveDocblockClassName($match[1])); } return null; } + private function resolveDocblockClassName(string $name): string + { + if (in_array( + $name, + ['array', 'bool', 'callable', 'false', 'float', 'int', 'iterable', 'mixed', 'never', 'null', 'object', 'resource', 'string', 'true', 'void'], + strict: true, + )) { + return $name; + } + + if (str_starts_with($name, '\\')) { + return ltrim($name, '\\'); + } + + $segments = explode('\\', $name); + $firstSegment = $segments[0]; + $aliases = $this->getUseAliases(); + + if (isset($aliases[$firstSegment])) { + $remainder = array_slice($segments, 1); + + if ($remainder === []) { + return $aliases[$firstSegment]; + } + + return $aliases[$firstSegment] . '\\' . implode('\\', $remainder); + } + + $namespace = $this->getClass()->getReflection()->getNamespaceName(); + + if ($namespace === '') { + return $name; + } + + return $namespace . '\\' . $name; + } + + /** @return array */ + private function getUseAliases(): array + { + $fileName = $this->reflectionProperty->getDeclaringClass()->getFileName(); + + if ($fileName === false) { + return []; + } + + return $this->collectUseAliases($fileName); + } + + /** @return array */ + private function collectUseAliases(string $fileName): array + { + static $cache = []; + + return $cache[$fileName] ??= $this->parseUseStatements($fileName); + } + + /** @return array */ + private function parseUseStatements(string $fileName): array + { + $tokens = token_get_all(file_get_contents($fileName)); + + $aliases = []; + $depth = 0; + $count = count($tokens); + + for ($i = 0; $i < $count; $i++) { + $token = $tokens[$i]; + + if (in_array($token, ['{'], true)) { + $depth++; + continue; + } + + if (in_array($token, ['}'], true)) { + $depth--; + continue; + } + + if ($depth !== 0 || ! is_array($token) || $token[0] !== T_USE) { + continue; + } + + $next = $tokens[$i + 1] ?? null; + + // Skip `use function ...` and `use const ...` statements. + if (is_array($next) && in_array($next[0], [T_FUNCTION, T_CONST], true)) { + continue; + } + + $compact = ''; + + for ($j = $i + 1; $j < $count; $j++) { + $statementToken = $tokens[$j]; + + if (in_array($statementToken, [';'], true)) { + break; + } + + if (is_array($statementToken)) { + if (in_array($statementToken[0], [T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + + $compact .= $statementToken[1]; + } else { + $compact .= $statementToken; + } + } + + foreach ($this->parseUseStatement($compact) as $alias => $name) { + $aliases[$alias] = $name; + } + } + + return $aliases; + } + + /** @return array */ + private function parseUseStatement(string $compact): array + { + $aliases = []; + + $brace = strpos($compact, '{'); + $prefix = $brace === false ? '' : trim(trim(substr($compact, 0, $brace)), '\\'); + $items = $brace === false ? explode(',', $compact) : explode(',', trim(substr($compact, $brace + 1, -1))); + + foreach ($items as $item) { + $parts = preg_split('/\bas\b/', trim($item)); + $name = trim($parts[0]); + $alias = $parts[1] ?? null; + + if ($name === '') { + continue; + } + + if ($alias === null) { + $nameSegments = explode('\\', $name); + $alias = array_pop($nameSegments); + } + + $aliases[trim($alias)] = $prefix === '' ? $name : $prefix . '\\' . $name; + } + + return $aliases; + } + public function isUninitialized(object $object): bool { return ! $this->reflectionProperty->isInitialized($object); diff --git a/packages/reflection/tests/Fixtures/DocblockCollectionModel.php b/packages/reflection/tests/Fixtures/DocblockCollectionModel.php new file mode 100644 index 0000000000..e2efbed172 --- /dev/null +++ b/packages/reflection/tests/Fixtures/DocblockCollectionModel.php @@ -0,0 +1,43 @@ + */ + public array $list = []; + + /** @var array */ + public array $arrayType = []; + + /** @var DocblockRelativeTarget[] */ + public array $relative = []; + + public array $noDocblock = []; +} diff --git a/packages/reflection/tests/Fixtures/DocblockRelativeTarget.php b/packages/reflection/tests/Fixtures/DocblockRelativeTarget.php new file mode 100644 index 0000000000..801c06a0ee --- /dev/null +++ b/packages/reflection/tests/Fixtures/DocblockRelativeTarget.php @@ -0,0 +1,7 @@ +assertSame(DocblockImportTarget::class, $model->getProperty('imported')->getIterableType()->getName()); + $this->assertSame(DocblockImportTarget::class, $model->getProperty('aliased')->getIterableType()->getName()); + $this->assertSame(DocblockGroupTargetA::class, $model->getProperty('group')->getIterableType()->getName()); + $this->assertSame(DocblockGroupTargetB::class, $model->getProperty('groupAliased')->getIterableType()->getName()); + $this->assertSame(DocblockGroupTargetB::class, $model->getProperty('groupName')->getIterableType()->getName()); + } + + #[Test] + public function iterable_type_resolves_relative_and_fully_qualified_names(): void + { + $model = new ClassReflector(DocblockCollectionModel::class); + + $this->assertSame(DocblockRelativeTarget::class, $model->getProperty('relative')->getIterableType()->getName()); + $this->assertSame(DocblockImportTarget::class, $model->getProperty('fullyQualified')->getIterableType()->getName()); + $this->assertSame(DocblockImportTarget::class, $model->getProperty('list')->getIterableType()->getName()); + $this->assertSame(DocblockImportTarget::class, $model->getProperty('arrayType')->getIterableType()->getName()); + } + + #[Test] + public function iterable_type_returns_null_without_a_docblock(): void + { + $model = new ClassReflector(DocblockCollectionModel::class); + + $this->assertNull($model->getProperty('noDocblock')->getIterableType()); + } +} diff --git a/tests/Fixtures/Modules/Books/Models/Author.php b/tests/Fixtures/Modules/Books/Models/Author.php index 771b3554f4..eb32d62131 100644 --- a/tests/Fixtures/Modules/Books/Models/Author.php +++ b/tests/Fixtures/Modules/Books/Models/Author.php @@ -15,7 +15,7 @@ public function __construct( public string $name, public ?AuthorType $type = AuthorType::A, - /** @var \Tests\Tempest\Fixtures\Modules\Books\Models\Book[] */ + /** @var Book[] */ public array $books = [], public ?Publisher $publisher = null, ) {} diff --git a/tests/Fixtures/Requests/BookRequest.php b/tests/Fixtures/Requests/BookRequest.php index 04ca40b0fe..78ad9a46b7 100644 --- a/tests/Fixtures/Requests/BookRequest.php +++ b/tests/Fixtures/Requests/BookRequest.php @@ -9,6 +9,7 @@ use Tempest\Http\Request; use Tempest\Validation\Rules\HasLength; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; +use Tests\Tempest\Fixtures\Modules\Books\Models\Chapter; use Tests\Tempest\Fixtures\Modules\Books\Models\Isbn; final class BookRequest implements Request @@ -20,7 +21,7 @@ final class BookRequest implements Request public ?Author $author = null; - /** @var \Tests\Tempest\Fixtures\Modules\Books\Models\Chapter[] */ + /** @var Chapter[] */ public array $chapters = []; #[HasOne] diff --git a/tests/Integration/Mapper/Fixtures/Children/LibraryItem.php b/tests/Integration/Mapper/Fixtures/Children/LibraryItem.php new file mode 100644 index 0000000000..cecbc3d005 --- /dev/null +++ b/tests/Integration/Mapper/Fixtures/Children/LibraryItem.php @@ -0,0 +1,10 @@ +assertSame('b', $object->b); } + #[Test] + public function map_collection_of_objects_from_short_docblock_name(): void + { + $object = map(['items' => [['name' => 'a'], ['name' => 'b']]])->to(Library::class); + + $this->assertCount(2, $object->items); + $this->assertContainsOnlyInstancesOf(LibraryItem::class, $object->items); + $this->assertSame('a', $object->items[0]->name); + } + #[Test] public function caster_on_property(): void { From 5e6fcfcfc03ec885105aaf4a82a14459d93c4432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radoslav=20Gren=C4=8D=C3=ADk?= Date: Thu, 24 Sep 2026 12:06:31 +0200 Subject: [PATCH 2/8] fix(docs): remove warning about short class names in relation types --- docs/1-essentials/03-database.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/1-essentials/03-database.md b/docs/1-essentials/03-database.md index 4af4ef95c6..1e6275fec4 100644 --- a/docs/1-essentials/03-database.md +++ b/docs/1-essentials/03-database.md @@ -193,10 +193,6 @@ final class Book } ``` -:::warning -Due to a restriction with reflection, relation types in docblocks must always be fully qualified. Short class names are not supported. -::: - ### Relation attributes Tempest infers all information needed to build queries. When property names and type information do not map one-to-one to the database schema, dedicated attributes can be used to define relations. From 64a1aa8ddc0dc72a1f747ff63383e0bccfc8c0eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radoslav=20Gren=C4=8D=C3=ADk?= Date: Mon, 5 Oct 2026 12:12:05 +0200 Subject: [PATCH 3/8] fix(reflection): harden docblock use-statement parsing --- packages/reflection/src/PropertyReflector.php | 24 +++++----- .../Fixtures/DocblockClosureUseModel.php | 19 ++++++++ .../Fixtures/DocblockMixedGroupModel.php | 25 +++++++++++ .../Fixtures/Imports/DocblockGroupTargetA.php | 7 --- .../Fixtures/Imports/DocblockGroupTargetB.php | 7 --- .../Fixtures/Imports/DocblockImportTarget.php | 7 --- .../Imports/DocblockImportTargets.php | 18 ++++++++ .../tests/PropertyReflectorTest.php | 45 +++++++++++++++++++ 8 files changed, 120 insertions(+), 32 deletions(-) create mode 100644 packages/reflection/tests/Fixtures/DocblockClosureUseModel.php create mode 100644 packages/reflection/tests/Fixtures/DocblockMixedGroupModel.php delete mode 100644 packages/reflection/tests/Fixtures/Imports/DocblockGroupTargetA.php delete mode 100644 packages/reflection/tests/Fixtures/Imports/DocblockGroupTargetB.php delete mode 100644 packages/reflection/tests/Fixtures/Imports/DocblockImportTarget.php create mode 100644 packages/reflection/tests/Fixtures/Imports/DocblockImportTargets.php diff --git a/packages/reflection/src/PropertyReflector.php b/packages/reflection/src/PropertyReflector.php index bfd42cb3a3..97abd5e334 100644 --- a/packages/reflection/src/PropertyReflector.php +++ b/packages/reflection/src/PropertyReflector.php @@ -131,13 +131,7 @@ private function resolveDocblockClassName(string $name): string $aliases = $this->getUseAliases(); if (isset($aliases[$firstSegment])) { - $remainder = array_slice($segments, 1); - - if ($remainder === []) { - return $aliases[$firstSegment]; - } - - return $aliases[$firstSegment] . '\\' . implode('\\', $remainder); + return implode('\\', [$aliases[$firstSegment], ...array_slice($segments, 1)]); } $namespace = $this->getClass()->getReflection()->getNamespaceName(); @@ -172,7 +166,14 @@ private function collectUseAliases(string $fileName): array /** @return array */ private function parseUseStatements(string $fileName): array { - $tokens = token_get_all(file_get_contents($fileName)); + // Unreadable files (eval'd code, phar paths) emit a warning we handle as empty aliases. + $source = @file_get_contents($fileName); + + if ($source === false) { + return []; + } + + $tokens = token_get_all($source); $aliases = []; $depth = 0; @@ -197,8 +198,8 @@ private function parseUseStatements(string $fileName): array $next = $tokens[$i + 1] ?? null; - // Skip `use function ...` and `use const ...` statements. - if (is_array($next) && in_array($next[0], [T_FUNCTION, T_CONST], true)) { + // Skip `use function ...`, `use const ...` and closure `use (...)`. + if ($next === '(' || is_array($next) && in_array($next[0], [T_FUNCTION, T_CONST], true)) { continue; } @@ -244,7 +245,8 @@ private function parseUseStatement(string $compact): array $name = trim($parts[0]); $alias = $parts[1] ?? null; - if ($name === '') { + // Skip `function`/`const` members of grouped imports. + if ($name === '' || str_starts_with($name, 'function ') || str_starts_with($name, 'const ')) { continue; } diff --git a/packages/reflection/tests/Fixtures/DocblockClosureUseModel.php b/packages/reflection/tests/Fixtures/DocblockClosureUseModel.php new file mode 100644 index 0000000000..7c4bce136f --- /dev/null +++ b/packages/reflection/tests/Fixtures/DocblockClosureUseModel.php @@ -0,0 +1,19 @@ +assertNull($model->getProperty('noDocblock')->getIterableType()); } + + #[Test] + public function iterable_type_ignores_mixed_group_import_members(): void + { + $model = new ClassReflector(DocblockMixedGroupModel::class); + + $this->assertSame(DocblockGroupTargetA::class, $model->getProperty('items')->getIterableType()->getName()); + } + + #[Test] + public function iterable_type_ignores_top_level_closure_use(): void + { + $model = new ClassReflector(DocblockClosureUseModel::class); + + $this->assertSame(DocblockImportTarget::class, $model->getProperty('items')->getIterableType()->getName()); + } + + #[Test] + public function iterable_type_on_unreadable_file_falls_back_to_namespace_resolution(): void + { + $file = sys_get_temp_dir() . '/tempest-unreadable-probe-' . bin2hex(random_bytes(8)) . '.php'; + file_put_contents($file, <<<'PHP' + assertSame('TempestEvalProbe\\Missing', $property->getIterableType()->getName()); + } } From 3382b7a88a203a08c69669dcc39fb1eb61de88a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radoslav=20Gren=C4=8D=C3=ADk?= Date: Mon, 5 Oct 2026 12:12:15 +0200 Subject: [PATCH 4/8] test(reflection): benchmark getIterableType docblock resolution --- .../Reflection/ColdParseStreamWrapper.php | 75 +++++++++ tests/Benchmark/Reflection/Fixtures/Book.php | 16 ++ .../Reflection/Fixtures/Fqcn/FqcnModels.php | 17 ++ .../Reflection/Fixtures/Plain/PlainModels.php | 16 ++ .../Fixtures/Short/ShortNameModels.php | 19 +++ .../Reflection/PropertyReflectorBench.php | 159 ++++++++++++++++++ 6 files changed, 302 insertions(+) create mode 100644 tests/Benchmark/Reflection/ColdParseStreamWrapper.php create mode 100644 tests/Benchmark/Reflection/Fixtures/Book.php create mode 100644 tests/Benchmark/Reflection/Fixtures/Fqcn/FqcnModels.php create mode 100644 tests/Benchmark/Reflection/Fixtures/Plain/PlainModels.php create mode 100644 tests/Benchmark/Reflection/Fixtures/Short/ShortNameModels.php create mode 100644 tests/Benchmark/Reflection/PropertyReflectorBench.php diff --git a/tests/Benchmark/Reflection/ColdParseStreamWrapper.php b/tests/Benchmark/Reflection/ColdParseStreamWrapper.php new file mode 100644 index 0000000000..ddce0235e0 --- /dev/null +++ b/tests/Benchmark/Reflection/ColdParseStreamWrapper.php @@ -0,0 +1,75 @@ +` resolves to content registered via + * {@see self::setContent()}. + */ +final class ColdParseStreamWrapper +{ + private const string PROTOCOL = 'parse-bench'; + + /** @var array */ + private static array $contents = []; + + /** @var resource|null */ + public $context; + + private string $content = ''; + + private int $position = 0; + + public static function pathFor(string $key): string + { + return self::PROTOCOL . '://' . $key; + } + + public static function setContent(string $path, string $content): void + { + self::register(); + + self::$contents[$path] = $content; + } + + private static function register(): void + { + if (in_array(self::PROTOCOL, stream_get_wrappers(), true)) { + return; + } + + stream_wrapper_register(self::PROTOCOL, self::class); + } + + public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool + { + $this->content = self::$contents[$path] ?? ''; + $this->position = 0; + + return isset(self::$contents[$path]); + } + + public function stream_read(int $count): string + { + $chunk = substr($this->content, $this->position, $count); + $this->position += strlen($chunk); + + return $chunk; + } + + public function stream_eof(): bool + { + return $this->position >= strlen($this->content); + } + + /** @return array */ + public function stream_stat(): array + { + return ['size' => strlen($this->content)]; + } + + public function stream_close(): void {} +} diff --git a/tests/Benchmark/Reflection/Fixtures/Book.php b/tests/Benchmark/Reflection/Fixtures/Book.php new file mode 100644 index 0000000000..9944bd0a20 --- /dev/null +++ b/tests/Benchmark/Reflection/Fixtures/Book.php @@ -0,0 +1,16 @@ + */ + public array $three; +} diff --git a/tests/Benchmark/Reflection/Fixtures/Short/ShortNameModels.php b/tests/Benchmark/Reflection/Fixtures/Short/ShortNameModels.php new file mode 100644 index 0000000000..176119d619 --- /dev/null +++ b/tests/Benchmark/Reflection/Fixtures/Short/ShortNameModels.php @@ -0,0 +1,19 @@ + */ + private array $coldFiles = []; + + public function setUp(): void + { + $this->shortNameReflectors = $this->collectReflectors(ShortNameModels::class); + $this->fqcnReflectors = $this->collectReflectors(FqcnModels::class); + $this->builtinReflectors = $this->collectReflectors(PlainModels::class); + } + + #[BeforeMethods('setUp')] + #[Iterations(20)] + #[Revs(50_000)] + #[Warmup(5_000)] + public function benchShortNameDocblocks(): void + { + foreach ($this->shortNameReflectors as $reflector) { + $reflector->getIterableType(); + } + } + + #[BeforeMethods('setUp')] + #[Iterations(20)] + #[Revs(50_000)] + #[Warmup(5_000)] + public function benchFqcnDocblocks(): void + { + foreach ($this->fqcnReflectors as $reflector) { + $reflector->getIterableType(); + } + } + + #[BeforeMethods('setUp')] + #[Iterations(20)] + #[Revs(50_000)] + #[Warmup(5_000)] + public function benchBuiltinTypeDocblocks(): void + { + foreach ($this->builtinReflectors as $reflector) { + $reflector->getIterableType(); + } + } + + /** + * Measures the uncached parse path. Stream wrappers avoid tmp-file IO: + * the source is served from memory via a unique `parse-bench://` wrapper. + */ + #[BeforeMethods('setUpCold')] + #[AfterMethods('tearDownCold')] + #[Iterations(20)] + #[Revs(20)] + public function benchColdParse(): void + { + static $index = 0; + + $index++; + + $reflector = new PropertyReflector(new ReflectionProperty("TempestParseBench{$index}\\BooksHolder", 'books')); + $reflector->getIterableType(); + + // Prepare a fresh source for the next rev. + $this->materializeColdFile($index + 1); + } + + public function setUpCold(): void + { + // Rev n must hit a fresh file, and the class must already exist for + // ReflectionProperty — so the file for rev n+1 is materialized during + // rev n (and the first two here). + for ($i = 1; $i <= 20; $i++) { + $this->coldFiles[$i] = ColdParseStreamWrapper::pathFor('tempest-parse-bench-' . bin2hex(random_bytes(8)) . '.php'); + } + + $this->materializeColdFile(1); + $this->materializeColdFile(2); + } + + private function materializeColdFile(int $index): void + { + $file = $this->coldFiles[$index] ?? null; + + if ($file === null) { + return; + } + + ColdParseStreamWrapper::setContent($file, $this->buildColdSource($index)); + require_once $file; + } + + public function tearDownCold(): void + { + $this->coldFiles = []; + } + + private function buildColdSource(int $index): string + { + $bookClass = Book::class; + + return <<getProperties() as $property) { + $reflectors[] = new PropertyReflector($property); + } + + return $reflectors; + } +} From 487358ae01332faec259d63e9a9f001da396aff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radoslav=20Gren=C4=8D=C3=ADk?= Date: Wed, 11 Nov 2026 10:31:44 +0100 Subject: [PATCH 5/8] fix(reflection): resolve iterable docblock types in their lexical context --- packages/reflection/src/DocblockContext.php | 196 ++++++++++++++++++ packages/reflection/src/PropertyReflector.php | 154 +++----------- .../tests/Fixtures/DocblockAnonymous.php | 26 +++ .../Fixtures/DocblockClosureUseModel.php | 19 -- .../Fixtures/DocblockCollectionModel.php | 12 -- .../tests/Fixtures/DocblockImports.php | 115 ++++++++++ .../Fixtures/DocblockMemoizationModel.php | 17 ++ .../Fixtures/DocblockMixedGroupModel.php | 25 --- .../tests/Fixtures/DocblockNamespaces.php | 33 +++ .../tests/Fixtures/DocblockTraits.php | 65 ++++++ .../tests/PropertyReflectorTest.php | 136 ++++++++++-- 11 files changed, 595 insertions(+), 203 deletions(-) create mode 100644 packages/reflection/src/DocblockContext.php create mode 100644 packages/reflection/tests/Fixtures/DocblockAnonymous.php delete mode 100644 packages/reflection/tests/Fixtures/DocblockClosureUseModel.php create mode 100644 packages/reflection/tests/Fixtures/DocblockImports.php create mode 100644 packages/reflection/tests/Fixtures/DocblockMemoizationModel.php delete mode 100644 packages/reflection/tests/Fixtures/DocblockMixedGroupModel.php create mode 100644 packages/reflection/tests/Fixtures/DocblockNamespaces.php create mode 100644 packages/reflection/tests/Fixtures/DocblockTraits.php diff --git a/packages/reflection/src/DocblockContext.php b/packages/reflection/src/DocblockContext.php new file mode 100644 index 0000000000..70ddabf39e --- /dev/null +++ b/packages/reflection/src/DocblockContext.php @@ -0,0 +1,196 @@ +} */ + public static function forProperty(ReflectionProperty $property): array + { + $class = $property->getDeclaringClass(); + $name = $property->getName(); + $context = self::forClass($class); + + // A class declaration wins over a compatible property contributed by a trait. + if (! $property->isPromoted() && ! isset($context['properties'][$name])) { + foreach ($class->getTraits() as $trait) { + if ($trait->hasProperty($name)) { + return self::forProperty($trait->getProperty($name)); + } + } + } + + return ['namespace' => $context['namespace'], 'aliases' => $context['aliases']]; + } + + /** + * @param ReflectionClass $class + * @return array{namespace: string, aliases: array, properties: array} + */ + private static function forClass(ReflectionClass $class): array + { + static $files = []; + + $file = $class->getFileName(); + // shortcut: same-line anonymous classes share metadata; disambiguate if they need different trait origins. + $key = $class->isAnonymous() ? '@' . $class->getStartLine() : $class->getName(); + + if ($file !== false) { + $files[$file] ??= self::parseFile($file); + + if (isset($files[$file][$key])) { + return $files[$file][$key]; + } + } + + return ['namespace' => $class->getNamespaceName(), 'aliases' => [], 'properties' => []]; + } + + /** @return array, properties: array}> */ + private static function parseFile(string $file): array + { + $source = @file_get_contents($file); + + if ($source === false) { + return []; + } + + $tokens = array_values(array_filter(PhpToken::tokenize($source), fn (PhpToken $token) => ! $token->isIgnorable())); + $contexts = []; + $namespace = ''; + $aliases = []; + $depth = 0; + $namespaceDepth = 0; + $parentheses = 0; + $classes = []; + $pendingClasses = []; + + for ($i = 0, $count = count($tokens); $i < $count; $i++) { + $token = $tokens[$i]; + $symbol = $token->id < 256 ? $token->text : null; + + if ($token->id === T_NAMESPACE && $depth === 0) { + $namespace = ''; + $aliases = []; + + while (! $tokens[++$i]->is([';', '{'])) { + $namespace .= $tokens[$i]->text; + } + + $namespaceDepth = $tokens[$i]->text === '{' ? ++$depth : 0; + continue; + } + + if ($token->id === T_USE && $depth === $namespaceDepth && ($tokens[$i + 1]->text ?? null) !== '(') { + $aliases = array_replace($aliases, self::parseImport($tokens, $i)); + continue; + } + + if (in_array($token->id, [T_CLASS, T_TRAIT, T_INTERFACE, T_ENUM], true) && ($tokens[$i - 1]->id ?? null) !== T_DOUBLE_COLON) { + $name = ($tokens[$i + 1]->id ?? null) === T_STRING ? $tokens[$i + 1]->text : null; + $key = $name === null ? '@' . $token->line : ltrim($namespace . '\\' . $name, '\\'); + $contexts[$key] = ['namespace' => $namespace, 'aliases' => $aliases, 'properties' => []]; + $pendingClasses[] = ['key' => $key, 'depth' => $depth, 'parentheses' => $parentheses]; + } + + if ($symbol === '{') { + $pending = end($pendingClasses); + + // Anonymous-class arguments can themselves contain closures or classes. + if ($pending !== false && $pending['depth'] === $depth && $pending['parentheses'] === $parentheses) { + $classes[] = array_pop($pendingClasses); + } + + $depth++; + } elseif ($token->id === T_CURLY_OPEN || $token->id === T_DOLLAR_OPEN_CURLY_BRACES) { + $depth++; + } elseif ($symbol === '}') { + $depth--; + + if ($classes !== [] && end($classes)['depth'] === $depth) { + array_pop($classes); + } + + if ($depth < $namespaceDepth) { + $namespace = ''; + $aliases = []; + $namespaceDepth = 0; + } + } elseif ($symbol === '(') { + $parentheses++; + } elseif ($symbol === ')') { + $parentheses--; + } + + $class = end($classes); + + if ($class === false || $depth !== ($class['depth'] + 1)) { + continue; + } + + if ($token->id === T_VARIABLE && $parentheses === $class['parentheses']) { + $contexts[$class['key']]['properties'][substr($token->text, 1)] = true; + } + } + + return $contexts; + } + + /** + * @param list $tokens + * @return array + */ + private static function parseImport(array $tokens, int &$i): array + { + $aliases = []; + $prefix = ''; + $name = ''; + $alias = ''; + $aliased = false; + $statementClassImport = ! $tokens[$i + 1]->is([T_FUNCTION, T_CONST]); + $classImport = $statementClassImport; + + while (isset($tokens[++$i])) { + $token = $tokens[$i]; + + if ($token->is([T_FUNCTION, T_CONST])) { + $classImport = false; + } elseif ($token->id === T_AS) { + $aliased = true; + } elseif ($token->text === '{') { + $prefix = $name; + $name = ''; + } elseif ($token->is([',', '}', ';'])) { + if ($classImport && $name !== '') { + $fullName = ltrim($prefix . $name, '\\'); + $alias = $aliased ? $alias : substr(strrchr('\\' . $name, '\\'), 1); + $aliases[strtolower($alias)] = $fullName; + } + + $name = ''; + $alias = ''; + $aliased = false; + $classImport = $statementClassImport; + + if ($token->text === '}') { + $prefix = ''; + } elseif ($token->text === ';') { + break; + } + } elseif ($aliased) { + $alias .= $token->text; + } else { + $name .= $token->text; + } + } + + return $aliases; + } +} diff --git a/packages/reflection/src/PropertyReflector.php b/packages/reflection/src/PropertyReflector.php index 97abd5e334..4691c48f28 100644 --- a/packages/reflection/src/PropertyReflector.php +++ b/packages/reflection/src/PropertyReflector.php @@ -97,16 +97,33 @@ public function getIterableType(): ?TypeReflector { $doc = $this->reflectionProperty->getDocComment(); - if (! $doc) { + if ($doc === false) { return null; } + /** @var array> $types */ + static $types = []; + + $class = $this->reflectionProperty->getDeclaringClass()->getName(); + $property = $this->reflectionProperty->getName(); + + if (! array_key_exists($property, $types[$class] ?? [])) { + $types[$class][$property] = $this->resolveIterableTypeName($doc); + } + + $name = $types[$class][$property]; + + return $name === null ? null : new TypeReflector($name); + } + + private function resolveIterableTypeName(string $doc): ?string + { if (preg_match('/@var\s+([\\\\\w]+)\[\]/', $doc, $match)) { - return new TypeReflector($this->resolveDocblockClassName($match[1])); + return $this->resolveDocblockClassName($match[1]); } if (preg_match('/@var\s+(?:list|array)<([\\\\\w]+)>/', $doc, $match)) { - return new TypeReflector($this->resolveDocblockClassName($match[1])); + return $this->resolveDocblockClassName($match[1]); } return null; @@ -127,14 +144,19 @@ private function resolveDocblockClassName(string $name): string } $segments = explode('\\', $name); - $firstSegment = $segments[0]; - $aliases = $this->getUseAliases(); + $firstSegment = strtolower($segments[0]); + $context = DocblockContext::forProperty($this->reflectionProperty); + $aliases = $context['aliases']; + + if ($firstSegment === 'namespace') { + $name = implode('\\', array_slice($segments, 1)); + } - if (isset($aliases[$firstSegment])) { + if ($firstSegment !== 'namespace' && isset($aliases[$firstSegment])) { return implode('\\', [$aliases[$firstSegment], ...array_slice($segments, 1)]); } - $namespace = $this->getClass()->getReflection()->getNamespaceName(); + $namespace = $context['namespace']; if ($namespace === '') { return $name; @@ -143,124 +165,6 @@ private function resolveDocblockClassName(string $name): string return $namespace . '\\' . $name; } - /** @return array */ - private function getUseAliases(): array - { - $fileName = $this->reflectionProperty->getDeclaringClass()->getFileName(); - - if ($fileName === false) { - return []; - } - - return $this->collectUseAliases($fileName); - } - - /** @return array */ - private function collectUseAliases(string $fileName): array - { - static $cache = []; - - return $cache[$fileName] ??= $this->parseUseStatements($fileName); - } - - /** @return array */ - private function parseUseStatements(string $fileName): array - { - // Unreadable files (eval'd code, phar paths) emit a warning we handle as empty aliases. - $source = @file_get_contents($fileName); - - if ($source === false) { - return []; - } - - $tokens = token_get_all($source); - - $aliases = []; - $depth = 0; - $count = count($tokens); - - for ($i = 0; $i < $count; $i++) { - $token = $tokens[$i]; - - if (in_array($token, ['{'], true)) { - $depth++; - continue; - } - - if (in_array($token, ['}'], true)) { - $depth--; - continue; - } - - if ($depth !== 0 || ! is_array($token) || $token[0] !== T_USE) { - continue; - } - - $next = $tokens[$i + 1] ?? null; - - // Skip `use function ...`, `use const ...` and closure `use (...)`. - if ($next === '(' || is_array($next) && in_array($next[0], [T_FUNCTION, T_CONST], true)) { - continue; - } - - $compact = ''; - - for ($j = $i + 1; $j < $count; $j++) { - $statementToken = $tokens[$j]; - - if (in_array($statementToken, [';'], true)) { - break; - } - - if (is_array($statementToken)) { - if (in_array($statementToken[0], [T_COMMENT, T_DOC_COMMENT], true)) { - continue; - } - - $compact .= $statementToken[1]; - } else { - $compact .= $statementToken; - } - } - - foreach ($this->parseUseStatement($compact) as $alias => $name) { - $aliases[$alias] = $name; - } - } - - return $aliases; - } - - /** @return array */ - private function parseUseStatement(string $compact): array - { - $aliases = []; - - $brace = strpos($compact, '{'); - $prefix = $brace === false ? '' : trim(trim(substr($compact, 0, $brace)), '\\'); - $items = $brace === false ? explode(',', $compact) : explode(',', trim(substr($compact, $brace + 1, -1))); - - foreach ($items as $item) { - $parts = preg_split('/\bas\b/', trim($item)); - $name = trim($parts[0]); - $alias = $parts[1] ?? null; - - // Skip `function`/`const` members of grouped imports. - if ($name === '' || str_starts_with($name, 'function ') || str_starts_with($name, 'const ')) { - continue; - } - - if ($alias === null) { - $nameSegments = explode('\\', $name); - $alias = array_pop($nameSegments); - } - - $aliases[trim($alias)] = $prefix === '' ? $name : $prefix . '\\' . $name; - } - - return $aliases; - } - public function isUninitialized(object $object): bool { return ! $this->reflectionProperty->isInitialized($object); diff --git a/packages/reflection/tests/Fixtures/DocblockAnonymous.php b/packages/reflection/tests/Fixtures/DocblockAnonymous.php new file mode 100644 index 0000000000..464611d153 --- /dev/null +++ b/packages/reflection/tests/Fixtures/DocblockAnonymous.php @@ -0,0 +1,26 @@ +assertSame($expected, $reflector->getIterableType()?->getName()); + $this->assertSame($expected, $reflector->getIterableType()?->getName()); + $this->assertSame($expected, PropertyReflector::fromParts(DocblockMemoizationModel::class, $name)->getIterableType()?->getName()); + } + + public static function iterable_types(): iterable + { + yield 'class name' => ['objects', 'stdClass']; + yield 'builtin' => ['integers', 'int']; + yield 'non-iterable docblock' => ['notIterable', null]; + } + + /** @param class-string $class */ + #[Test] + #[DataProvider('docblock_contexts')] + public function iterable_type_uses_the_docblocks_lexical_context(string $fixture, string $class, string $expected): void + { + require_once __DIR__ . '/Fixtures/' . $fixture . '.php'; + + for ($i = 0; $i < 2; $i++) { + $reflector = PropertyReflector::fromParts($class, 'items'); + $this->assertSame($expected, $reflector->getIterableType()?->getName()); + } + } + + public static function docblock_contexts(): iterable + { + yield 'bracketed namespace' => ['DocblockNamespaces', 'DocblockBracketed\\First', DocblockImportTarget::class]; + yield 'other bracketed namespace' => ['DocblockNamespaces', 'DocblockOtherBracketed\\Second', DocblockGroupTargetB::class]; + yield 'global namespace block' => ['DocblockNamespaces', 'DocblockGlobalHolder', DocblockGroupTargetA::class]; + yield 'uppercase AS' => ['DocblockImports', 'DocblockImports\\UppercaseAlias', DocblockImportTarget::class]; + yield 'case-insensitive alias' => ['DocblockImports', 'DocblockImports\\LowercaseReference', DocblockImportTarget::class]; + yield 'grouped imports' => ['DocblockImports', 'DocblockImports\\Grouped', DocblockImportTarget::class]; + yield 'grouped namespace alias' => ['DocblockImports', 'DocblockImports\\GroupedPrefix', DocblockGroupTargetA::class]; + yield 'mixed grouped imports' => ['DocblockImports', 'DocblockImports\\MixedGrouped', DocblockGroupTargetB::class]; + yield 'separate function aliases' => ['DocblockImports', 'DocblockImports\\MixedGroupedDoesNotShadowClass', DocblockImportTarget::class]; + yield 'closure use and interpolation' => ['DocblockImports', 'DocblockImports\\AfterClosure', DocblockGroupTargetA::class]; + yield 'namespace-relative name' => ['DocblockImports', 'DocblockImports\\NamespaceRelative', 'DocblockImports\\NamespaceBook']; + yield 'function import list' => ['DocblockImports', 'DocblockImports\\FunctionList', DocblockImportTarget::class]; + yield 'constant import list' => ['DocblockImports', 'DocblockImports\\ConstantList', DocblockGroupTargetA::class]; + yield 'literal brace in interpolated string' => ['DocblockImports', 'DocblockImports\\AfterString', DocblockGroupTargetB::class]; + yield 'second namespace' => ['DocblockImports', 'DocblockOtherImports\\OtherNamespace', DocblockGroupTargetB::class]; + yield 'nested trait' => ['DocblockTraits', 'DocblockTraitConsumer\\Holder', DocblockImportTarget::class]; + yield 'inherited trait property' => ['DocblockTraits', 'DocblockTraitConsumer\\Child', DocblockImportTarget::class]; + yield 'class overrides trait property' => ['DocblockTraits', 'DocblockTraitConsumer\\Override', DocblockGroupTargetB::class]; + yield 'promoted property overrides trait' => ['DocblockTraits', 'DocblockTraitConsumer\\PromotedOverride', DocblockGroupTargetB::class]; + yield 'readonly promoted property overrides trait' => ['DocblockTraits', 'DocblockTraitConsumer\\ReadonlyPromotedOverride', DocblockGroupTargetB::class]; + } + + #[Test] + public function iterable_type_resolves_anonymous_classes_with_nested_arguments(): void + { + /** @var object $object */ + $object = require __DIR__ . '/Fixtures/DocblockAnonymous.php'; + + $this->assertSame(DocblockImportTarget::class, PropertyReflector::fromParts($object, 'items')->getIterableType()?->getName()); + } + + #[Test] + public function iterable_type_keeps_imports_local_to_repeated_namespace_blocks(): void + { + // PHPStan rejects these legal repeated aliases, so compile this regression fixture at runtime. + $file = sys_get_temp_dir() . '/tempest-repeated-namespace-' . bin2hex(random_bytes(8)) . '.php'; + $namespace = 'DocblockRepeated' . bin2hex(random_bytes(8)); + $first = DocblockImportTarget::class; + $second = DocblockGroupTargetB::class; + + file_put_contents($file, << $first, 'Second' => $second] as $name => $expected) { + $this->assertSame( + $expected, + PropertyReflector::fromParts($namespace . '\\' . $name, 'items') + ->getIterableType() + ?->getName(), + ); + } + } finally { + unlink($file); + } + } + #[Test] public function iterable_type_resolves_imported_short_names(): void { @@ -28,9 +139,6 @@ public function iterable_type_resolves_imported_short_names(): void $this->assertSame(DocblockImportTarget::class, $model->getProperty('imported')->getIterableType()->getName()); $this->assertSame(DocblockImportTarget::class, $model->getProperty('aliased')->getIterableType()->getName()); - $this->assertSame(DocblockGroupTargetA::class, $model->getProperty('group')->getIterableType()->getName()); - $this->assertSame(DocblockGroupTargetB::class, $model->getProperty('groupAliased')->getIterableType()->getName()); - $this->assertSame(DocblockGroupTargetB::class, $model->getProperty('groupName')->getIterableType()->getName()); } #[Test] @@ -52,22 +160,6 @@ public function iterable_type_returns_null_without_a_docblock(): void $this->assertNull($model->getProperty('noDocblock')->getIterableType()); } - #[Test] - public function iterable_type_ignores_mixed_group_import_members(): void - { - $model = new ClassReflector(DocblockMixedGroupModel::class); - - $this->assertSame(DocblockGroupTargetA::class, $model->getProperty('items')->getIterableType()->getName()); - } - - #[Test] - public function iterable_type_ignores_top_level_closure_use(): void - { - $model = new ClassReflector(DocblockClosureUseModel::class); - - $this->assertSame(DocblockImportTarget::class, $model->getProperty('items')->getIterableType()->getName()); - } - #[Test] public function iterable_type_on_unreadable_file_falls_back_to_namespace_resolution(): void { @@ -89,7 +181,7 @@ final class DocblockHolder require_once $file; unlink($file); - $property = new PropertyReflector(new \ReflectionProperty('TempestEvalProbe\\DocblockHolder', 'items')); // @phpstan-ignore argument.type (class exists only at runtime, after require) + $property = PropertyReflector::fromParts('TempestEvalProbe\\DocblockHolder', 'items'); $this->assertSame('TempestEvalProbe\\Missing', $property->getIterableType()->getName()); } From 51505642031ef28174924bd17098169b2c8c7a05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radoslav=20Gren=C4=8D=C3=ADk?= Date: Wed, 11 Nov 2026 10:32:07 +0100 Subject: [PATCH 6/8] test(reflection): correct iterable type benchmark measurements --- .../Reflection/ColdParseStreamWrapper.php | 75 ------- .../Reflection/Fixtures/Fqcn/FqcnModels.php | 6 - .../Reflection/Fixtures/Plain/PlainModels.php | 6 +- .../Fixtures/Short/ShortNameModels.php | 6 - .../Reflection/PropertyReflectorBench.php | 192 ++++++++++-------- 5 files changed, 110 insertions(+), 175 deletions(-) delete mode 100644 tests/Benchmark/Reflection/ColdParseStreamWrapper.php diff --git a/tests/Benchmark/Reflection/ColdParseStreamWrapper.php b/tests/Benchmark/Reflection/ColdParseStreamWrapper.php deleted file mode 100644 index ddce0235e0..0000000000 --- a/tests/Benchmark/Reflection/ColdParseStreamWrapper.php +++ /dev/null @@ -1,75 +0,0 @@ -` resolves to content registered via - * {@see self::setContent()}. - */ -final class ColdParseStreamWrapper -{ - private const string PROTOCOL = 'parse-bench'; - - /** @var array */ - private static array $contents = []; - - /** @var resource|null */ - public $context; - - private string $content = ''; - - private int $position = 0; - - public static function pathFor(string $key): string - { - return self::PROTOCOL . '://' . $key; - } - - public static function setContent(string $path, string $content): void - { - self::register(); - - self::$contents[$path] = $content; - } - - private static function register(): void - { - if (in_array(self::PROTOCOL, stream_get_wrappers(), true)) { - return; - } - - stream_wrapper_register(self::PROTOCOL, self::class); - } - - public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool - { - $this->content = self::$contents[$path] ?? ''; - $this->position = 0; - - return isset(self::$contents[$path]); - } - - public function stream_read(int $count): string - { - $chunk = substr($this->content, $this->position, $count); - $this->position += strlen($chunk); - - return $chunk; - } - - public function stream_eof(): bool - { - return $this->position >= strlen($this->content); - } - - /** @return array */ - public function stream_stat(): array - { - return ['size' => strlen($this->content)]; - } - - public function stream_close(): void {} -} diff --git a/tests/Benchmark/Reflection/Fixtures/Fqcn/FqcnModels.php b/tests/Benchmark/Reflection/Fixtures/Fqcn/FqcnModels.php index 52728a49b7..e2d75103db 100644 --- a/tests/Benchmark/Reflection/Fixtures/Fqcn/FqcnModels.php +++ b/tests/Benchmark/Reflection/Fixtures/Fqcn/FqcnModels.php @@ -8,10 +8,4 @@ final class FqcnModels { /** @var \Tests\Tempest\Benchmark\Reflection\Fixtures\Book[] */ public array $one; - - /** @var \Tests\Tempest\Benchmark\Reflection\Fixtures\Book[] */ - public array $two; - - /** @var \Tests\Tempest\Benchmark\Reflection\Fixtures\Book[] */ - public array $three; } diff --git a/tests/Benchmark/Reflection/Fixtures/Plain/PlainModels.php b/tests/Benchmark/Reflection/Fixtures/Plain/PlainModels.php index 6d0f1e5e83..7970984196 100644 --- a/tests/Benchmark/Reflection/Fixtures/Plain/PlainModels.php +++ b/tests/Benchmark/Reflection/Fixtures/Plain/PlainModels.php @@ -6,11 +6,11 @@ final class PlainModels { - public array $one; + public array $noDocblock; /** @var int[] */ - public array $two; + public array $integers; /** @var list */ - public array $three; + public array $strings; } diff --git a/tests/Benchmark/Reflection/Fixtures/Short/ShortNameModels.php b/tests/Benchmark/Reflection/Fixtures/Short/ShortNameModels.php index 176119d619..3b272b5c36 100644 --- a/tests/Benchmark/Reflection/Fixtures/Short/ShortNameModels.php +++ b/tests/Benchmark/Reflection/Fixtures/Short/ShortNameModels.php @@ -10,10 +10,4 @@ final class ShortNameModels { /** @var Book[] */ public array $one; - - /** @var Book[] */ - public array $two; - - /** @var Book[] */ - public array $three; } diff --git a/tests/Benchmark/Reflection/PropertyReflectorBench.php b/tests/Benchmark/Reflection/PropertyReflectorBench.php index cb355bd9b6..08a7c3d5eb 100644 --- a/tests/Benchmark/Reflection/PropertyReflectorBench.php +++ b/tests/Benchmark/Reflection/PropertyReflectorBench.php @@ -4,156 +4,178 @@ namespace Tests\Tempest\Benchmark\Reflection; +use Generator; use PhpBench\Attributes\AfterMethods; use PhpBench\Attributes\BeforeMethods; use PhpBench\Attributes\Iterations; +use PhpBench\Attributes\ParamProviders; use PhpBench\Attributes\Revs; use PhpBench\Attributes\Warmup; -use ReflectionClass; -use ReflectionProperty; +use PHPUnit\Framework\Assert; +use RuntimeException; use Tempest\Reflection\PropertyReflector; use Tests\Tempest\Benchmark\Reflection\Fixtures\Book; use Tests\Tempest\Benchmark\Reflection\Fixtures\Fqcn\FqcnModels; use Tests\Tempest\Benchmark\Reflection\Fixtures\Plain\PlainModels; use Tests\Tempest\Benchmark\Reflection\Fixtures\Short\ShortNameModels; +use Throwable; +#[Iterations(20)] final class PropertyReflectorBench { - /** @var PropertyReflector[] */ - private array $shortNameReflectors; + private const int COLD_REVS = 20; - /** @var PropertyReflector[] */ - private array $fqcnReflectors; + private PropertyReflector $reflector; - /** @var PropertyReflector[] */ - private array $builtinReflectors; + private ?string $expectedType; - /** @var array */ + /** @var list */ + private array $coldReflectors = []; + + /** @var list */ private array $coldFiles = []; - public function setUp(): void + private int $coldIndex = 0; + + /** @param array{class: class-string, property: string, type: string|null} $params */ + public function setUpWarm(array $params): void { - $this->shortNameReflectors = $this->collectReflectors(ShortNameModels::class); - $this->fqcnReflectors = $this->collectReflectors(FqcnModels::class); - $this->builtinReflectors = $this->collectReflectors(PlainModels::class); + $this->reflector = PropertyReflector::fromParts($params['class'], $params['property']); + $this->expectedType = $params['type']; } - #[BeforeMethods('setUp')] - #[Iterations(20)] + #[BeforeMethods('setUpWarm')] + #[AfterMethods('assertWarmType')] + #[ParamProviders('warmSources')] #[Revs(50_000)] #[Warmup(5_000)] - public function benchShortNameDocblocks(): void + public function benchWarmLookup(): void { - foreach ($this->shortNameReflectors as $reflector) { - $reflector->getIterableType(); - } + $this->reflector->getIterableType(); } - #[BeforeMethods('setUp')] - #[Iterations(20)] - #[Revs(50_000)] - #[Warmup(5_000)] - public function benchFqcnDocblocks(): void + /** @return Generator */ + public function warmSources(): Generator { - foreach ($this->fqcnReflectors as $reflector) { - $reflector->getIterableType(); - } + yield 'short-name' => ['class' => ShortNameModels::class, 'property' => 'one', 'type' => Book::class]; + yield 'fqcn' => ['class' => FqcnModels::class, 'property' => 'one', 'type' => Book::class]; + yield 'int[]' => ['class' => PlainModels::class, 'property' => 'integers', 'type' => 'int']; + yield 'list' => ['class' => PlainModels::class, 'property' => 'strings', 'type' => 'string']; + yield 'no-docblock' => ['class' => PlainModels::class, 'property' => 'noDocblock', 'type' => null]; } - #[BeforeMethods('setUp')] - #[Iterations(20)] - #[Revs(50_000)] - #[Warmup(5_000)] - public function benchBuiltinTypeDocblocks(): void + public function assertWarmType(): void { - foreach ($this->builtinReflectors as $reflector) { - $reflector->getIterableType(); - } + Assert::assertSame($this->expectedType, $this->reflector->getIterableType()?->getName()); } - /** - * Measures the uncached parse path. Stream wrappers avoid tmp-file IO: - * the source is served from memory via a unique `parse-bench://` wrapper. - */ - #[BeforeMethods('setUpCold')] + // Cold means uncached source metadata, not an empty OS filesystem cache. + #[BeforeMethods('setUpFirstLookup')] #[AfterMethods('tearDownCold')] - #[Iterations(20)] - #[Revs(20)] - public function benchColdParse(): void + #[ParamProviders('firstLookupSources')] + #[Revs(self::COLD_REVS)] + #[Warmup(0)] + public function benchFirstLookup(): void { - static $index = 0; - - $index++; - - $reflector = new PropertyReflector(new ReflectionProperty("TempestParseBench{$index}\\BooksHolder", 'books')); - $reflector->getIterableType(); + $this->coldReflectors[$this->coldIndex++]->getIterableType(); + } - // Prepare a fresh source for the next rev. - $this->materializeColdFile($index + 1); + /** @return Generator */ + public function firstLookupSources(): Generator + { + yield 'cold-small' => ['extraProperties' => 0, 'sourceCached' => false]; + yield 'cold-100-properties' => ['extraProperties' => 100, 'sourceCached' => false]; + yield 'cached-source' => ['extraProperties' => 0, 'sourceCached' => true]; } - public function setUpCold(): void + /** @param array{extraProperties: int, sourceCached: bool} $params */ + public function setUpFirstLookup(array $params): void { - // Rev n must hit a fresh file, and the class must already exist for - // ReflectionProperty — so the file for rev n+1 is materialized during - // rev n (and the first two here). - for ($i = 1; $i <= 20; $i++) { - $this->coldFiles[$i] = ColdParseStreamWrapper::pathFor('tempest-parse-bench-' . bin2hex(random_bytes(8)) . '.php'); + // Load resolver dependencies without touching any of the measured source files. + PropertyReflector::fromParts(ShortNameModels::class, 'one')->getIterableType(); + $run = 'TempestParseBench' . bin2hex(random_bytes(8)); + + try { + for ($i = 0; $i < self::COLD_REVS; $i++) { + $file = tempnam(sys_get_temp_dir(), 'tempest-reflection-'); + + if ($file === false) { + throw new RuntimeException('Could not create a cold benchmark source file.'); + } + + $this->coldFiles[] = $file; + $namespace = $run . '\\Case' . $i; + + if (file_put_contents($file, $this->buildColdSource($namespace, $params['extraProperties'])) === false) { + throw new RuntimeException('Could not write a cold benchmark source file.'); + } + + require $file; + $this->coldReflectors[] = PropertyReflector::fromParts($namespace . '\\BooksHolder', 'books'); + } + + if ($params['sourceCached']) { + foreach ($this->coldReflectors as $reflector) { + Assert::assertSame(Book::class, $reflector->getClass()->getProperty('warmupBooks')->getIterableType()?->getName()); + } + } + } catch (Throwable $exception) { + $this->removeColdFiles(); + throw $exception; } - - $this->materializeColdFile(1); - $this->materializeColdFile(2); } - private function materializeColdFile(int $index): void + public function tearDownCold(): void { - $file = $this->coldFiles[$index] ?? null; - - if ($file === null) { - return; + try { + Assert::assertSame(self::COLD_REVS, $this->coldIndex, 'First-lookup subjects require exactly 20 revolutions and no warmup.'); + + foreach ($this->coldReflectors as $reflector) { + Assert::assertSame(Book::class, $reflector->getIterableType()?->getName()); + } + } finally { + $this->removeColdFiles(); } - - ColdParseStreamWrapper::setContent($file, $this->buildColdSource($index)); - require_once $file; } - public function tearDownCold(): void + private function removeColdFiles(): void { + foreach ($this->coldFiles as $file) { + unlink($file); + } + $this->coldFiles = []; + $this->coldReflectors = []; + $this->coldIndex = 0; } - private function buildColdSource(int $index): string + private function buildColdSource(string $namespace, int $extraProperties): string { $bookClass = Book::class; + $properties = ''; + + for ($i = 0; $i < $extraProperties; $i++) { + $properties .= " public string \$field{$i};\n"; + } return <<getProperties() as $property) { - $reflectors[] = new PropertyReflector($property); - } - - return $reflectors; - } } From c25348faf45156a6dbc65583c7775d135a983469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radoslav=20Gren=C4=8D=C3=ADk?= Date: Wed, 11 Nov 2026 12:38:54 +0100 Subject: [PATCH 7/8] perf(reflection): add a guarded regex fast path for docblock imports --- packages/reflection/src/DocblockContext.php | 58 ++++++++++++++++--- .../tests/Fixtures/DocblockCommentImport.php | 16 +++++ .../tests/Fixtures/DocblockHeaderClasses.php | 23 ++++++++ .../tests/Fixtures/DocblockStringImport.php | 17 ++++++ .../tests/PropertyReflectorTest.php | 6 ++ 5 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 packages/reflection/tests/Fixtures/DocblockCommentImport.php create mode 100644 packages/reflection/tests/Fixtures/DocblockHeaderClasses.php create mode 100644 packages/reflection/tests/Fixtures/DocblockStringImport.php diff --git a/packages/reflection/src/DocblockContext.php b/packages/reflection/src/DocblockContext.php index 70ddabf39e..c12e9b96e4 100644 --- a/packages/reflection/src/DocblockContext.php +++ b/packages/reflection/src/DocblockContext.php @@ -43,25 +43,67 @@ private static function forClass(ReflectionClass $class): array $key = $class->isAnonymous() ? '@' . $class->getStartLine() : $class->getName(); if ($file !== false) { - $files[$file] ??= self::parseFile($file); + if (! isset($files[$file][$key])) { + $source = @file_get_contents($file); - if (isset($files[$file][$key])) { - return $files[$file][$key]; + if ($source !== false) { + $context = self::parseHeader($source, $class); + + if ($context === null) { + $files[$file] = self::parseSource($source); + } else { + $files[$file][$key] = $context; + } + } + + $files[$file][$key] ??= ['namespace' => $class->getNamespaceName(), 'aliases' => [], 'properties' => []]; } + + return $files[$file][$key]; } return ['namespace' => $class->getNamespaceName(), 'aliases' => [], 'properties' => []]; } - /** @return array, properties: array}> */ - private static function parseFile(string $file): array + /** + * @param ReflectionClass $class + * @return array{namespace: string, aliases: array, properties: array}|null + */ + private static function parseHeader(string $source, ReflectionClass $class): ?array { - $source = @file_get_contents($file); + // Only a complete simple header can bypass tokenization; comments and other PHP syntax fall back. + if ($class->isAnonymous() || preg_match(<<<'REGEX' + ~\A<\?php\s+ + (?:declare\s*\(\s*strict_types\s*=\s*1\s*\)\s*;\s*)? + (?:namespace\s+(?[\w\x5c]+)\s*;\s*)? + (?(?:use\s+[\w\x5c]+(?:\s+as\s+\w+)?\s*;\s*)*) + (?:(?:final|abstract|readonly)\s+)*class\s+(?\w+)\b + ~ix + REGEX, $source, $match) !== 1) { + return null; + } - if ($source === false) { - return []; + $namespace = $match['namespace']; + + if (ltrim($namespace . '\\' . $match['class'], '\\') !== $class->getName() || $class->getTraits() !== []) { + return null; + } + + preg_match_all('/use\s+([\w\x5c]+)(?:\s+as\s+(\w+))?\s*;/i', $match['imports'], $imports, PREG_SET_ORDER); + $aliases = []; + + foreach ($imports as $import) { + $name = ltrim($import[1], '\\'); + $alias = $import[2] ?? substr(strrchr('\\' . $name, '\\'), 1); + $aliases[strtolower($alias)] = $name; } + return ['namespace' => $namespace, 'aliases' => $aliases, 'properties' => []]; + } + + /** @return array, properties: array}> */ + private static function parseSource(string $source): array + { $tokens = array_values(array_filter(PhpToken::tokenize($source), fn (PhpToken $token) => ! $token->isIgnorable())); $contexts = []; $namespace = ''; diff --git a/packages/reflection/tests/Fixtures/DocblockCommentImport.php b/packages/reflection/tests/Fixtures/DocblockCommentImport.php new file mode 100644 index 0000000000..9d513021dc --- /dev/null +++ b/packages/reflection/tests/Fixtures/DocblockCommentImport.php @@ -0,0 +1,16 @@ + ['DocblockImports', 'DocblockImports\\ConstantList', DocblockGroupTargetA::class]; yield 'literal brace in interpolated string' => ['DocblockImports', 'DocblockImports\\AfterString', DocblockGroupTargetB::class]; yield 'second namespace' => ['DocblockImports', 'DocblockOtherImports\\OtherNamespace', DocblockGroupTargetB::class]; + yield 'import text in a comment' => ['DocblockCommentImport', DocblockCommentImport::class, DocblockImportTarget::class]; + yield 'import text in a string' => ['DocblockStringImport', DocblockStringImport::class, DocblockImportTarget::class]; + yield 'simple header before another class' => ['DocblockHeaderClasses', 'DocblockHeaderFirst\\First', DocblockImportTarget::class]; + yield 'another class after a cached header' => ['DocblockHeaderClasses', 'DocblockHeaderSecond\\Second', DocblockGroupTargetB::class]; yield 'nested trait' => ['DocblockTraits', 'DocblockTraitConsumer\\Holder', DocblockImportTarget::class]; yield 'inherited trait property' => ['DocblockTraits', 'DocblockTraitConsumer\\Child', DocblockImportTarget::class]; yield 'class overrides trait property' => ['DocblockTraits', 'DocblockTraitConsumer\\Override', DocblockGroupTargetB::class]; From 59a7d5c410204b0052b1a9d9e9410394d62fbfeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radoslav=20Gren=C4=8D=C3=ADk?= Date: Wed, 11 Nov 2026 12:39:14 +0100 Subject: [PATCH 8/8] test(reflection): benchmark regex hits and parser fallbacks --- .../Reflection/PropertyReflectorBench.php | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/tests/Benchmark/Reflection/PropertyReflectorBench.php b/tests/Benchmark/Reflection/PropertyReflectorBench.php index 08a7c3d5eb..cbd57f81cc 100644 --- a/tests/Benchmark/Reflection/PropertyReflectorBench.php +++ b/tests/Benchmark/Reflection/PropertyReflectorBench.php @@ -80,15 +80,17 @@ public function benchFirstLookup(): void $this->coldReflectors[$this->coldIndex++]->getIterableType(); } - /** @return Generator */ + /** @return Generator */ public function firstLookupSources(): Generator { - yield 'cold-small' => ['extraProperties' => 0, 'sourceCached' => false]; - yield 'cold-100-properties' => ['extraProperties' => 100, 'sourceCached' => false]; - yield 'cached-source' => ['extraProperties' => 0, 'sourceCached' => true]; + yield 'cold-small' => ['extraProperties' => 0, 'sourceCached' => false, 'groupedImport' => false, 'propertiesPerFile' => 1]; + yield 'cold-100-properties' => ['extraProperties' => 100, 'sourceCached' => false, 'groupedImport' => false, 'propertiesPerFile' => 1]; + yield 'cached-source' => ['extraProperties' => 0, 'sourceCached' => true, 'groupedImport' => false, 'propertiesPerFile' => 1]; + yield 'cold-grouped-import' => ['extraProperties' => 0, 'sourceCached' => false, 'groupedImport' => true, 'propertiesPerFile' => 1]; + yield 'cold-20-properties-one-file' => ['extraProperties' => 0, 'sourceCached' => false, 'groupedImport' => false, 'propertiesPerFile' => self::COLD_REVS]; } - /** @param array{extraProperties: int, sourceCached: bool} $params */ + /** @param array{extraProperties: int, sourceCached: bool, groupedImport: bool, propertiesPerFile: int} $params */ public function setUpFirstLookup(array $params): void { // Load resolver dependencies without touching any of the measured source files. @@ -96,7 +98,7 @@ public function setUpFirstLookup(array $params): void $run = 'TempestParseBench' . bin2hex(random_bytes(8)); try { - for ($i = 0; $i < self::COLD_REVS; $i++) { + for ($i = 0; $i < self::COLD_REVS; $i += $params['propertiesPerFile']) { $file = tempnam(sys_get_temp_dir(), 'tempest-reflection-'); if ($file === false) { @@ -106,12 +108,15 @@ public function setUpFirstLookup(array $params): void $this->coldFiles[] = $file; $namespace = $run . '\\Case' . $i; - if (file_put_contents($file, $this->buildColdSource($namespace, $params['extraProperties'])) === false) { + if (file_put_contents($file, $this->buildColdSource($namespace, $params['extraProperties'], $params['groupedImport'], $params['propertiesPerFile'])) === false) { throw new RuntimeException('Could not write a cold benchmark source file.'); } require $file; - $this->coldReflectors[] = PropertyReflector::fromParts($namespace . '\\BooksHolder', 'books'); + + for ($property = 0; $property < $params['propertiesPerFile']; $property++) { + $this->coldReflectors[] = PropertyReflector::fromParts($namespace . '\\BooksHolder', $property === 0 ? 'books' : 'books' . $property); + } } if ($params['sourceCached']) { @@ -149,15 +154,21 @@ private function removeColdFiles(): void $this->coldIndex = 0; } - private function buildColdSource(string $namespace, int $extraProperties): string + private function buildColdSource(string $namespace, int $extraProperties, bool $groupedImport, int $propertiesPerFile): string { $bookClass = Book::class; + $bookNamespace = substr($bookClass, 0, strrpos($bookClass, '\\')); + $import = $groupedImport ? "use {$bookNamespace}\\{Book};" : "use {$bookClass};"; $properties = ''; for ($i = 0; $i < $extraProperties; $i++) { $properties .= " public string \$field{$i};\n"; } + for ($i = 1; $i < $propertiesPerFile; $i++) { + $properties .= " /** @var Book[] */\n public array \$books{$i};\n"; + } + return <<