diff --git a/packages/reflection/src/PropertyReflector.php b/packages/reflection/src/PropertyReflector.php index cf18b2553..077d66f77 100644 --- a/packages/reflection/src/PropertyReflector.php +++ b/packages/reflection/src/PropertyReflector.php @@ -5,12 +5,20 @@ namespace Tempest\Reflection; use Error; +use PhpToken; +use ReflectionClass; use ReflectionProperty as PHPReflectionProperty; final class PropertyReflector implements Reflector { use HasAttributes; + /** @var array> */ + private static array $useStatementCache = []; + + /** @var array */ + private static array $resolvedTypeCache = []; + public function __construct( private readonly PHPReflectionProperty $reflectionProperty, ) {} @@ -94,17 +102,207 @@ public function getIterableType(): ?TypeReflector { $doc = $this->reflectionProperty->getDocComment(); - if (! $doc) { + if (! $doc || ! preg_match('/@var\s+(?:(?:array|list)<([\\\\\w]+)>|([\\\\\w]+)\[\])/', $doc, $match)) { return null; } - preg_match('/@var ([\\\\\w]+)\[]/', $doc, $match); + $rawType = $match[1] !== '' ? $match[1] : $match[2]; + $typeName = ltrim($rawType, '\\'); + + return str_contains($rawType, '\\') + ? new TypeReflector($typeName) + : new TypeReflector($this->resolveShortClassName($typeName)); + } + + private function resolveShortClassName(string $shortName): string + { + $declaringClass = $this->reflectionProperty->getDeclaringClass(); + $cacheKey = $declaringClass->getName() . '::' . $shortName; + + return self::$resolvedTypeCache[$cacheKey] ??= $this->doResolveShortClassName($shortName, $declaringClass); + } + + private function doResolveShortClassName(string $shortName, ReflectionClass $declaringClass): string + { + $fileName = $declaringClass->getFileName(); + + if ($fileName) { + self::$useStatementCache[$fileName] ??= $this->parseUseStatements($fileName); + + if (isset(self::$useStatementCache[$fileName][$shortName])) { + return self::$useStatementCache[$fileName][$shortName]; + } + } + + $namespace = $declaringClass->getNamespaceName(); + + if ($namespace !== '') { + $fqcn = $namespace . '\\' . $shortName; + + if (class_exists($fqcn)) { + return $fqcn; + } + } + + return $shortName; + } + + /** @return array */ + private function parseUseStatements(string $fileName): array + { + $content = file_get_contents($fileName); + + if ($content === false) { + return []; + } + + $tokens = PhpToken::tokenize($content); + $useStatements = []; + $count = count($tokens); + + for ($i = 0; $i < $count; $i++) { + $token = $tokens[$i]; + + if ($token->is([T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM])) { + break; + } + + if (! $token->is(T_USE)) { + continue; + } + + $i++; + $this->skipWhitespaceTokens($tokens, $i, $count); + + if ($tokens[$i]->is([T_FUNCTION, T_CONST])) { + continue; + } + + $fqcn = $this->parseNamespacedName($tokens, $i, $count); + $this->skipWhitespaceTokens($tokens, $i, $count); + + if ($tokens[$i]->text === '{') { + $this->parseGroupUse($tokens, $i, $count, $fqcn, $useStatements); + continue; + } + + $alias = $this->parseAlias($tokens, $i, $count) ?? $this->getShortName($fqcn); + $useStatements[$alias] = $fqcn; + + while ($i < $count && $tokens[$i]->text !== ';') { + $i++; + } + } + + return $useStatements; + } - if (! isset($match[1])) { + /** @param PhpToken[] $tokens */ + private function skipWhitespaceTokens(array $tokens, int &$i, int $count): void + { + while ($i < $count && $tokens[$i]->is([T_WHITESPACE, T_COMMENT])) { + $i++; + } + } + + /** @param PhpToken[] $tokens */ + private function parseNamespacedName(array $tokens, int &$i, int $count): string + { + $name = ''; + + while ($i < $count) { + $token = $tokens[$i]; + + if ($token->is([T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, T_NS_SEPARATOR])) { + $name .= $token->text; + $i++; + } elseif ($token->is(T_WHITESPACE)) { + $i++; + } else { + break; + } + } + + return ltrim($name, '\\'); + } + + /** @param PhpToken[] $tokens */ + private function parseAlias(array $tokens, int &$i, int $count): ?string + { + if (! $tokens[$i]->is(T_AS)) { return null; } - return new TypeReflector(ltrim($match[1], '\\')); + $i++; + $this->skipWhitespaceTokens($tokens, $i, $count); + + if ($tokens[$i]->is(T_STRING)) { + return $tokens[$i++]->text; + } + + return null; + } + + /** + * @param PhpToken[] $tokens + * @param array $useStatements + */ + private function parseGroupUse(array $tokens, int &$i, int $count, string $prefix, array &$useStatements): void + { + $i++; + + while ($i < $count && $tokens[$i]->text !== '}') { + $this->skipWhitespaceTokens($tokens, $i, $count); + + if ($i >= $count) { + break; + } + + if ($tokens[$i]->is([T_FUNCTION, T_CONST])) { + while ($i < $count && $tokens[$i]->text !== ',' && $tokens[$i]->text !== '}') { + $i++; + } + + if ($i < $count && $tokens[$i]->text === ',') { + $i++; + } + + continue; + } + + $name = $this->parseNamespacedName($tokens, $i, $count); + + if ($name === '') { + $i++; + continue; + } + + $this->skipWhitespaceTokens($tokens, $i, $count); + + if ($i >= $count) { + break; + } + + $alias = $this->parseAlias($tokens, $i, $count) ?? $this->getShortName($name); + $useStatements[$alias] = $prefix . '\\' . $name; + + $this->skipWhitespaceTokens($tokens, $i, $count); + + if ($i < $count && $tokens[$i]->text === ',') { + $i++; + } + } + + if ($i < $count) { + $i++; + } + } + + private function getShortName(string $fqcn): string + { + $pos = strrpos($fqcn, '\\'); + + return $pos === false ? $fqcn : substr($fqcn, $pos + 1); } public function isUninitialized(object $object): bool diff --git a/packages/reflection/tests/Fixtures/IterableTypeResolution/Author.php b/packages/reflection/tests/Fixtures/IterableTypeResolution/Author.php new file mode 100644 index 000000000..15d702ec7 --- /dev/null +++ b/packages/reflection/tests/Fixtures/IterableTypeResolution/Author.php @@ -0,0 +1,10 @@ +getIterableType(); + + $this->assertNotNull($iterableType); + $this->assertSame(Book::class, $iterableType->getName()); + } + + #[Test] + public function iterable_type_with_aliased_use_statement(): void + { + $property = PropertyReflector::fromParts(AliasedUseStatement::class, 'books'); + + $iterableType = $property->getIterableType(); + + $this->assertNotNull($iterableType); + $this->assertSame(Book::class, $iterableType->getName()); + } + + #[Test] + public function iterable_type_with_group_use_statement(): void + { + $booksProperty = PropertyReflector::fromParts(GroupUseStatement::class, 'books'); + $authorsProperty = PropertyReflector::fromParts(GroupUseStatement::class, 'authors'); + + $booksType = $booksProperty->getIterableType(); + $authorsType = $authorsProperty->getIterableType(); + + $this->assertNotNull($booksType); + $this->assertSame(Book::class, $booksType->getName()); + + $this->assertNotNull($authorsType); + $this->assertSame(Author::class, $authorsType->getName()); + } + + #[Test] + public function iterable_type_with_same_namespace(): void + { + $property = PropertyReflector::fromParts(SameNamespace::class, 'books'); + + $iterableType = $property->getIterableType(); + + $this->assertNotNull($iterableType); + $this->assertSame(Book::class, $iterableType->getName()); + } + + #[Test] + public function iterable_type_with_fully_qualified_class_name(): void + { + $property = PropertyReflector::fromParts(FullyQualifiedClassName::class, 'books'); + + $iterableType = $property->getIterableType(); + + $this->assertNotNull($iterableType); + $this->assertSame(Book::class, $iterableType->getName()); + } +}