diff --git a/src/ReflectionClassConstant.php b/src/ReflectionClassConstant.php index 6a24445..51e9f3c 100644 --- a/src/ReflectionClassConstant.php +++ b/src/ReflectionClassConstant.php @@ -249,6 +249,37 @@ public function isFinal(): bool return $this->classConstOrEnumCaseNode instanceof ClassConst && $this->classConstOrEnumCaseNode->isFinal(); } + /** + * @inheritDoc + */ + public function isDeprecated(): bool + { + // Since PHP 8.4 class constants and enum cases can be marked with the #[\Deprecated] attribute + foreach ($this->classConstOrEnumCaseNode->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if (self::isDeprecatedAttributeName($attr->name)) { + return true; + } + } + } + + return false; + } + + /** + * Checks statically, without any autoloading, if the given attribute name points to the + * global `\Deprecated` attribute class + */ + private static function isDeprecatedAttributeName(Node\Name $attributeName): bool + { + $resolvedName = $attributeName->getAttribute('resolvedName'); + if ($resolvedName instanceof Node\Name) { + $attributeName = $resolvedName; + } + + return strcasecmp(ltrim($attributeName->toString(), '\\'), 'Deprecated') === 0; + } + /** * @inheritDoc */ diff --git a/src/ReflectionConstant.php b/src/ReflectionConstant.php new file mode 100644 index 0000000..deaf698 --- /dev/null +++ b/src/ReflectionConstant.php @@ -0,0 +1,339 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection; + +use Deprecated; +use Go\ParserReflection\Resolver\NodeExpressionResolver; +use PhpParser\Node\Attribute; +use PhpParser\Node\AttributeGroup; +use PhpParser\Node\Const_ as ConstItemNode; +use PhpParser\Node\Name; +use PhpParser\Node\Stmt\Const_ as ConstStatementNode; +use Reflector; +use Stringable; + +/** + * AST-based reflection for global and namespaced constants declared with the "const" keyword + * + * Native \ReflectionConstant is declared as final, therefore this reflection can not extend it and + * mirrors its public API instead. Constants defined via "define(...)" are not supported, because they + * are created at runtime and can not be resolved statically. + * + * @see \Go\ParserReflection\ReflectionConstantTest + */ +final class ReflectionConstant implements NodeAwareInterface, Reflector, Stringable +{ + /** + * Fully-qualified name of the constant, provided to mirror the native reflection property + */ + public string $name; + + /** + * Node with the concrete "NAME = value" declaration + */ + private ConstItemNode $constNode; + + /** + * Node with the whole "const ...;" statement, it holds attribute groups for the constant + */ + private ConstStatementNode $declarationNode; + + /** + * Namespace of the file where this constant is declared, used as a context for expressions + */ + private ?ReflectionFileNamespace $fileNamespace; + + /** + * Initializes a reflection for the global or namespaced constant + * + * @param string $constantName Fully-qualified name of the constant + * @param ConstItemNode|null $constNode Optional AST-node with the concrete constant declaration + * @param ConstStatementNode|null $declarationNode Optional AST-node with the whole "const" statement + * @param ReflectionFileNamespace|null $fileNamespace Optional namespace to search the constant in + * + * @throws ReflectionException if the constant nodes are not given and can not be found + */ + public function __construct( + string $constantName, + ?ConstItemNode $constNode = null, + ?ConstStatementNode $declarationNode = null, + ?ReflectionFileNamespace $fileNamespace = null + ) { + $this->name = ltrim($constantName, '\\'); + $this->fileNamespace = $fileNamespace; + + if (!isset($constNode, $declarationNode)) { + if (!isset($fileNamespace)) { + throw new ReflectionException( + "Could not find the constant " . $this->name . ", because global constants can not be located" + . " by name, an AST-node or a file namespace to search in should be given" + ); + } + [$declarationNode, $constNode] = self::findConstantNodes($fileNamespace, $this->getShortName()); + } + + $this->constNode = $constNode; + $this->declarationNode = $declarationNode; + } + + /** + * Emulating original behaviour of reflection + * + * @return array + */ + public function __debugInfo(): array + { + return ['name' => $this->name]; + } + + /** + * Returns the fully-qualified name of the constant + */ + public function getName(): string + { + return $this->name; + } + + /** + * Returns the name of the constant without the namespace prefix + */ + public function getShortName(): string + { + $namespaceParts = explode('\\', $this->name); + + return (string) array_pop($namespaceParts); + } + + /** + * Returns the namespace of the constant or an empty string for the global namespace + */ + public function getNamespaceName(): string + { + $namespaceParts = explode('\\', $this->name); + // Remove the last part with the constant name itself + array_pop($namespaceParts); + + return implode('\\', $namespaceParts); + } + + /** + * Returns the value of the constant, evaluated at the pure AST level + */ + public function getValue(): mixed + { + $expressionSolver = new NodeExpressionResolver($this->fileNamespace); + $expressionSolver->process($this->constNode->value); + + return $expressionSolver->getValue(); + } + + /** + * Checks if the constant is marked with the #[\Deprecated] attribute + * + * Resolved at the pure AST level, without loading anything into the memory. + */ + public function isDeprecated(): bool + { + foreach ($this->declarationNode->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if (strcasecmp(self::resolveAttributeName($attr), Deprecated::class) === 0) { + return true; + } + } + } + + return false; + } + + /** + * Returns the list of attributes, declared for this constant + * + * Attributes on constants are available since PHP 8.5, older versions can only parse them. + * + * @param class-string|null $name Optional name of the attribute to filter by + * + * @return ReflectionAttribute[] + */ + public function getAttributes(?string $name = null, int $flags = 0): array + { + $attributes = []; + $nodeExpressionResolver = new NodeExpressionResolver($this->fileNamespace); + + foreach ($this->declarationNode->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + $resolvedAttrName = self::resolveAttributeName($attr); + if (isset($name) && $name !== $resolvedAttrName) { + continue; + } + + $arguments = []; + foreach ($attr->args as $arg) { + $nodeExpressionResolver->process($arg->value); + $arguments[] = $nodeExpressionResolver->getValue(); + } + + $isRepeated = self::isAttributeRepeated($resolvedAttrName, $this->declarationNode->attrGroups); + $attributes[] = self::createAttributeReflection($attr, $resolvedAttrName, $arguments, $isRepeated); + } + } + + return $attributes; + } + + /** + * Returns an AST-node for the concrete constant declaration + */ + public function getNode(): ConstItemNode + { + return $this->constNode; + } + + /** + * Returns textual representation of the constant, following the native format + */ + public function __toString(): string + { + $constantValue = $this->getValue(); + $printedValue = match (true) { + is_array($constantValue) => 'Array', + is_scalar($constantValue), $constantValue === null => (string) $constantValue, + $constantValue instanceof \Stringable => (string) $constantValue, + default => get_debug_type($constantValue), + }; + + return sprintf( + "Constant [ %s %s ] { %s }\n", + get_debug_type($constantValue), + $this->getName(), + $printedValue + ); + } + + /** + * Searches for the pair of [statement, declaration] nodes for the given constant short name + * + * @return array{0: ConstStatementNode, 1: ConstItemNode} + * + * @throws ReflectionException if there is no such constant in the given namespace + */ + private static function findConstantNodes(ReflectionFileNamespace $fileNamespace, string $shortName): array + { + // constants can be only top-level nodes in the namespace, so we can scan them directly + foreach ($fileNamespace->getNode()->stmts as $namespaceLevelNode) { + if (!$namespaceLevelNode instanceof ConstStatementNode) { + continue; + } + foreach ($namespaceLevelNode->consts as $nodeConstant) { + if ($nodeConstant->name->toString() === $shortName) { + return [$namespaceLevelNode, $nodeConstant]; + } + } + } + + throw new ReflectionException( + "Could not find the constant " . $shortName . " in the file " . $fileNamespace->getFileName() + ); + } + + /** + * Normalizes the attribute class name from the given attribute node, without triggering autoloading + * + * @return class-string + */ + private static function resolveAttributeName(Attribute $attributeNode): string + { + $attributeNameNode = $attributeNode->name; + // If we have resolved node name, then we should use it instead + if ($attributeNameNode->hasAttribute('resolvedName')) { + $resolvedNameNode = $attributeNameNode->getAttribute('resolvedName'); + if ($resolvedNameNode instanceof Name) { + $attributeNameNode = $resolvedNameNode; + } + } + + /** @var class-string $attributeClassName */ + $attributeClassName = ltrim($attributeNameNode->toString(), '\\'); + + return $attributeClassName; + } + + /** + * @param AttributeGroup[] $attrGroups + */ + private static function isAttributeRepeated(string $attributeName, array $attrGroups): bool + { + $count = 0; + + foreach ($attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if (self::resolveAttributeName($attr) === $attributeName) { + ++$count; + } + } + } + + return $count >= 2; + } + + /** + * Builds a reflection for the given attribute node + * + * Attributes are built here instead of the shared AttributeResolverTrait, because the reflector + * argument of ReflectionAttribute does not accept a constant reflection. + * + * @param class-string $attributeName + * @param array $arguments + */ + private static function createAttributeReflection( + Attribute $attributeNode, + string $attributeName, + array $arguments, + bool $isRepeated + ): ReflectionAttribute { + return new class ($attributeNode, $attributeName, $arguments, $isRepeated) extends ReflectionAttribute { + /** + * @param class-string $attributeClassName + * @param array $attributeArguments + */ + public function __construct( + private Attribute $attributeNode, + private string $attributeClassName, + private array $attributeArguments, + private bool $attributeIsRepeated + ) {} + + public function getNode(): Attribute + { + return $this->attributeNode; + } + + public function getName(): string + { + return $this->attributeClassName; + } + + /** + * @return array + */ + public function getArguments(): array + { + return $this->attributeArguments; + } + + public function isRepeated(): bool + { + return $this->attributeIsRepeated; + } + }; + } +} diff --git a/src/ReflectionEngine.php b/src/ReflectionEngine.php index 2874f47..9bba8d4 100644 --- a/src/ReflectionEngine.php +++ b/src/ReflectionEngine.php @@ -26,6 +26,7 @@ use PhpParser\NodeVisitor\NameResolver; use PhpParser\Parser; use PhpParser\ParserFactory; +use PhpParser\PhpVersion; /** * AST-based reflection engine, powered by PHP-Parser @@ -47,9 +48,20 @@ class ReflectionEngine private function __construct() {} - public static function init(LocatorInterface $locator): void + /** + * Initializes the engine with the given locator and an optional grammar version + * + * By default the newest grammar supported by PHP-Parser is used, so that sources written for a newer + * PHP version than the host one can still be analysed statically. + * + * @param PhpVersion|null $phpVersion Optional PHP version of the grammar to parse sources with + */ + public static function init(LocatorInterface $locator, ?PhpVersion $phpVersion = null): void { - self::$parser = (new ParserFactory())->createForHostVersion(); + $parserFactory = new ParserFactory(); + self::$parser = isset($phpVersion) + ? $parserFactory->createForVersion($phpVersion) + : $parserFactory->createForNewestSupportedVersion(); self::$traverser = $traverser = new NodeTraverser(); $traverser->addVisitor(new NameResolver( diff --git a/src/ReflectionEnumBackedCase.php b/src/ReflectionEnumBackedCase.php index 2447b2c..0c7be40 100644 --- a/src/ReflectionEnumBackedCase.php +++ b/src/ReflectionEnumBackedCase.php @@ -12,9 +12,13 @@ namespace Go\ParserReflection; +use Deprecated; use Go\ParserReflection\Resolver\NodeExpressionResolver; +use PhpParser\Node\Name; use PhpParser\Node\Stmt\EnumCase; +use ReflectionClassConstant as InternalReflectionClassConstant; use ReflectionEnumBackedCase as InternalReflectionEnumBackedCase; +use ReflectionType; use UnitEnum; /** @@ -189,7 +193,64 @@ public function isProtected(): bool */ public function isFinal(): bool { - return true; + return false; + } + + /** + * {@inheritDoc} + * + * Enum cases are always public and never expose the final bit, exactly like the native reflection does. + */ + public function getModifiers(): int + { + return InternalReflectionClassConstant::IS_PUBLIC; + } + + /** + * {@inheritDoc} + * + * Enum cases never have a declared type, even for backed enums. + */ + public function hasType(): bool + { + return false; + } + + /** + * {@inheritDoc} + * + * Enum cases never have a declared type, even for backed enums. + */ + public function getType(): ?ReflectionType + { + return null; + } + + /** + * {@inheritDoc} + * + * Resolved from the #[\Deprecated] attribute at the pure AST level, without loading the enum. + */ + public function isDeprecated(): bool + { + foreach ($this->enumCaseNode->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + $attributeNameNode = $attr->name; + // If we have resolved node name, then we should use it instead + if ($attributeNameNode->hasAttribute('resolvedName')) { + $resolvedNameNode = $attributeNameNode->getAttribute('resolvedName'); + if ($resolvedNameNode instanceof Name) { + $attributeNameNode = $resolvedNameNode; + } + } + + if (strcasecmp(ltrim($attributeNameNode->toString(), '\\'), Deprecated::class) === 0) { + return true; + } + } + } + + return false; } /** diff --git a/src/ReflectionEnumUnitCase.php b/src/ReflectionEnumUnitCase.php index 59f68a9..7dd3e53 100644 --- a/src/ReflectionEnumUnitCase.php +++ b/src/ReflectionEnumUnitCase.php @@ -12,10 +12,14 @@ namespace Go\ParserReflection; +use Deprecated; use Go\ParserReflection\Traits\AttributeResolverTrait; use Go\ParserReflection\Traits\InternalPropertiesEmulationTrait; +use PhpParser\Node\Name; use PhpParser\Node\Stmt\EnumCase; +use ReflectionClassConstant as InternalReflectionClassConstant; use ReflectionEnumUnitCase as InternalReflectionEnumUnitCase; +use ReflectionType; use UnitEnum; /** @@ -168,7 +172,64 @@ public function isProtected(): bool */ public function isFinal(): bool { - return true; + return false; + } + + /** + * {@inheritDoc} + * + * Enum cases are always public and never expose the final bit, exactly like the native reflection does. + */ + public function getModifiers(): int + { + return InternalReflectionClassConstant::IS_PUBLIC; + } + + /** + * {@inheritDoc} + * + * Enum cases never have a declared type, even for backed enums. + */ + public function hasType(): bool + { + return false; + } + + /** + * {@inheritDoc} + * + * Enum cases never have a declared type, even for backed enums. + */ + public function getType(): ?ReflectionType + { + return null; + } + + /** + * {@inheritDoc} + * + * Resolved from the #[\Deprecated] attribute at the pure AST level, without loading the enum. + */ + public function isDeprecated(): bool + { + foreach ($this->enumCaseNode->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + $attributeNameNode = $attr->name; + // If we have resolved node name, then we should use it instead + if ($attributeNameNode->hasAttribute('resolvedName')) { + $resolvedNameNode = $attributeNameNode->getAttribute('resolvedName'); + if ($resolvedNameNode instanceof Name) { + $attributeNameNode = $resolvedNameNode; + } + } + + if (strcasecmp(ltrim($attributeNameNode->toString(), '\\'), Deprecated::class) === 0) { + return true; + } + } + } + + return false; } /** diff --git a/src/ReflectionFileNamespace.php b/src/ReflectionFileNamespace.php index 8761d09..0ee303c 100644 --- a/src/ReflectionFileNamespace.php +++ b/src/ReflectionFileNamespace.php @@ -65,6 +65,13 @@ class ReflectionFileNamespace implements NodeAwareInterface */ protected array $fileConstantsWithDefined; + /** + * List of reflections for constants in the namespace + * + * @var array + */ + protected array $fileReflectionConstants; + /** * List of imported namespaces (aliases) * @@ -178,6 +185,34 @@ public function getConstants(bool $withDefined = false): array return $this->fileConstants; } + /** + * Returns the reflection for the concrete constant, declared with the "const" keyword + * + * Constants defined via "define(...)" are not covered, because they are created at runtime. + * + * @param string $constantName Name of the constant without the namespace prefix + */ + public function getReflectionConstant(string $constantName): ReflectionConstant|false + { + $reflectionConstants = $this->getReflectionConstants(); + + return $reflectionConstants[$constantName] ?? false; + } + + /** + * Returns the list of reflections for constants, declared with the "const" keyword + * + * @return array + */ + public function getReflectionConstants(): array + { + if (!isset($this->fileReflectionConstants)) { + $this->fileReflectionConstants = $this->findReflectionConstants(); + } + + return $this->fileReflectionConstants; + } + /** * Gets doc comments from a namespace node if it exists, otherwise "false" */ @@ -458,6 +493,38 @@ private function findConstants(bool $withDefined = false): array return $constants; } + /** + * Searches for reflections of constants, declared with the "const" keyword, in the given AST + * + * @return array + */ + private function findReflectionConstants(): array + { + $reflectionConstants = []; + $namespaceName = $this->getName(); + + // constants can be only top-level nodes in the namespace, so we can scan them directly + foreach ($this->namespaceNode->stmts as $namespaceLevelNode) { + if (!$namespaceLevelNode instanceof Const_) { + continue; + } + $namespaceLevelNode->setAttribute('fileName', $this->fileName); + foreach ($namespaceLevelNode->consts as $nodeConstant) { + $constantShortName = $nodeConstant->name->toString(); + $constantName = $namespaceName ? $namespaceName . '\\' . $constantShortName : $constantShortName; + + $reflectionConstants[$constantShortName] = new ReflectionConstant( + $constantName, + $nodeConstant, + $namespaceLevelNode, + $this + ); + } + } + + return $reflectionConstants; + } + /** * Searches for namespace aliases for the current block * diff --git a/src/ReflectionFunction.php b/src/ReflectionFunction.php index dbb431d..a66e7e8 100644 --- a/src/ReflectionFunction.php +++ b/src/ReflectionFunction.php @@ -113,6 +113,26 @@ public function invokeArgs(array $args): mixed return parent::invokeArgs($args); } + /** + * Checks if the function is anonymous + * + * This reflection is always built from a named `function` node, so it can never be anonymous. + */ + public function isAnonymous(): bool + { + return false; + } + + /** + * Checks if the function is static + * + * Named functions are never static, this matches the behaviour of the native reflection. + */ + public function isStatic(): bool + { + return false; + } + /** * Checks if function is disabled * diff --git a/src/ReflectionMethod.php b/src/ReflectionMethod.php index 90381fb..cc53d25 100644 --- a/src/ReflectionMethod.php +++ b/src/ReflectionMethod.php @@ -409,6 +409,29 @@ public function setAccessible(bool $accessible): void { } + /** + * Creates a reflection instance from the `Class::method` notation + * + * Unlike the native implementation the class is resolved with the {@see ReflectionEngine}, + * so it is never loaded into memory. + * + * @throws ReflectionException if the given name is not a valid method name + */ + public static function createFromMethodName(string $method): static + { + $separatorPosition = strpos($method, '::'); + if ($separatorPosition === false) { + throw new ReflectionException( + 'ReflectionMethod::createFromMethodName(): Argument #1 ($method) must be a valid method name' + ); + } + + $className = substr($method, 0, $separatorPosition); + $methodName = substr($method, $separatorPosition + 2); + + return new static($className, $methodName); + } + /** * Parses methods from the concrete class node * diff --git a/src/ReflectionProperty.php b/src/ReflectionProperty.php index 345fa91..a1def9e 100644 --- a/src/ReflectionProperty.php +++ b/src/ReflectionProperty.php @@ -373,6 +373,24 @@ public function getHook(PropertyHookType $type): ?ReflectionMethod return null; } + /** + * {@inheritDoc} + * + * @return array Hooks, keyed by the hook name (@see PropertyHookType::$value) + */ + public function getHooks(): array + { + $hooks = []; + foreach (PropertyHookType::cases() as $type) { + $hook = $this->getHook($type); + if ($hook !== null) { + $hooks[(string) $type->value] = $hook; + } + } + + return $hooks; + } + /** * @inheritDoc * @@ -439,17 +457,26 @@ public function isDefault(): bool return true; } + /** + * {@inheritDoc} + */ + public function isDynamic(): bool + { + // Declared and promoted properties are always a part of the class definition + + return false; + } + /** * {@inheritDoc} * * @see Property::isFinal() + * @see Param::isFinal() */ public function isFinal(): bool { - $explicitFinal = false; - if ($this->propertyOrPromotedParam instanceof Property) { - $explicitFinal = $this->propertyOrPromotedParam->isFinal(); - } + // Promoted properties can also be declared final since PHP 8.5 + $explicitFinal = $this->propertyOrPromotedParam->isFinal(); // Property with private(set) modifier is implicitly final return $explicitFinal || $this->isPrivateSet(); @@ -563,6 +590,61 @@ public function isInitialized(?object $object = null): bool return $this->hasDefaultValue(); } + /** + * {@inheritDoc} + */ + public function isLazy(object $object): bool + { + // Laziness is a property of the concrete object, so original reflection is required + $this->initializeInternalReflection(); + + return parent::isLazy($object); + } + + /** + * {@inheritDoc} + */ + public function getRawValue(object $object): mixed + { + // With object we should call original reflection to bypass property hooks + $this->initializeInternalReflection(); + + return parent::getRawValue($object); + } + + /** + * {@inheritDoc} + */ + public function setRawValue(object $object, mixed $value): void + { + // With object we should call original reflection to bypass property hooks + $this->initializeInternalReflection(); + + parent::setRawValue($object, $value); + } + + /** + * {@inheritDoc} + */ + public function setRawValueWithoutLazyInitialization(object $object, mixed $value): void + { + // Laziness is a property of the concrete object, so original reflection is required + $this->initializeInternalReflection(); + + parent::setRawValueWithoutLazyInitialization($object, $value); + } + + /** + * {@inheritDoc} + */ + public function skipLazyInitialization(object $object): void + { + // Laziness is a property of the concrete object, so original reflection is required + $this->initializeInternalReflection(); + + parent::skipLazyInitialization($object); + } + /** * @inheritDoc */ diff --git a/src/Resolver/NodeExpressionResolver.php b/src/Resolver/NodeExpressionResolver.php index 3021fa7..c27b23a 100644 --- a/src/Resolver/NodeExpressionResolver.php +++ b/src/Resolver/NodeExpressionResolver.php @@ -13,6 +13,7 @@ namespace Go\ParserReflection\Resolver; use Go\ParserReflection\ReflectionClass; +use Go\ParserReflection\ReflectionEngine; use Go\ParserReflection\ReflectionException; use Go\ParserReflection\ReflectionFileNamespace; use PhpParser\Node; @@ -24,6 +25,9 @@ use PhpParser\Node\Scalar\MagicConst\Line; use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt\Expression; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor\CloningVisitor; +use PhpParser\NodeVisitorAbstract; use PhpParser\PrettyPrinter\Standard; use Closure; use ReflectionFunction; @@ -233,6 +237,12 @@ protected function resolveExprFuncCall(Expr\FuncCall $node): mixed } // Set isConstExpr so callers can reconstruct the expression as code even if resolution fails $this->isConstExpr = true; + if (!function_exists($functionName)) { + throw new ReflectionException( + "First-class callable syntax refers to the function '{$functionName}(...)' that is not defined, " . + "therefore it can not be resolved to a Closure." + ); + } $reflectedFunction = new ReflectionFunction($functionName); if (!$reflectedFunction->isInternal()) { throw new ReflectionException( @@ -240,9 +250,6 @@ protected function resolveExprFuncCall(Expr\FuncCall $node): mixed "to a Closure statically, as closures cannot be represented as code in proxies." ); } - if (!is_callable($functionName)) { - throw new ReflectionException("Function '{$functionName}' is not callable."); - } return Closure::fromCallable($functionName); } @@ -315,12 +322,16 @@ protected function resolveExprStaticCall(Expr\StaticCall $node): Closure $methodName = $resolvedName; } + // Set isConstExpr so callers can reconstruct the expression as code even if resolution fails + $this->isConstExpr = true; + + // is_callable() and Closure::fromCallable() would silently autoload the class, so it is loaded explicitly + $this->loadClassDefinition($className); + $callable = $className . '::' . $methodName; if (!is_callable($callable)) { throw new ReflectionException("'{$callable}' is not callable and cannot be used as a first-class callable."); } - // Set isConstExpr so callers can reconstruct the expression as code - $this->isConstExpr = true; return Closure::fromCallable($callable); } @@ -391,13 +402,40 @@ protected function resolveExprNew(Expr\New_ $node): object } // Use ReflectionClass to safely instantiate the class - if (!class_exists($className)) { + $this->loadClassDefinition($className); + if (!class_exists($className, false)) { throw new ReflectionException("Class '{$className}' does not exist and cannot be instantiated."); } $reflectionClass = new \ReflectionClass($className); return $reflectionClass->newInstance(...$resolvedArgs); } + /** + * Resolves a closure used as a constant expression, e.g. `const CALLBACK = static function (): int {...};` + * + * @throws ReflectionException If the closure can not be evaluated + */ + protected function resolveExprClosure(Expr\Closure $node): Closure + { + if ($node->uses !== []) { + throw new ReflectionException( + "Closure with captured variables can not be resolved, because there is no outer scope to capture." + ); + } + + return $this->evaluateClosureNode($node); + } + + /** + * Resolves an arrow function used as a constant expression, e.g. `const CALLBACK = static fn (): int => 1;` + * + * @throws ReflectionException If the arrow function can not be evaluated + */ + protected function resolveExprArrowFunction(Expr\ArrowFunction $node): Closure + { + return $this->evaluateClosureNode($node); + } + protected function resolveScalarFloat(Float_ $node): float { return $node->value; @@ -887,6 +925,116 @@ private function resolveInt(mixed $value): int return 0; } + /** + * Builds a real closure that is equivalent to the given closure-like node + * + * Closures in constant expressions are always static and never capture anything from the outer scope, + * therefore an equivalent closure can be built from the source code of the node itself. + * + * @throws ReflectionException If the given node can not be evaluated + */ + private function evaluateClosureNode(Expr\Closure|Expr\ArrowFunction $node): Closure + { + // Closure has a source code representation, so callers are able to reconstruct it even on failure + $this->isConstExpr = true; + + $closureNode = $this->resolveNodeNames($node); + if (!$closureNode instanceof Expr\Closure && !$closureNode instanceof Expr\ArrowFunction) { + throw new ReflectionException('Unexpected node type after closure name resolution.'); + } + // Constant expression closures are always static, this also prevents binding of the resolver itself + $closureNode->static = true; + + $printer = new Standard(['shortArraySyntax' => true]); + $closureSource = $printer->prettyPrintExpr($closureNode); + + try { + $closure = eval('return ' . $closureSource . ';'); + } catch (\Throwable $e) { + throw new ReflectionException("Could not evaluate the closure expression: {$e->getMessage()}", 0, $e); + } + if (!$closure instanceof Closure) { + throw new ReflectionException("Evaluation of the closure expression did not produce a closure."); + } + + // Closure is compiled in the scope of this class, thus the scope is dropped to mimic the original one + $unscopedClosure = Closure::bind($closure, null, null); + + return $unscopedClosure ?? $closure; + } + + /** + * Returns a deep copy of the given node with all resolved names replaced by fully-qualified ones + * + * Unqualified function and constant names are intentionally kept as is, because PHP resolves them at + * runtime with a fallback to the global namespace. + */ + private function resolveNodeNames(Node $node): Node + { + $cloningTraverser = new NodeTraverser(new CloningVisitor()); + [$clonedNode] = $cloningTraverser->traverse([$node]); + + $nameTraverser = new NodeTraverser(new class extends NodeVisitorAbstract { + public function enterNode(Node $node): ?Node + { + if ($node instanceof Name && $node->hasAttribute('resolvedName')) { + $resolvedName = $node->getAttribute('resolvedName'); + if ($resolvedName instanceof Name) { + return new Name\FullyQualified($resolvedName->toString(), $node->getAttributes()); + } + } + + return null; + } + }); + [$resolvedNode] = $nameTraverser->traverse([$clonedNode]); + + return $resolvedNode; + } + + /** + * Makes the definition of the given class available in the current runtime + * + * Reflection is performed on the AST only, but several expressions, such as object instantiation or + * first-class callables, can not be represented without a real class definition. For these cases the file + * with the class is resolved via the registered locator and included explicitly, thus an implicit + * autoloading is never triggered by the resolver itself. + * + * @throws ReflectionException If the class is not loaded and its file can not be located + */ + private function loadClassDefinition(string $className): void + { + if ($this->isClassDefinitionLoaded($className)) { + return; + } + + try { + $classFileName = ReflectionEngine::locateClassFile($className); + } catch (\Throwable $e) { + throw new ReflectionException( + "Class '{$className}' is not loaded and its file can not be found by the registered locator.", + 0, + $e + ); + } + + include_once $classFileName; + + if (!$this->isClassDefinitionLoaded($className)) { + throw new ReflectionException("Class '{$className}' was not found in the file '{$classFileName}'."); + } + } + + /** + * @phpstan-impure The result changes once the located class file has been included + */ + private function isClassDefinitionLoaded(string $className): bool + { + return class_exists($className, false) + || interface_exists($className, false) + || trait_exists($className, false); + } + private function getDispatchMethodFor(Node $node): string { $nodeType = $node->getType(); diff --git a/src/Traits/AttributeResolverTrait.php b/src/Traits/AttributeResolverTrait.php index 04477b3..2a5a95f 100644 --- a/src/Traits/AttributeResolverTrait.php +++ b/src/Traits/AttributeResolverTrait.php @@ -13,6 +13,7 @@ namespace Go\ParserReflection\Traits; use Go\ParserReflection\ReflectionAttribute; +use Go\ParserReflection\ReflectionClass as ParsedReflectionClass; use Go\ParserReflection\Resolver\NodeExpressionResolver; use PhpParser\Node\Name; use PhpParser\Node\Param; @@ -42,9 +43,17 @@ protected function getNodeForAttributes(): ClassLike|ClassMethod|Function_|Param */ public function getAttributes(?string $name = null, int $flags = 0): array { + if ($flags !== 0 && $flags !== \ReflectionAttribute::IS_INSTANCEOF) { + throw new \ValueError( + $this->getAttributeFilterOwnerName() + . '::getAttributes(): Argument #2 ($flags) must be a valid attribute filter flag' + ); + } + $node = $this->getNodeForAttributes(); - $attributes = []; + $filterByInstanceOf = $name !== null && ($flags & \ReflectionAttribute::IS_INSTANCEOF) !== 0; + $attributes = []; $nodeExpressionResolver = new NodeExpressionResolver($this); foreach ($node->attrGroups as $attrGroup) { @@ -67,6 +76,16 @@ public function getAttributes(?string $name = null, int $flags = 0): array continue; } + if ($filterByInstanceOf) { + if (!self::isAttributeInstanceOf($resolvedAttrName, $name)) { + continue; + } + + $attributes[] = new ReflectionAttribute($resolvedAttrName, $this, $arguments, $this->isAttributeRepeated($resolvedAttrName, $node->attrGroups)); + + continue; + } + if ($name !== $resolvedAttrName) { continue; } @@ -100,6 +119,93 @@ private static function resolveAttributeClassName(mixed $nameNode): string return $className; } + /** + * Returns the name of the internal reflection class that declares getAttributes(), used to build + * the same \ValueError message as the engine does for an invalid filter flag. + */ + private function getAttributeFilterOwnerName(): string + { + return match (true) { + $this instanceof \ReflectionFunctionAbstract => 'ReflectionFunctionAbstract', + $this instanceof \ReflectionParameter => 'ReflectionParameter', + $this instanceof \ReflectionProperty => 'ReflectionProperty', + $this instanceof \ReflectionClassConstant => 'ReflectionClassConstant', + default => 'ReflectionClass', + }; + } + + /** + * Checks that an attribute class is the given class, extends it or implements it, following the + * instanceof semantics of \ReflectionAttribute::IS_INSTANCEOF without triggering autoloading. + */ + private static function isAttributeInstanceOf(string $attributeClassName, string $filterClassName): bool + { + $filterClassName = strtolower(ltrim($filterClassName, '\\')); + if ($filterClassName === '') { + return false; + } + + $classNamesToVisit = [$attributeClassName]; + $visitedClassNames = []; + + while ($classNamesToVisit !== []) { + $currentClassName = ltrim(array_shift($classNamesToVisit), '\\'); + $lowerClassName = strtolower($currentClassName); + if ($lowerClassName === '' || isset($visitedClassNames[$lowerClassName])) { + continue; + } + $visitedClassNames[$lowerClassName] = true; + + if ($lowerClassName === $filterClassName) { + return true; + } + + foreach (self::resolveClassAncestorNames($currentClassName) as $ancestorClassName) { + $classNamesToVisit[] = $ancestorClassName; + } + } + + return false; + } + + /** + * Resolves direct and inherited ancestors of a class without loading it: already loaded classes are + * inspected with native reflection, everything else is resolved from the AST via the current locator. + * + * @return list + */ + private static function resolveClassAncestorNames(string $className): array + { + if (class_exists($className, false) || interface_exists($className, false)) { + $reflection = new \ReflectionClass($className); + } else { + try { + $reflection = new ParsedReflectionClass($className); + } catch (\Throwable) { + // Attribute classes are not required to exist until an attribute is instantiated + return []; + } + } + + $ancestorNames = []; + try { + $ancestorNames = $reflection->getInterfaceNames(); + } catch (\Throwable) { + // Unresolvable interfaces simply do not participate in the instanceof check + } + + try { + $parentClass = $reflection->getParentClass(); + if ($parentClass !== false) { + $ancestorNames[] = $parentClass->getName(); + } + } catch (\Throwable) { + // Same for an unresolvable parent class + } + + return $ancestorNames; + } + /** * @param \PhpParser\Node\AttributeGroup[] $attrGroups */ diff --git a/src/Traits/ReflectionClassLikeTrait.php b/src/Traits/ReflectionClassLikeTrait.php index 4c7131a..335b9e1 100644 --- a/src/Traits/ReflectionClassLikeTrait.php +++ b/src/Traits/ReflectionClassLikeTrait.php @@ -1046,6 +1046,102 @@ public function newInstanceWithoutConstructor(): object return parent::newInstanceWithoutConstructor(); } + /** + * Creates a new lazy ghost instance of the class. + * + * @link https://php.net/manual/en/reflectionclass.newlazyghost.php + */ + public function newLazyGhost(callable $initializer, int $options = 0): object + { + $this->initializeInternalReflection(); + + return parent::newLazyGhost($initializer, $options); + } + + /** + * Creates a new lazy proxy instance of the class. + * + * @link https://php.net/manual/en/reflectionclass.newlazyproxy.php + */ + public function newLazyProxy(callable $factory, int $options = 0): object + { + $this->initializeInternalReflection(); + + return parent::newLazyProxy($factory, $options); + } + + /** + * Resets an existing object and makes it a lazy ghost. + * + * @link https://php.net/manual/en/reflectionclass.resetaslazyghost.php + */ + public function resetAsLazyGhost(object $object, callable $initializer, int $options = 0): void + { + $this->initializeInternalReflection(); + + parent::resetAsLazyGhost($object, $initializer, $options); + } + + /** + * Resets an existing object and makes it a lazy proxy. + * + * @link https://php.net/manual/en/reflectionclass.resetaslazyproxy.php + */ + public function resetAsLazyProxy(object $object, callable $factory, int $options = 0): void + { + $this->initializeInternalReflection(); + + parent::resetAsLazyProxy($object, $factory, $options); + } + + /** + * Forces the initialization of a lazy object. + * + * @link https://php.net/manual/en/reflectionclass.initializelazyobject.php + */ + public function initializeLazyObject(object $object): object + { + $this->initializeInternalReflection(); + + return parent::initializeLazyObject($object); + } + + /** + * Checks if a lazy object is still uninitialized. + * + * @link https://php.net/manual/en/reflectionclass.isuninitializedlazyobject.php + */ + public function isUninitializedLazyObject(object $object): bool + { + $this->initializeInternalReflection(); + + return parent::isUninitializedLazyObject($object); + } + + /** + * Marks a lazy object as initialized, without calling the initializer or factory. + * + * @link https://php.net/manual/en/reflectionclass.marklazyobjectasinitialized.php + */ + public function markLazyObjectAsInitialized(object $object): object + { + $this->initializeInternalReflection(); + + return parent::markLazyObjectAsInitialized($object); + } + + /** + * Returns the initializer or factory of a lazy object, or null if it is not lazy anymore. + * + * @link https://php.net/manual/en/reflectionclass.getlazyinitializer.php + */ + public function getLazyInitializer(object $object): ?callable + { + $this->initializeInternalReflection(); + + return parent::getLazyInitializer($object); + } + /** * Sets static property value * diff --git a/src/Traits/ReflectionFunctionLikeTrait.php b/src/Traits/ReflectionFunctionLikeTrait.php index fd608d1..4666433 100644 --- a/src/Traits/ReflectionFunctionLikeTrait.php +++ b/src/Traits/ReflectionFunctionLikeTrait.php @@ -21,6 +21,7 @@ use PhpParser\Node\Expr\Closure; use PhpParser\Node\FunctionLike; use PhpParser\Node\Identifier; +use PhpParser\Node\Name; use PhpParser\Node\NullableType; use PhpParser\Node\Stmt\ClassMethod; use PhpParser\Node\Stmt\Function_; @@ -65,6 +66,18 @@ protected function getParentClassNameForTypes(): ?string */ protected array $parameters; + /** + * {@inheritDoc} + * + * Statically analysed code never holds a bound closure, so there is no called class to report. + * + * @return \ReflectionClass|null + */ + public function getClosureCalledClass(): ?\ReflectionClass + { + return null; + } + /** * {@inheritDoc} * @@ -87,6 +100,30 @@ public function getClosureThis(): ?object return parent::getClosureThis(); } + /** + * {@inheritDoc} + * + * Values of the used variables are only known at runtime, therefore every captured variable + * is reported with a `null` value. Non-closures report an empty list, just like the engine does. + * + * @return array + */ + public function getClosureUsedVariables(): array + { + if (!$this->functionLikeNode instanceof Closure) { + return []; + } + + $usedVariables = []; + foreach ($this->functionLikeNode->uses as $closureUse) { + if (is_string($closureUse->var->name)) { + $usedVariables[$closureUse->var->name] = null; + } + } + + return $usedVariables; + } + public function getDocComment(): string|false { $docComment = $this->functionLikeNode->getDocComment(); @@ -255,6 +292,19 @@ public function getStaticVariables(): array return $variablesCollector->getStaticVariables(); } + /** + * Returns the tentative return type of a function + * + * Tentative return types exist only for internal functions, user-land code always declares + * its return type explicitly, so there is nothing tentative to report. + * + * @link http://php.net/manual/en/reflectionfunctionabstract.gettentativereturntype.php + */ + public function getTentativeReturnType(): ?\ReflectionType + { + return null; + } + /** * Checks if the function has a specified return type * @@ -268,6 +318,16 @@ public function hasReturnType(): bool return isset($returnType); } + /** + * Checks if the function has a tentative return type + * + * @link http://php.net/manual/en/reflectionfunctionabstract.hastentativereturntype.php + */ + public function hasTentativeReturnType(): bool + { + return false; + } + /** * {@inheritDoc} */ @@ -289,10 +349,32 @@ public function isClosure(): bool */ public function isDeprecated(): bool { - // user-land method/function/closure can not be deprecated + // Since PHP 8.4 user-land functions and methods can be marked with the #[\Deprecated] attribute + foreach ($this->functionLikeNode->getAttrGroups() as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if (self::isDeprecatedAttributeName($attr->name)) { + return true; + } + } + } + return false; } + /** + * Checks statically, without any autoloading, if the given attribute name points to the + * global `\Deprecated` attribute class + */ + private static function isDeprecatedAttributeName(Name $attributeName): bool + { + $resolvedName = $attributeName->getAttribute('resolvedName'); + if ($resolvedName instanceof Name) { + $attributeName = $resolvedName; + } + + return strcasecmp(ltrim($attributeName->toString(), '\\'), 'Deprecated') === 0; + } + /** * {@inheritDoc} */ diff --git a/tests/AttributeInstanceOfFilterTest.php b/tests/AttributeInstanceOfFilterTest.php new file mode 100644 index 0000000..7cc116b --- /dev/null +++ b/tests/AttributeInstanceOfFilterTest.php @@ -0,0 +1,221 @@ +setUpAstOnlyLocator(); + + $parsedClass = new ReflectionClass(self::STUB_NAMESPACE . '\HookedClass'); + + $childAttributes = $parsedClass->getAttributes( + self::STUB_NAMESPACE . '\ChildAttribute', + InternalReflectionAttribute::IS_INSTANCEOF + ); + $this->assertCount(1, $childAttributes); + $this->assertSame(self::STUB_NAMESPACE . '\ChildAttribute', $childAttributes[0]->getName()); + $this->assertSame(['class-level'], $childAttributes[0]->getArguments()); + + $baseAttributes = $parsedClass->getAttributes( + self::STUB_NAMESPACE . '\BaseAttribute', + InternalReflectionAttribute::IS_INSTANCEOF + ); + $this->assertCount(1, $baseAttributes); + $this->assertSame(self::STUB_NAMESPACE . '\ChildAttribute', $baseAttributes[0]->getName()); + + $this->assertCount(0, $parsedClass->getAttributes(self::STUB_NAMESPACE . '\BaseAttribute')); + + $parsedProperty = $parsedClass->getProperty('hookedProperty'); + $inheritedAttributes = $parsedProperty->getAttributes( + self::STUB_NAMESPACE . '\BaseAttribute', + InternalReflectionAttribute::IS_INSTANCEOF + ); + $this->assertCount(1, $inheritedAttributes); + $this->assertSame(self::STUB_NAMESPACE . '\GrandChildAttribute', $inheritedAttributes[0]->getName()); + + $parsedMethod = $parsedClass->getMethod('hookedMethod'); + $this->assertCount(1, $parsedMethod->getAttributes( + self::STUB_NAMESPACE . '\MarkerInterface', + InternalReflectionAttribute::IS_INSTANCEOF + )); + $this->assertCount(1, $parsedMethod->getAttributes( + self::STUB_NAMESPACE . '\ExtendedMarkerInterface', + InternalReflectionAttribute::IS_INSTANCEOF + )); + $this->assertCount(0, $parsedMethod->getAttributes( + self::STUB_NAMESPACE . '\BaseAttribute', + InternalReflectionAttribute::IS_INSTANCEOF + )); + + $this->assertStubHierarchyIsNotLoaded(); + } + + public function testInstanceOfFilterIgnoresUnknownAttributeClasses(): void + { + $parsedFile = new ReflectionFile(self::STUB_FILE); + $parsedClass = $parsedFile + ->getFileNamespace(self::STUB_NAMESPACE) + ->getClass(self::STUB_NAMESPACE . '\HookedClass'); + + ReflectionEngine::init(new CallableLocator(fn(string $className): false|string => false)); + + $this->assertCount(0, $parsedClass->getAttributes( + self::STUB_NAMESPACE . '\BaseAttribute', + InternalReflectionAttribute::IS_INSTANCEOF + )); + $this->assertCount(2, $parsedClass->getAttributes()); + + $this->assertStubHierarchyIsNotLoaded(); + } + + public function testInvalidFilterFlagThrowsValueError(): void + { + $this->setUpAstOnlyLocator(); + + $parsedClass = new ReflectionClass(self::STUB_NAMESPACE . '\HookedClass'); + + $this->expectException(\ValueError::class); + $this->expectExceptionMessage( + 'ReflectionClass::getAttributes(): Argument #2 ($flags) must be a valid attribute filter flag' + ); + + $parsedClass->getAttributes(self::STUB_NAMESPACE . '\BaseAttribute', 3); + } + + public function testInvalidFilterFlagThrowsValueErrorForFunctionLike(): void + { + $this->setUpAstOnlyLocator(); + + $parsedMethod = (new ReflectionClass(self::STUB_NAMESPACE . '\HookedClass'))->getMethod('hookedMethod'); + + $this->expectException(\ValueError::class); + $this->expectExceptionMessage( + 'ReflectionFunctionAbstract::getAttributes(): Argument #2 ($flags) must be a valid attribute filter flag' + ); + + $parsedMethod->getAttributes(self::STUB_NAMESPACE . '\BaseAttribute', 3); + } + + /** + * Runs isolated to keep the stub hierarchy unloaded for the AST-only test cases + */ + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testInstanceOfFilterMatchesNativeReflectionForLoadedClasses(): void + { + require_once self::STUB_FILE; + + $className = self::STUB_NAMESPACE . '\HookedClass'; + $parsedClass = new ReflectionClass($className); + $nativeClass = new \ReflectionClass($className); + $filterNames = [ + self::STUB_NAMESPACE . '\BaseAttribute', + self::STUB_NAMESPACE . '\ChildAttribute', + self::STUB_NAMESPACE . '\GrandChildAttribute', + self::STUB_NAMESPACE . '\MarkerInterface', + self::STUB_NAMESPACE . '\ExtendedMarkerInterface', + self::STUB_NAMESPACE . '\UnrelatedAttribute', + ]; + + foreach ($filterNames as $filterName) { + $this->assertSame( + $this->describeAttributes($nativeClass->getAttributes($filterName, InternalReflectionAttribute::IS_INSTANCEOF)), + $this->describeAttributes($parsedClass->getAttributes($filterName, InternalReflectionAttribute::IS_INSTANCEOF)), + "Class attributes filtered by $filterName" + ); + + $this->assertSame( + $this->describeAttributes($nativeClass->getMethod('hookedMethod')->getAttributes($filterName, InternalReflectionAttribute::IS_INSTANCEOF)), + $this->describeAttributes($parsedClass->getMethod('hookedMethod')->getAttributes($filterName, InternalReflectionAttribute::IS_INSTANCEOF)), + "Method attributes filtered by $filterName" + ); + + $this->assertSame( + $this->describeAttributes($nativeClass->getProperty('hookedProperty')->getAttributes($filterName, InternalReflectionAttribute::IS_INSTANCEOF)), + $this->describeAttributes($parsedClass->getProperty('hookedProperty')->getAttributes($filterName, InternalReflectionAttribute::IS_INSTANCEOF)), + "Property attributes filtered by $filterName" + ); + + $this->assertSame( + $this->describeAttributes($nativeClass->getAttributes($filterName)), + $this->describeAttributes($parsedClass->getAttributes($filterName)), + "Class attributes filtered by exact name $filterName" + ); + } + + $this->assertSame( + $this->describeAttributes($nativeClass->getAttributes(null, InternalReflectionAttribute::IS_INSTANCEOF)), + $this->describeAttributes($parsedClass->getAttributes(null, InternalReflectionAttribute::IS_INSTANCEOF)), + 'Unfiltered attributes are not affected by the flag' + ); + + // Unlike the engine, an unresolvable filter class never triggers autoloading and matches nothing + $this->assertSame([], $parsedClass->getAttributes( + self::STUB_NAMESPACE . '\MissingAttribute', + InternalReflectionAttribute::IS_INSTANCEOF + )); + } + + /** + * @param \ReflectionAttribute[] $attributes + * + * @return array}> + */ + private function describeAttributes(array $attributes): array + { + $description = []; + foreach ($attributes as $attribute) { + $description[] = [$attribute->getName(), $attribute->getArguments()]; + } + + return $description; + } + + private function setUpAstOnlyLocator(): void + { + ReflectionEngine::init(new CallableLocator( + fn(string $className): false|string => str_starts_with($className, self::STUB_NAMESPACE . '\\') + ? self::STUB_FILE + : false + )); + } + + private function assertStubHierarchyIsNotLoaded(): void + { + foreach (['HookedClass', 'BaseAttribute', 'ChildAttribute', 'GrandChildAttribute', 'InterfaceAttribute'] as $shortName) { + $this->assertFalse( + class_exists(self::STUB_NAMESPACE . '\\' . $shortName, false), + "Class $shortName must not be loaded by the AST-based instanceof resolution" + ); + } + + foreach (['MarkerInterface', 'ExtendedMarkerInterface'] as $shortName) { + $this->assertFalse( + interface_exists(self::STUB_NAMESPACE . '\\' . $shortName, false), + "Interface $shortName must not be loaded by the AST-based instanceof resolution" + ); + } + } +} diff --git a/tests/ConstantExpressionClosuresTest.php b/tests/ConstantExpressionClosuresTest.php new file mode 100644 index 0000000..902403d --- /dev/null +++ b/tests/ConstantExpressionClosuresTest.php @@ -0,0 +1,266 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection; + +use Go\ParserReflection\Locator\CallableLocator; +use Go\ParserReflection\Locator\ComposerLocator; +use Go\ParserReflection\Resolver\NodeExpressionResolver; +use PhpParser\Parser; +use PhpParser\ParserFactory; +use PHPUnit\Framework\TestCase; + +/** + * Verifies resolving of closures, arrow functions and first-class callables used in constant expressions. + * + * Stub file with such constant expressions uses PHP 8.5 syntax, therefore it is only parsed, never included. + */ +class ConstantExpressionClosuresTest extends TestCase +{ + public const CLOSURES_STUB_FILE = '/Stub/FileWithConstExprClosures85.php'; + + public const INITIALIZERS_STUB_FILE = '/Stub/FileWithNewInInitializers81.php'; + + public const STUB_NAMESPACE = 'Go\ParserReflection\Stub'; + + public const CLOSURES_STUB_CLASS = 'Go\ParserReflection\Stub\ClassWithConstExprClosures'; + + public const ATTRIBUTE_STUB_CLASS = 'Go\ParserReflection\Stub\ConstExprClosureAttribute'; + + public const INITIALIZERS_STUB_CLASS = 'Go\ParserReflection\Stub\ClassWithNewInInitializers'; + + public const INITIALIZERS_DEPENDENCY_CLASS = 'Go\ParserReflection\Stub\NewInInitializerDependency'; + + private string $closuresFileName; + + private string $initializersFileName; + + private Parser $parser; + + protected function setUp(): void + { + $this->closuresFileName = $this->resolveStubFile(self::CLOSURES_STUB_FILE); + $this->initializersFileName = $this->resolveStubFile(self::INITIALIZERS_STUB_FILE); + $this->parser = (new ParserFactory())->createForNewestSupportedVersion(); + + ReflectionEngine::init($this->createStubLocator([ + self::CLOSURES_STUB_CLASS => $this->closuresFileName, + self::ATTRIBUTE_STUB_CLASS => $this->closuresFileName, + self::INITIALIZERS_STUB_CLASS => $this->initializersFileName, + self::INITIALIZERS_DEPENDENCY_CLASS => $this->initializersFileName, + ])); + } + + protected function tearDown(): void + { + // Restores the default locator for the following tests, because this one replaces it + ReflectionEngine::init(new ComposerLocator()); + } + + public function testGlobalConstantsWithClosuresAreResolved(): void + { + $constants = $this->getStubFileNamespace()->getConstants(); + + $this->assertArrayHasKey('CLOSURE_CONST', $constants); + $this->assertInstanceOf(\Closure::class, $constants['CLOSURE_CONST']); + $this->assertSame(10, ($constants['CLOSURE_CONST'])(5)); + + $this->assertArrayHasKey('ARROW_FUNCTION_CONST', $constants); + $this->assertInstanceOf(\Closure::class, $constants['ARROW_FUNCTION_CONST']); + $this->assertSame(15, ($constants['ARROW_FUNCTION_CONST'])(5)); + + // Sibling constants should not be poisoned by the presence of closures in the same file + $this->assertSame('plain', $constants['PLAIN_CONST']); + } + + public function testGlobalFirstClassCallableConstantsAreResolved(): void + { + $constants = $this->getStubFileNamespace()->getConstants(); + + $this->assertInstanceOf(\Closure::class, $constants['FCC_CONST']); + $this->assertSame(6, ($constants['FCC_CONST'])('foobar')); + + $this->assertInstanceOf(\Closure::class, $constants['UNQUALIFIED_FCC_CONST']); + $this->assertSame('ABC', ($constants['UNQUALIFIED_FCC_CONST'])('abc')); + } + + public function testClassConstantsWithClosuresAreResolved(): void + { + $parsedClass = $this->getStubFileNamespace()->getClass(self::CLOSURES_STUB_CLASS); + $constants = $parsedClass->getConstants(); + + $this->assertInstanceOf(\Closure::class, $constants['CALLBACK']); + $this->assertSame(2, ($constants['CALLBACK'])(1)); + + $this->assertInstanceOf(\Closure::class, $constants['DOUBLER']); + $this->assertSame(8, ($constants['DOUBLER'])(4)); + + $this->assertInstanceOf(\Closure::class, $constants['UPPERCASE']); + $this->assertSame('ABC', ($constants['UPPERCASE'])('abc')); + + $this->assertSame('simple', $constants['PLAIN']); + $this->assertFalse(class_exists(self::CLOSURES_STUB_CLASS, false), 'Stub class should not be loaded'); + } + + public function testParameterDefaultValueWithClosureIsResolved(): void + { + $parsedClass = $this->getStubFileNamespace()->getClass(self::CLOSURES_STUB_CLASS); + $parsedMethod = $parsedClass->getMethod('methodWithClosureDefault'); + $parameter = $parsedMethod->getParameters()[0]; + + $this->assertTrue($parameter->isDefaultValueAvailable()); + $defaultValue = $parameter->getDefaultValue(); + $this->assertInstanceOf(\Closure::class, $defaultValue); + $this->assertSame(1, $defaultValue()); + + $expression = $parameter->getDefaultValueExpression(); + $this->assertNotNull($expression); + $this->assertStringContainsString('static function', $expression); + } + + public function testParameterDefaultValueWithArrowFunctionIsResolved(): void + { + $parsedClass = $this->getStubFileNamespace()->getClass(self::CLOSURES_STUB_CLASS); + $parsedMethod = $parsedClass->getMethod('methodWithArrowFunctionDefault'); + $parameter = $parsedMethod->getParameters()[0]; + + $defaultValue = $parameter->getDefaultValue(); + $this->assertInstanceOf(\Closure::class, $defaultValue); + $this->assertSame(12, $defaultValue(4)); + $this->assertStringContainsString('static fn', (string) $parameter->getDefaultValueExpression()); + } + + public function testPropertyDefaultValueWithArrowFunctionIsResolved(): void + { + $parsedClass = $this->getStubFileNamespace()->getClass(self::CLOSURES_STUB_CLASS); + $parsedProperty = $parsedClass->getProperty('handler'); + + $defaultValue = $parsedProperty->getDefaultValue(); + $this->assertInstanceOf(\Closure::class, $defaultValue); + $this->assertSame('trimmed', $defaultValue(' trimmed ')); + } + + public function testAttributeArgumentWithArrowFunctionIsResolved(): void + { + $parsedClass = $this->getStubFileNamespace()->getClass(self::CLOSURES_STUB_CLASS); + $attributes = $parsedClass->getAttributes(); + + $this->assertCount(1, $attributes); + $this->assertSame(self::ATTRIBUTE_STUB_CLASS, $attributes[0]->getName()); + + $arguments = $attributes[0]->getArguments(); + $this->assertInstanceOf(\Closure::class, $arguments[0]); + $this->assertSame(25, ($arguments[0])(5)); + } + + public function testResolvedClosureIsStaticAndHasNoScope(): void + { + $constants = $this->getStubFileNamespace()->getConstants(); + + $closureReflection = new \ReflectionFunction($constants['CLOSURE_CONST']); + $this->assertNull($closureReflection->getClosureThis()); + $this->assertNull($closureReflection->getClosureScopeClass()); + } + + public function testClosureWithCapturedVariablesIsNotResolved(): void + { + $this->expectException(ReflectionException::class); + $this->expectExceptionMessageMatches('/captured variables/'); + + $nodes = $this->parser->parse('process($nodes[0]); + } + + public function testFirstClassCallableForUnknownFunctionThrowsDescriptiveException(): void + { + $nodes = $this->parser->parse('process($nodes[0]); + $this->fail('Expected ReflectionException was not thrown'); + } catch (ReflectionException $exception) { + $this->assertStringNotContainsString('Could not find handler', $exception->getMessage()); + $this->assertMatchesRegularExpression('/is not defined/', $exception->getMessage()); + } + } + + public function testNewInInitializerDoesNotTriggerAutoloading(): void + { + $this->assertFalse( + class_exists(self::INITIALIZERS_DEPENDENCY_CLASS, false), + 'Dependency class should not be loaded before the test' + ); + + $autoloadedClasses = []; + $autoloadSpy = static function (string $className) use (&$autoloadedClasses): void { + $autoloadedClasses[] = $className; + }; + spl_autoload_register($autoloadSpy); + + try { + $reflectionFile = new ReflectionFile($this->initializersFileName); + $reflectionNamespace = $reflectionFile->getFileNamespace(self::STUB_NAMESPACE); + $parsedMethod = $reflectionNamespace->getClass(self::INITIALIZERS_STUB_CLASS) + ->getMethod('withDependency'); + + $defaultValue = $parsedMethod->getParameters()[0]->getDefaultValue(); + } finally { + spl_autoload_unregister($autoloadSpy); + } + + $this->assertIsObject($defaultValue); + $this->assertSame(self::INITIALIZERS_DEPENDENCY_CLASS, $defaultValue::class); + $this->assertSame('injected', $defaultValue->label); + $this->assertNotContains( + self::INITIALIZERS_DEPENDENCY_CLASS, + $autoloadedClasses, + 'Instantiation of a class in the initializer should not trigger the autoloader' + ); + } + + public function testNewExpressionForUnknownClassThrowsDescriptiveException(): void + { + $this->expectException(ReflectionException::class); + $this->expectExceptionMessageMatches('/can not be found by the registered locator/'); + + $nodes = $this->parser->parse('process($nodes[0]); + } + + private function getStubFileNamespace(): ReflectionFileNamespace + { + return (new ReflectionFile($this->closuresFileName))->getFileNamespace(self::STUB_NAMESPACE); + } + + /** + * @param array $classMap + */ + private function createStubLocator(array $classMap): CallableLocator + { + $composerLocator = new ComposerLocator(); + + return new CallableLocator( + static fn (string $className): false|string + => $classMap[$className] ?? $composerLocator->locateClass($className) + ); + } + + private function resolveStubFile(string $stubFileName): string + { + $resolvedFileName = stream_resolve_include_path(__DIR__ . $stubFileName); + $this->assertIsString($resolvedFileName, "Stub file {$stubFileName} should be available"); + + return $resolvedFileName; + } +} diff --git a/tests/DeprecatedAndFunctionLikeGapsTest.php b/tests/DeprecatedAndFunctionLikeGapsTest.php new file mode 100644 index 0000000..beb0f56 --- /dev/null +++ b/tests/DeprecatedAndFunctionLikeGapsTest.php @@ -0,0 +1,272 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection; + +use Go\ParserReflection\Locator\CallableLocator; +use Go\ParserReflection\Locator\ComposerLocator; +use PHPUnit\Framework\TestCase; + +/** + * Verifies the function-like reflection methods that used to fall back to the uninitialized + * internal reflection, together with the PHP 8.4 `#[\Deprecated]` attribute support. + * + * Test methods that must not load the stub file are declared before the ones comparing the + * results with the native reflection, because the latter have to include the stub file. + */ +class DeprecatedAndFunctionLikeGapsTest extends TestCase +{ + public const STUB_FILE = '/Stub/FileWithDeprecatedFeatures84.php'; + + public const STUB_NAMESPACE = 'Go\ParserReflection\Stub'; + + public const STUB_CLASS = 'Go\ParserReflection\Stub\ClassWithPhp84DeprecatedFeatures'; + + /** + * Names of the deprecated methods of the stub class + */ + private const DEPRECATED_METHODS = ['deprecatedMethod', 'deprecatedStaticMethod', 'importedDeprecatedMethod']; + + /** + * Names of the deprecated constants of the stub class + */ + private const DEPRECATED_CONSTANTS = [ + 'DEPRECATED_CONSTANT', + 'DEPRECATED_CONSTANT_WITH_ARGUMENTS', + 'IMPORTED_DEPRECATED_CONSTANT', + ]; + + /** + * Names of the deprecated functions of the stub file, without the namespace prefix + */ + private const DEPRECATED_FUNCTIONS = [ + 'php84DeprecatedFunction', + 'php84DeprecatedFunctionWithArguments', + 'php84ImportedDeprecatedFunction', + ]; + + private string $stubFileName; + + protected function setUp(): void + { + $resolvedFileName = stream_resolve_include_path(__DIR__ . self::STUB_FILE); + $this->assertIsString($resolvedFileName, 'Stub file with deprecated features should be available'); + + $this->stubFileName = $resolvedFileName; + } + + protected function tearDown(): void + { + // Restores the default locator for the following tests, because some of them replace it + ReflectionEngine::init(new ComposerLocator()); + } + + public function testMethodDeprecationIsResolvedWithoutLoadingTheClass(): void + { + $parsedClass = $this->getNotLoadedStubClass(); + + foreach (self::DEPRECATED_METHODS as $methodName) { + $this->assertTrue( + $parsedClass->getMethod($methodName)->isDeprecated(), + "Method {$methodName}() should be reported as deprecated" + ); + } + $this->assertFalse($parsedClass->getMethod('actualMethod')->isDeprecated()); + $this->assertStubClassIsNotLoaded(); + } + + public function testConstantDeprecationIsResolvedWithoutLoadingTheClass(): void + { + $parsedClass = $this->getNotLoadedStubClass(); + + foreach (self::DEPRECATED_CONSTANTS as $constantName) { + $this->assertTrue( + $parsedClass->getReflectionConstant($constantName)->isDeprecated(), + "Constant {$constantName} should be reported as deprecated" + ); + } + $this->assertFalse($parsedClass->getReflectionConstant('ACTUAL_CONSTANT')->isDeprecated()); + $this->assertStubClassIsNotLoaded(); + } + + public function testFunctionLikeGapsAreResolvedWithoutLoadingTheClass(): void + { + $parsedClass = $this->getNotLoadedStubClass(); + $parsedMethod = $parsedClass->getMethod('actualMethod'); + + $this->assertFalse($parsedMethod->hasTentativeReturnType()); + $this->assertNull($parsedMethod->getTentativeReturnType()); + $this->assertSame([], $parsedMethod->getClosureUsedVariables()); + $this->assertNull($parsedMethod->getClosureCalledClass()); + $this->assertStubClassIsNotLoaded(); + } + + public function testCreateFromMethodNameDoesNotLoadTheClass(): void + { + ReflectionEngine::init($this->createStubLocator()); + + $parsedMethod = ReflectionMethod::createFromMethodName(self::STUB_CLASS . '::deprecatedStaticMethod'); + + $this->assertInstanceOf(ReflectionMethod::class, $parsedMethod); + $this->assertSame('deprecatedStaticMethod', $parsedMethod->getName()); + $this->assertSame(self::STUB_CLASS, $parsedMethod->getDeclaringClass()->getName()); + $this->assertTrue($parsedMethod->isStatic()); + $this->assertTrue($parsedMethod->isDeprecated()); + $this->assertStubClassIsNotLoaded(); + } + + public function testCreateFromMethodNameRejectsInvalidMethodName(): void + { + $this->expectException(\ReflectionException::class); + + ReflectionMethod::createFromMethodName('thisIsNotAMethodName'); + } + + public function testFunctionDeprecationIsResolvedWithoutLoadingTheFile(): void + { + $parsedNamespace = $this->getStubFileNamespace(); + + foreach (self::DEPRECATED_FUNCTIONS as $functionName) { + $this->assertFalse( + function_exists(self::STUB_NAMESPACE . '\\' . $functionName), + "Function {$functionName}() should not be declared yet" + ); + $this->assertTrue( + $parsedNamespace->getFunction($functionName)->isDeprecated(), + "Function {$functionName}() should be reported as deprecated" + ); + } + $this->assertFalse($parsedNamespace->getFunction('php84PlainFunction')->isDeprecated()); + } + + public function testMethodParityWithNativeReflection(): void + { + $parsedClass = $this->includeStubFileAndGetParsedClass(); + $nativeClass = new \ReflectionClass(self::STUB_CLASS); + + foreach ($nativeClass->getMethods() as $nativeMethod) { + $methodName = $nativeMethod->getName(); + $parsedMethod = $parsedClass->getMethod($methodName); + $message = "Method {$methodName}() should be reflected as the native one"; + + $this->assertSame($nativeMethod->isDeprecated(), $parsedMethod->isDeprecated(), $message); + $this->assertSame($nativeMethod->hasTentativeReturnType(), $parsedMethod->hasTentativeReturnType(), $message); + $this->assertSame($nativeMethod->getTentativeReturnType(), $parsedMethod->getTentativeReturnType(), $message); + $this->assertSame($nativeMethod->getClosureUsedVariables(), $parsedMethod->getClosureUsedVariables(), $message); + $this->assertSame($nativeMethod->getClosureCalledClass(), $parsedMethod->getClosureCalledClass(), $message); + } + } + + public function testConstantParityWithNativeReflection(): void + { + $parsedClass = $this->includeStubFileAndGetParsedClass(); + $nativeClass = new \ReflectionClass(self::STUB_CLASS); + + foreach ($nativeClass->getReflectionConstants() as $nativeConstant) { + $constantName = $nativeConstant->getName(); + $this->assertSame( + $nativeConstant->isDeprecated(), + $parsedClass->getReflectionConstant($constantName)->isDeprecated(), + "Constant {$constantName} should be reflected as the native one" + ); + } + } + + public function testFunctionParityWithNativeReflection(): void + { + $parsedNamespace = $this->includeStubFileAndGetParsedNamespace(); + + $functionNames = array_merge(self::DEPRECATED_FUNCTIONS, ['php84PlainFunction']); + foreach ($functionNames as $functionName) { + $parsedFunction = $parsedNamespace->getFunction($functionName); + $nativeFunction = new \ReflectionFunction(self::STUB_NAMESPACE . '\\' . $functionName); + $message = "Function {$functionName}() should be reflected as the native one"; + + $this->assertSame($nativeFunction->isDeprecated(), $parsedFunction->isDeprecated(), $message); + $this->assertSame($nativeFunction->isAnonymous(), $parsedFunction->isAnonymous(), $message); + $this->assertSame($nativeFunction->isStatic(), $parsedFunction->isStatic(), $message); + $this->assertSame($nativeFunction->hasTentativeReturnType(), $parsedFunction->hasTentativeReturnType(), $message); + $this->assertSame($nativeFunction->getTentativeReturnType(), $parsedFunction->getTentativeReturnType(), $message); + $this->assertSame($nativeFunction->getClosureUsedVariables(), $parsedFunction->getClosureUsedVariables(), $message); + $this->assertSame($nativeFunction->getClosureCalledClass(), $parsedFunction->getClosureCalledClass(), $message); + } + } + + public function testCreateFromMethodNameParityWithNativeReflection(): void + { + $this->includeStubFile(); + + $methodReference = self::STUB_CLASS . '::deprecatedStaticMethod'; + $parsedMethod = ReflectionMethod::createFromMethodName($methodReference); + $nativeMethod = \ReflectionMethod::createFromMethodName($methodReference); + + $this->assertInstanceOf(ReflectionMethod::class, $parsedMethod); + $this->assertSame($nativeMethod->getName(), $parsedMethod->getName()); + $this->assertSame($nativeMethod->getDeclaringClass()->getName(), $parsedMethod->getDeclaringClass()->getName()); + $this->assertSame($nativeMethod->isStatic(), $parsedMethod->isStatic()); + $this->assertSame($nativeMethod->isPublic(), $parsedMethod->isPublic()); + $this->assertSame($nativeMethod->isDeprecated(), $parsedMethod->isDeprecated()); + } + + /** + * Returns a locator that resolves the stub class without asking composer for it + */ + private function createStubLocator(): CallableLocator + { + $stubFileName = $this->stubFileName; + + return new CallableLocator( + static fn(string $className): false|string + => $className === self::STUB_CLASS ? $stubFileName : false + ); + } + + /** + * Returns the parsed stub class, resolved by name only, without loading it into memory + */ + private function getNotLoadedStubClass(): ReflectionClass + { + ReflectionEngine::init($this->createStubLocator()); + + return new ReflectionClass(self::STUB_CLASS); + } + + private function getStubFileNamespace(): ReflectionFileNamespace + { + $reflectionFile = new ReflectionFile($this->stubFileName); + + return $reflectionFile->getFileNamespace(self::STUB_NAMESPACE); + } + + private function includeStubFile(): void + { + include_once $this->stubFileName; + } + + private function includeStubFileAndGetParsedClass(): ReflectionClass + { + $this->includeStubFile(); + + return $this->getStubFileNamespace()->getClass(self::STUB_CLASS); + } + + private function includeStubFileAndGetParsedNamespace(): ReflectionFileNamespace + { + $this->includeStubFile(); + + return $this->getStubFileNamespace(); + } + + private function assertStubClassIsNotLoaded(): void + { + $this->assertFalse(class_exists(self::STUB_CLASS, false), 'Stub class should not be loaded'); + } +} diff --git a/tests/EnumCaseAndLazyObjectTest.php b/tests/EnumCaseAndLazyObjectTest.php new file mode 100644 index 0000000..c6644a1 --- /dev/null +++ b/tests/EnumCaseAndLazyObjectTest.php @@ -0,0 +1,217 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection; + +use Go\ParserReflection\Stub\ClassForLazyObjects; +use PHPUnit\Framework\TestCase; + +/** + * Tests for the inherited \ReflectionClassConstant methods on enum cases and for the PHP 8.4 lazy-object API + * + * @see ReflectionEnumUnitCase + * @see ReflectionEnumBackedCase + * @see \Go\ParserReflection\Traits\ReflectionClassLikeTrait + */ +class EnumCaseAndLazyObjectTest extends TestCase +{ + private const STUB_FILENAME = '/Stub/FileWithEnumCases84.php'; + + private const BACKED_ENUM_NAME = 'Go\ParserReflection\Stub\EnumCaseSuit'; + + private const UNIT_ENUM_NAME = 'Go\ParserReflection\Stub\EnumCaseDirection'; + + private ReflectionFileNamespace $parsedRefFileNamespace; + + protected function setUp(): void + { + $fileName = stream_resolve_include_path(__DIR__ . self::STUB_FILENAME); + $fileNode = ReflectionEngine::parseFile($fileName); + + $reflectionFile = new ReflectionFile($fileName, $fileNode); + + $this->parsedRefFileNamespace = $reflectionFile->getFileNamespace('Go\ParserReflection\Stub'); + } + + /** + * Enum cases must answer the inherited constant methods from the AST only, without loading the enum + */ + public function testEnumCaseMethodsAreResolvedWithoutLoadingEnum(): void + { + $this->assertFalse(enum_exists(self::BACKED_ENUM_NAME, false), 'Backed enum should not be loaded yet'); + $this->assertFalse(enum_exists(self::UNIT_ENUM_NAME, false), 'Unit enum should not be loaded yet'); + + $parsedBackedEnum = $this->getParsedEnum(self::BACKED_ENUM_NAME); + + $deprecatedBackedCase = $parsedBackedEnum->getCase('Hearts'); + $this->assertInstanceOf(ReflectionEnumBackedCase::class, $deprecatedBackedCase); + $this->assertSame(\ReflectionClassConstant::IS_PUBLIC, $deprecatedBackedCase->getModifiers()); + $this->assertFalse($deprecatedBackedCase->hasType()); + $this->assertNull($deprecatedBackedCase->getType()); + $this->assertTrue($deprecatedBackedCase->isDeprecated()); + + $plainBackedCase = $parsedBackedEnum->getCase('Spades'); + $this->assertSame(\ReflectionClassConstant::IS_PUBLIC, $plainBackedCase->getModifiers()); + $this->assertFalse($plainBackedCase->hasType()); + $this->assertNull($plainBackedCase->getType()); + $this->assertFalse($plainBackedCase->isDeprecated()); + + $parsedUnitEnum = $this->getParsedEnum(self::UNIT_ENUM_NAME); + + $plainUnitCase = $parsedUnitEnum->getCase('Up'); + $this->assertInstanceOf(ReflectionEnumUnitCase::class, $plainUnitCase); + $this->assertSame(\ReflectionClassConstant::IS_PUBLIC, $plainUnitCase->getModifiers()); + $this->assertFalse($plainUnitCase->hasType()); + $this->assertNull($plainUnitCase->getType()); + $this->assertFalse($plainUnitCase->isDeprecated()); + + $deprecatedUnitCase = $parsedUnitEnum->getCase('Down'); + $this->assertSame(\ReflectionClassConstant::IS_PUBLIC, $deprecatedUnitCase->getModifiers()); + $this->assertTrue($deprecatedUnitCase->isDeprecated()); + + $this->assertFalse(enum_exists(self::BACKED_ENUM_NAME, false), 'Backed enum should still not be loaded'); + $this->assertFalse(enum_exists(self::UNIT_ENUM_NAME, false), 'Unit enum should still not be loaded'); + } + + /** + * Parsed enum cases should give exactly the same answers as the native reflection for a loaded enum + */ + public function testEnumCaseMethodsMatchNativeReflection(): void + { + $this->loadStubFile(); + + foreach ([self::BACKED_ENUM_NAME, self::UNIT_ENUM_NAME] as $enumName) { + $parsedEnum = $this->getParsedEnum($enumName); + /** @var \ReflectionEnum<\UnitEnum> $nativeEnum */ + $nativeEnum = new \ReflectionEnum($enumName); + + foreach ($nativeEnum->getCases() as $nativeCase) { + $caseName = $nativeCase->getName(); + $parsedCase = $parsedEnum->getCase($caseName); + $message = $enumName . '::' . $caseName; + + $this->assertSame($nativeCase->getModifiers(), $parsedCase->getModifiers(), $message); + $this->assertSame($nativeCase->hasType(), $parsedCase->hasType(), $message); + $this->assertSame($nativeCase->getType(), $parsedCase->getType(), $message); + $this->assertSame($nativeCase->isDeprecated(), $parsedCase->isDeprecated(), $message); + } + } + } + + /** + * Lazy ghosts created from a parsed class should behave exactly like the native ones + */ + public function testNewLazyGhost(): void + { + $this->loadStubFile(); + + $parsedClass = $this->parsedRefFileNamespace->getClass(ClassForLazyObjects::class); + $nativeClass = new \ReflectionClass(ClassForLazyObjects::class); + + $initializer = static function (ClassForLazyObjects $instance): void { + $instance->value = 42; + $instance->title = 'initialized'; + }; + + $parsedGhost = $parsedClass->newLazyGhost($initializer); + $nativeGhost = $nativeClass->newLazyGhost($initializer); + + $this->assertInstanceOf(ClassForLazyObjects::class, $parsedGhost); + $this->assertTrue($parsedClass->isUninitializedLazyObject($parsedGhost)); + $this->assertSame($nativeClass->isUninitializedLazyObject($nativeGhost), $parsedClass->isUninitializedLazyObject($parsedGhost)); + $this->assertIsCallable($parsedClass->getLazyInitializer($parsedGhost)); + + $this->assertSame($parsedGhost, $parsedClass->initializeLazyObject($parsedGhost)); + $this->assertFalse($parsedClass->isUninitializedLazyObject($parsedGhost)); + $this->assertNull($parsedClass->getLazyInitializer($parsedGhost)); + $this->assertSame(42, $parsedGhost->value); + $this->assertSame('initialized', $parsedGhost->title); + } + + /** + * Lazy proxies created from a parsed class should delegate to the real instance built by the factory + */ + public function testNewLazyProxy(): void + { + $this->loadStubFile(); + + $parsedClass = $this->parsedRefFileNamespace->getClass(ClassForLazyObjects::class); + + $parsedProxy = $parsedClass->newLazyProxy(static fn(): ClassForLazyObjects => new ClassForLazyObjects(7)); + + $this->assertInstanceOf(ClassForLazyObjects::class, $parsedProxy); + $this->assertTrue($parsedClass->isUninitializedLazyObject($parsedProxy)); + + $realInstance = $parsedClass->initializeLazyObject($parsedProxy); + $this->assertFalse($parsedClass->isUninitializedLazyObject($parsedProxy)); + $this->assertSame(7, $realInstance->value); + $this->assertSame(7, $parsedProxy->value); + } + + /** + * An existing instance should be resettable to a lazy ghost or to a lazy proxy + */ + public function testResetAsLazyGhostAndProxy(): void + { + $this->loadStubFile(); + + $parsedClass = $this->parsedRefFileNamespace->getClass(ClassForLazyObjects::class); + + $ghostCandidate = new ClassForLazyObjects(5); + $this->assertFalse($parsedClass->isUninitializedLazyObject($ghostCandidate)); + $this->assertNull($parsedClass->getLazyInitializer($ghostCandidate)); + + $parsedClass->resetAsLazyGhost($ghostCandidate, static function (ClassForLazyObjects $instance): void { + $instance->value = 99; + }); + $this->assertTrue($parsedClass->isUninitializedLazyObject($ghostCandidate)); + $this->assertSame(99, $ghostCandidate->value); + $this->assertFalse($parsedClass->isUninitializedLazyObject($ghostCandidate)); + + $proxyCandidate = new ClassForLazyObjects(5); + $parsedClass->resetAsLazyProxy($proxyCandidate, static fn(): ClassForLazyObjects => new ClassForLazyObjects(3)); + $this->assertTrue($parsedClass->isUninitializedLazyObject($proxyCandidate)); + $this->assertSame(3, $proxyCandidate->value); + $this->assertFalse($parsedClass->isUninitializedLazyObject($proxyCandidate)); + } + + /** + * Marking a lazy object as initialized should skip the initializer and keep declared default values + */ + public function testMarkLazyObjectAsInitialized(): void + { + $this->loadStubFile(); + + $parsedClass = $this->parsedRefFileNamespace->getClass(ClassForLazyObjects::class); + + $parsedGhost = $parsedClass->newLazyGhost(static function (ClassForLazyObjects $instance): void { + $instance->value = 1000; + }); + + $this->assertSame($parsedGhost, $parsedClass->markLazyObjectAsInitialized($parsedGhost)); + $this->assertFalse($parsedClass->isUninitializedLazyObject($parsedGhost)); + $this->assertSame(0, $parsedGhost->value); + $this->assertSame('untouched', $parsedGhost->title); + } + + private function getParsedEnum(string $enumName): ReflectionEnum + { + $parsedEnums = $this->parsedRefFileNamespace->getEnums(); + $this->assertArrayHasKey($enumName, $parsedEnums); + + return $parsedEnums[$enumName]; + } + + private function loadStubFile(): void + { + include_once stream_resolve_include_path(__DIR__ . self::STUB_FILENAME); + } +} diff --git a/tests/FinalPromotedPropertyTest.php b/tests/FinalPromotedPropertyTest.php new file mode 100644 index 0000000..8cdc943 --- /dev/null +++ b/tests/FinalPromotedPropertyTest.php @@ -0,0 +1,124 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection; + +use Go\ParserReflection\Locator\CallableLocator; +use Go\ParserReflection\Locator\ComposerLocator; +use PHPUnit\Framework\TestCase; + +/** + * Verifies that the PHP 8.5 `final` modifier on promoted constructor properties is reflected. + * + * The stub file uses PHP 8.5 syntax, therefore it must never be included or required, + * it is only allowed to be parsed by the reflection engine. + */ +class FinalPromotedPropertyTest extends TestCase +{ + public const STUB_FILE = '/Stub/FileWithFinalPromoted85.php'; + + public const STUB_CLASS = 'Go\ParserReflection\Stub\ClassWithFinalPromotedProperty85'; + + private string $stubFileName; + + protected function setUp(): void + { + $resolvedFileName = stream_resolve_include_path(__DIR__ . self::STUB_FILE); + $this->assertIsString($resolvedFileName, 'PHP 8.5 stub file should be available'); + + $this->stubFileName = $resolvedFileName; + } + + protected function tearDown(): void + { + // Restores the default locator for the following tests, because some of them replace it + ReflectionEngine::init(new ComposerLocator()); + } + + public function testFinalPromotedPropertyIsFinal(): void + { + $parsedClass = $this->getParsedClass(); + + $parsedProperty = $parsedClass->getProperty('finalPromoted'); + $this->assertTrue($parsedProperty->isPromoted(), 'Property should be promoted'); + $this->assertTrue($parsedProperty->isFinal(), 'Promoted property should be final'); + $this->assertSame( + \ReflectionProperty::IS_FINAL, + $parsedProperty->getModifiers() & \ReflectionProperty::IS_FINAL, + 'Modifiers should contain the IS_FINAL bit' + ); + $this->assertTrue($parsedProperty->isPublic()); + } + + public function testPlainPromotedPropertyIsNotFinal(): void + { + $parsedClass = $this->getParsedClass(); + + $parsedProperty = $parsedClass->getProperty('plainPromoted'); + $this->assertTrue($parsedProperty->isPromoted(), 'Property should be promoted'); + $this->assertFalse($parsedProperty->isFinal(), 'Plain promoted property should not be final'); + $this->assertSame( + 0, + $parsedProperty->getModifiers() & \ReflectionProperty::IS_FINAL, + 'Modifiers should not contain the IS_FINAL bit' + ); + } + + public function testFinalReadonlyPromotedPropertyIsFinal(): void + { + $parsedClass = $this->getParsedClass(); + + $parsedProperty = $parsedClass->getProperty('finalReadonlyPromoted'); + $this->assertTrue($parsedProperty->isFinal()); + $this->assertTrue($parsedProperty->isReadOnly()); + $this->assertTrue($parsedProperty->isProtected()); + $this->assertSame( + \ReflectionProperty::IS_FINAL, + $parsedProperty->getModifiers() & \ReflectionProperty::IS_FINAL + ); + } + + public function testPrivateSetPromotedPropertyRemainsImplicitlyFinal(): void + { + $parsedClass = $this->getParsedClass(); + + $parsedProperty = $parsedClass->getProperty('privateSetPromoted'); + $this->assertTrue($parsedProperty->isPrivateSet(), 'Property should have private(set) visibility'); + $this->assertTrue($parsedProperty->isFinal(), 'Property with private(set) is implicitly final'); + $this->assertSame( + \ReflectionProperty::IS_FINAL, + $parsedProperty->getModifiers() & \ReflectionProperty::IS_FINAL + ); + } + + public function testStubClassIsNeverLoaded(): void + { + $parsedClass = $this->getParsedClass(); + + $this->assertSame(self::STUB_CLASS, $parsedClass->getName()); + $this->assertFalse(class_exists(self::STUB_CLASS, false), 'Stub class should not be loaded'); + } + + /** + * Reflects the stub class without triggering autoloading of the PHP 8.5 source + */ + private function getParsedClass(): ReflectionClass + { + $stubFileName = $this->stubFileName; + $locator = new CallableLocator( + static fn(string $className): false|string + => $className === self::STUB_CLASS ? $stubFileName : false + ); + ReflectionEngine::init($locator); + + return new ReflectionClass(self::STUB_CLASS); + } +} diff --git a/tests/Php85ParsingTest.php b/tests/Php85ParsingTest.php new file mode 100644 index 0000000..c1f7a24 --- /dev/null +++ b/tests/Php85ParsingTest.php @@ -0,0 +1,119 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection; + +use Go\ParserReflection\Locator\CallableLocator; +use Go\ParserReflection\Locator\ComposerLocator; +use PHPUnit\Framework\TestCase; + +/** + * Verifies that sources written for a PHP version newer than the host one can still be analyzed. + * + * The stub file uses the PHP 8.5 pipe operator, therefore it must never be included or required, + * it is only allowed to be parsed by the reflection engine. + */ +class Php85ParsingTest extends TestCase +{ + public const STUB_FILE = '/Stub/FileWithPhp85Syntax.php'; + + public const STUB_CLASS = 'Go\ParserReflection\Stub\ClassWithPhp85PipeOperator'; + + private string $stubFileName; + + protected function setUp(): void + { + $resolvedFileName = stream_resolve_include_path(__DIR__ . self::STUB_FILE); + $this->assertIsString($resolvedFileName, 'PHP 8.5 stub file should be available'); + + $this->stubFileName = $resolvedFileName; + } + + protected function tearDown(): void + { + // Restores the default locator for the following tests, because some of them replace it + ReflectionEngine::init(new ComposerLocator()); + } + + public function testFileWithNewerSyntaxIsParsed(): void + { + $reflectionFile = new ReflectionFile($this->stubFileName); + + $this->assertTrue($reflectionFile->isStrictMode()); + $this->assertTrue($reflectionFile->hasFileNamespace('Go\ParserReflection\Stub')); + } + + public function testClassWithNewerSyntaxIsFound(): void + { + $reflectionFile = new ReflectionFile($this->stubFileName); + $reflectionNamespace = $reflectionFile->getFileNamespace('Go\ParserReflection\Stub'); + + $this->assertArrayHasKey(self::STUB_CLASS, $reflectionNamespace->getClasses()); + + $parsedClass = $reflectionNamespace->getClass(self::STUB_CLASS); + $this->assertSame(self::STUB_CLASS, $parsedClass->getName()); + $this->assertSame($this->stubFileName, $parsedClass->getFileName()); + $this->assertSame( + ['normalize', 'splitAndTrim', 'firstOrNull'], + array_map( + static fn(\ReflectionMethod $method): string => $method->getName(), + $parsedClass->getMethods() + ) + ); + } + + public function testMethodMetadataIsAvailable(): void + { + $reflectionFile = new ReflectionFile($this->stubFileName); + $reflectionNamespace = $reflectionFile->getFileNamespace('Go\ParserReflection\Stub'); + $parsedClass = $reflectionNamespace->getClass(self::STUB_CLASS); + + $parsedMethod = $parsedClass->getMethod('splitAndTrim'); + $this->assertSame('splitAndTrim', $parsedMethod->getName()); + $this->assertFalse($parsedMethod->isStatic()); + $this->assertTrue($parsedMethod->isPublic()); + $this->assertSame('array', (string) $parsedMethod->getReturnType()); + + $parsedParameters = $parsedMethod->getParameters(); + $this->assertCount(2, $parsedParameters); + + [$sentenceParameter, $separatorParameter] = $parsedParameters; + $this->assertSame('sentence', $sentenceParameter->getName()); + $this->assertSame('string', (string) $sentenceParameter->getType()); + $this->assertFalse($sentenceParameter->isDefaultValueAvailable()); + + $this->assertSame('separator', $separatorParameter->getName()); + $this->assertSame('string', (string) $separatorParameter->getType()); + $this->assertTrue($separatorParameter->isDefaultValueAvailable()); + $this->assertSame(',', $separatorParameter->getDefaultValue()); + + $staticMethod = $parsedClass->getMethod('firstOrNull'); + $this->assertTrue($staticMethod->isStatic()); + $this->assertSame('?string', (string) $staticMethod->getReturnType()); + } + + public function testClassWithNewerSyntaxIsReflectableByName(): void + { + $stubFileName = $this->stubFileName; + $locator = new CallableLocator( + static fn(string $className): false|string + => $className === self::STUB_CLASS ? $stubFileName : false + ); + ReflectionEngine::init($locator); + + $parsedClass = new ReflectionClass(self::STUB_CLASS); + + $this->assertSame(self::STUB_CLASS, $parsedClass->getName()); + $this->assertFalse(class_exists(self::STUB_CLASS, false), 'Stub class should not be loaded'); + $this->assertTrue($parsedClass->hasMethod('normalize')); + $this->assertSame('string', (string) $parsedClass->getMethod('normalize')->getReturnType()); + } +} diff --git a/tests/PropertyHooksApiTest.php b/tests/PropertyHooksApiTest.php new file mode 100644 index 0000000..f407b15 --- /dev/null +++ b/tests/PropertyHooksApiTest.php @@ -0,0 +1,208 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection; + +use Go\ParserReflection\Locator\CallableLocator; +use Go\ParserReflection\Locator\ComposerLocator; +use PHPUnit\Framework\TestCase; + +/** + * Tests for the PHP 8.4 property API that is inherited from the internal reflection + * + * @see \Go\ParserReflection\ReflectionProperty::getHooks() + * @see \Go\ParserReflection\ReflectionProperty::isDynamic() + */ +class PropertyHooksApiTest extends TestCase +{ + private const STUB_FILE = __DIR__ . '/Stub/FileWithPropertyHooks84.php'; + + private const STUB_NAMESPACE = 'Go\\ParserReflection\\Stub\\'; + + private const HOOKED_CLASS = self::STUB_NAMESPACE . 'ClassWithHookedAndPlainProperties'; + + private const HOOKED_INTERFACE = self::STUB_NAMESPACE . 'InterfaceWithAbstractHook'; + + /** + * Expected hook names for every property of the stub class, keyed by the property name + * + * @var array> + */ + private const EXPECTED_HOOKS = [ + 'plainProperty' => [], + 'backedCounter' => ['get', 'set'], + 'virtualWithBothHooks' => ['get', 'set'], + 'reversedHookOrder' => ['get', 'set'], + 'virtualReadOnly' => ['get'], + 'promotedProperty' => [], + ]; + + protected function setUp(): void + { + // Stub file holds several classes at once, so it can not be resolved by the PSR-4 rules + $locator = new CallableLocator( + static fn(string $className): false|string + => str_starts_with($className, self::STUB_NAMESPACE) ? self::STUB_FILE : false + ); + ReflectionEngine::init($locator); + } + + protected function tearDown(): void + { + ReflectionEngine::init(new ComposerLocator()); + } + + /** + * This test intentionally goes first to check the pure AST-based path for the non-loaded classes + */ + public function testGetHooksAndIsDynamicWithoutLoadingClass(): void + { + $this->assertFalse( + class_exists(self::HOOKED_CLASS, false), + 'Stub class should not be loaded to verify the static analysis path' + ); + + $parsedClass = new ReflectionClass(self::HOOKED_CLASS); + foreach (self::EXPECTED_HOOKS as $propertyName => $expectedHookNames) { + $parsedProperty = $parsedClass->getProperty($propertyName); + $hooks = $parsedProperty->getHooks(); + + $this->assertSame( + $expectedHookNames, + array_keys($hooks), + "Hook names for property {$propertyName} should be resolved from the AST" + ); + foreach ($hooks as $hookName => $hook) { + $this->assertInstanceOf(ReflectionMethod::class, $hook); + $this->assertSame('$' . $propertyName . '::' . $hookName, $hook->getName()); + $this->assertSame(self::HOOKED_CLASS, $hook->getDeclaringClass()->getName()); + } + $this->assertFalse($parsedProperty->isDynamic(), "Property {$propertyName} should not be dynamic"); + } + + $this->assertFalse(class_exists(self::HOOKED_CLASS, false), 'Static analysis should not load the class'); + } + + public function testGetHooksForAbstractInterfaceHookWithoutLoadingInterface(): void + { + $this->assertFalse( + interface_exists(self::HOOKED_INTERFACE, false), + 'Stub interface should not be loaded to verify the static analysis path' + ); + + $parsedProperty = (new ReflectionClass(self::HOOKED_INTERFACE))->getProperty('abstractHooked'); + $hooks = $parsedProperty->getHooks(); + + $this->assertSame(['get'], array_keys($hooks)); + $this->assertSame('$abstractHooked::get', $hooks['get']->getName()); + $this->assertTrue($parsedProperty->isAbstract()); + $this->assertFalse($parsedProperty->isDynamic()); + } + + public function testGetHooksParityWithNativeReflection(): void + { + $parsedClass = $this->loadStubClass(); + + foreach (array_keys(self::EXPECTED_HOOKS) as $propertyName) { + $parsedProperty = $parsedClass->getProperty($propertyName); + $nativeProperty = new \ReflectionProperty(self::HOOKED_CLASS, $propertyName); + + $parsedHooks = $parsedProperty->getHooks(); + $nativeHooks = $nativeProperty->getHooks(); + + $this->assertSame( + array_keys($nativeHooks), + array_keys($parsedHooks), + "Hook names for property {$propertyName} should be equal to the native ones" + ); + foreach ($nativeHooks as $hookName => $nativeHook) { + $this->assertSame( + $nativeHook->getName(), + $parsedHooks[$hookName]->getName(), + "Hook method name for {$propertyName}::{$hookName} should be equal" + ); + $this->assertSame( + $nativeHook->getNumberOfParameters(), + $parsedHooks[$hookName]->getNumberOfParameters(), + "Hook parameter count for {$propertyName}::{$hookName} should be equal" + ); + } + $this->assertSame( + $nativeProperty->isDynamic(), + $parsedProperty->isDynamic(), + "Dynamic flag for property {$propertyName} should be equal to the native one" + ); + } + } + + public function testRawValueRoundTripOnRealInstance(): void + { + $parsedClass = $this->loadStubClass(); + $className = self::HOOKED_CLASS; + $instance = new $className(); + + $parsedProperty = $parsedClass->getProperty('backedCounter'); + $nativeProperty = new \ReflectionProperty($className, 'backedCounter'); + + // Raw access bypasses the get hook that adds one to the stored value + $this->assertSame(1, $parsedProperty->getRawValue($instance)); + $this->assertSame($nativeProperty->getRawValue($instance), $parsedProperty->getRawValue($instance)); + + $parsedProperty->setRawValue($instance, 21); + $this->assertSame(21, $parsedProperty->getRawValue($instance)); + // The set hook is bypassed too, but the get hook is still used for the normal read + $this->assertSame(22, $instance->backedCounter); + + $this->assertFalse($parsedProperty->isLazy($instance)); + } + + public function testLazyObjectMethodsAreDelegatedToNativeReflection(): void + { + $parsedClass = $this->loadStubClass(); + $nativeClass = new \ReflectionClass(self::HOOKED_CLASS); + $initializer = static function (object $instance): void { + $instance->__construct('initialized'); + }; + + $parsedProperty = $parsedClass->getProperty('plainProperty'); + $nativeProperty = new \ReflectionProperty(self::HOOKED_CLASS, 'plainProperty'); + + $parsedGhost = $nativeClass->newLazyGhost($initializer); + $nativeGhost = $nativeClass->newLazyGhost($initializer); + + $this->assertTrue($parsedProperty->isLazy($parsedGhost)); + $this->assertSame($nativeProperty->isLazy($nativeGhost), $parsedProperty->isLazy($parsedGhost)); + + $parsedProperty->setRawValueWithoutLazyInitialization($parsedGhost, 'preset'); + $nativeProperty->setRawValueWithoutLazyInitialization($nativeGhost, 'preset'); + + $this->assertSame('preset', $parsedProperty->getRawValue($parsedGhost)); + $this->assertSame($nativeProperty->getRawValue($nativeGhost), $parsedProperty->getRawValue($parsedGhost)); + $this->assertFalse($parsedProperty->isLazy($parsedGhost)); + + $skippedGhost = $nativeClass->newLazyGhost($initializer); + $promotedParsed = $parsedClass->getProperty('promotedProperty'); + + $this->assertTrue($promotedParsed->isLazy($skippedGhost)); + $promotedParsed->skipLazyInitialization($skippedGhost); + $this->assertFalse($promotedParsed->isLazy($skippedGhost)); + } + + /** + * Loads the stub file and returns a parsed reflection for the class with hooked properties + */ + private function loadStubClass(): ReflectionClass + { + include_once self::STUB_FILE; + + return new ReflectionClass(self::HOOKED_CLASS); + } +} diff --git a/tests/ReflectionConstantTest.php b/tests/ReflectionConstantTest.php new file mode 100644 index 0000000..2ec2289 --- /dev/null +++ b/tests/ReflectionConstantTest.php @@ -0,0 +1,288 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection; + +use Deprecated; +use PhpParser\Node\Attribute; +use PhpParser\Node\Const_; +use PHPUnit\Framework\TestCase; + +/** + * Tests for the AST-based reflection of global and namespaced constants + * + * @see ReflectionConstant + */ +class ReflectionConstantTest extends TestCase +{ + private const STUB_FILENAME = '/Stub/FileWithGlobalConstants84.php'; + + private const ATTRIBUTED_STUB_FILENAME = '/Stub/FileWithAttributedConstants85.php'; + + private const STUB_NAMESPACE = 'Go\ParserReflection\Stub'; + + /** + * List of constants from the stub file with their expected values + * + * @var array + */ + private const EXPECTED_CONSTANTS = [ + 'GLOBAL_CONSTANT_INT' => 42, + 'GLOBAL_CONSTANT_EXPRESSION' => 7, + 'GLOBAL_CONSTANT_STRING' => 'parser-reflection', + 'GLOBAL_CONSTANT_ARRAY' => [1, 2, 3], + 'GLOBAL_CONSTANT_FIRST' => 1, + 'GLOBAL_CONSTANT_SECOND' => 2, + ]; + + private ReflectionFileNamespace $parsedRefFileNamespace; + + protected function setUp(): void + { + $this->parsedRefFileNamespace = $this->getParsedFileNamespace(self::STUB_FILENAME); + } + + /** + * Constants should be resolved from the AST only, without including the file with them + */ + public function testConstantsAreResolvedWithoutLoadingFile(): void + { + foreach (array_keys(self::EXPECTED_CONSTANTS) as $shortName) { + $this->assertFalse( + defined(self::STUB_NAMESPACE . '\\' . $shortName), + 'Stub constant ' . $shortName . ' should not be defined yet' + ); + } + + $parsedConstants = $this->parsedRefFileNamespace->getReflectionConstants(); + $this->assertSame(array_keys(self::EXPECTED_CONSTANTS), array_keys($parsedConstants)); + + foreach (self::EXPECTED_CONSTANTS as $shortName => $expectedValue) { + $parsedConstant = $parsedConstants[$shortName]; + + $this->assertInstanceOf(ReflectionConstant::class, $parsedConstant); + $this->assertSame(self::STUB_NAMESPACE . '\\' . $shortName, $parsedConstant->getName()); + $this->assertSame(self::STUB_NAMESPACE . '\\' . $shortName, $parsedConstant->name); + $this->assertSame($shortName, $parsedConstant->getShortName()); + $this->assertSame(self::STUB_NAMESPACE, $parsedConstant->getNamespaceName()); + $this->assertSame($expectedValue, $parsedConstant->getValue()); + $this->assertFalse($parsedConstant->isDeprecated()); + $this->assertSame([], $parsedConstant->getAttributes()); + $this->assertInstanceOf(Const_::class, $parsedConstant->getNode()); + } + + foreach (array_keys(self::EXPECTED_CONSTANTS) as $shortName) { + $this->assertFalse( + defined(self::STUB_NAMESPACE . '\\' . $shortName), + 'Stub constant ' . $shortName . ' should still not be defined' + ); + } + } + + /** + * Textual representation should follow the native format even without loading the file + */ + public function testStringRepresentationIsResolvedWithoutLoadingFile(): void + { + $parsedConstant = $this->parsedRefFileNamespace->getReflectionConstant('GLOBAL_CONSTANT_INT'); + $this->assertInstanceOf(ReflectionConstant::class, $parsedConstant); + $this->assertSame( + "Constant [ int " . self::STUB_NAMESPACE . "\\GLOBAL_CONSTANT_INT ] { 42 }\n", + (string) $parsedConstant + ); + + $parsedArrayConstant = $this->parsedRefFileNamespace->getReflectionConstant('GLOBAL_CONSTANT_ARRAY'); + $this->assertInstanceOf(ReflectionConstant::class, $parsedArrayConstant); + $this->assertSame( + "Constant [ array " . self::STUB_NAMESPACE . "\\GLOBAL_CONSTANT_ARRAY ] { Array }\n", + (string) $parsedArrayConstant + ); + } + + /** + * Lookup of a single constant should be available by its short name only + */ + public function testGetReflectionConstant(): void + { + $parsedConstant = $this->parsedRefFileNamespace->getReflectionConstant('GLOBAL_CONSTANT_SECOND'); + + $this->assertInstanceOf(ReflectionConstant::class, $parsedConstant); + $this->assertSame(2, $parsedConstant->getValue()); + $this->assertFalse($this->parsedRefFileNamespace->getReflectionConstant('UNKNOWN_CONSTANT')); + } + + /** + * Existing methods for constants should not be affected by the new reflection + */ + public function testPlainConstantListIsNotAffected(): void + { + $this->assertSame(self::EXPECTED_CONSTANTS, $this->parsedRefFileNamespace->getConstants()); + $this->assertTrue($this->parsedRefFileNamespace->hasConstant('GLOBAL_CONSTANT_INT')); + $this->assertSame(42, $this->parsedRefFileNamespace->getConstant('GLOBAL_CONSTANT_INT')); + } + + /** + * Constant can be also reflected by its name, when the namespace to search in is known + */ + public function testConstantCanBeFoundByNameInGivenNamespace(): void + { + $parsedConstant = new ReflectionConstant( + self::STUB_NAMESPACE . '\GLOBAL_CONSTANT_STRING', + null, + null, + $this->parsedRefFileNamespace + ); + + $this->assertSame('GLOBAL_CONSTANT_STRING', $parsedConstant->getShortName()); + $this->assertSame('parser-reflection', $parsedConstant->getValue()); + $this->assertSame(['name' => self::STUB_NAMESPACE . '\GLOBAL_CONSTANT_STRING'], $parsedConstant->__debugInfo()); + } + + /** + * Global constants can not be located by name alone, because there is no locator for them + */ + public function testConstantWithoutNodesAndNamespaceIsRejected(): void + { + $this->expectException(ReflectionException::class); + + new ReflectionConstant(self::STUB_NAMESPACE . '\GLOBAL_CONSTANT_INT'); + } + + /** + * Unknown constant should be reported as an error during the search in the namespace + */ + public function testUnknownConstantIsRejected(): void + { + $this->expectException(ReflectionException::class); + + new ReflectionConstant( + self::STUB_NAMESPACE . '\UNKNOWN_CONSTANT', + null, + null, + $this->parsedRefFileNamespace + ); + } + + /** + * Parsed constants should give exactly the same answers as the native reflection for a loaded file + */ + public function testConstantsMatchNativeReflection(): void + { + include_once stream_resolve_include_path(__DIR__ . self::STUB_FILENAME); + + foreach (array_keys(self::EXPECTED_CONSTANTS) as $shortName) { + $constantName = self::STUB_NAMESPACE . '\\' . $shortName; + + $parsedConstant = $this->parsedRefFileNamespace->getReflectionConstant($shortName); + $this->assertInstanceOf(ReflectionConstant::class, $parsedConstant); + $nativeConstant = new \ReflectionConstant($constantName); + + $this->assertSame($nativeConstant->getName(), $parsedConstant->getName(), $constantName); + $this->assertSame($nativeConstant->getShortName(), $parsedConstant->getShortName(), $constantName); + $this->assertSame($nativeConstant->getNamespaceName(), $parsedConstant->getNamespaceName(), $constantName); + $this->assertSame($nativeConstant->getValue(), $parsedConstant->getValue(), $constantName); + $this->assertSame($nativeConstant->isDeprecated(), $parsedConstant->isDeprecated(), $constantName); + $this->assertSame($nativeConstant->__toString(), $parsedConstant->__toString(), $constantName); + $this->assertSame($nativeConstant->name, $parsedConstant->name, $constantName); + } + } + + /** + * Attributes on constants are a PHP 8.5 feature, they should be resolved from the AST only + */ + public function testAttributesOnConstantsAreResolvedStatically(): void + { + $parsedNamespace = $this->getParsedFileNamespace(self::ATTRIBUTED_STUB_FILENAME); + + $deprecatedConstant = $parsedNamespace->getReflectionConstant('ATTRIBUTED_CONSTANT_LEGACY'); + $this->assertInstanceOf(ReflectionConstant::class, $deprecatedConstant); + $this->assertTrue($deprecatedConstant->isDeprecated()); + $this->assertSame('legacy', $deprecatedConstant->getValue()); + + $attributes = $deprecatedConstant->getAttributes(); + $this->assertCount(1, $attributes); + $this->assertInstanceOf(ReflectionAttribute::class, $attributes[0]); + $this->assertSame(Deprecated::class, $attributes[0]->getName()); + $this->assertSame(['Use ATTRIBUTED_CONSTANT_MODERN instead', '8.5'], $attributes[0]->getArguments()); + $this->assertFalse($attributes[0]->isRepeated()); + $this->assertInstanceOf(Attribute::class, $attributes[0]->getNode()); + + $aliasedConstant = $parsedNamespace->getReflectionConstant('ATTRIBUTED_CONSTANT_ALIASED'); + $this->assertInstanceOf(ReflectionConstant::class, $aliasedConstant); + $this->assertTrue($aliasedConstant->isDeprecated()); + $this->assertSame( + [Deprecated::class], + array_map(static fn(ReflectionAttribute $attribute): string => $attribute->getName(), $aliasedConstant->getAttributes()) + ); + + $markedConstant = $parsedNamespace->getReflectionConstant('ATTRIBUTED_CONSTANT_MARKED'); + $this->assertInstanceOf(ReflectionConstant::class, $markedConstant); + $this->assertFalse($markedConstant->isDeprecated()); + $markedAttributes = $markedConstant->getAttributes(); + $this->assertCount(1, $markedAttributes); + $this->assertSame(self::STUB_NAMESPACE . '\ConstantMarker', $markedAttributes[0]->getName()); + $this->assertSame(['marked'], $markedAttributes[0]->getArguments()); + $this->assertSame([], $markedConstant->getAttributes(Deprecated::class)); + + $modernConstant = $parsedNamespace->getReflectionConstant('ATTRIBUTED_CONSTANT_MODERN'); + $this->assertInstanceOf(ReflectionConstant::class, $modernConstant); + $this->assertFalse($modernConstant->isDeprecated()); + $this->assertSame([], $modernConstant->getAttributes()); + + $this->assertCount(1, $deprecatedConstant->getAttributes(Deprecated::class)); + $this->assertFalse( + defined(self::STUB_NAMESPACE . '\ATTRIBUTED_CONSTANT_LEGACY'), + 'PHP 8.5 stub file should never be loaded' + ); + } + + /** + * Constants from the global namespace should not have any namespace prefix + */ + public function testConstantInGlobalNamespace(): void + { + $parsedNamespace = $this->getParsedFileNamespace(self::ATTRIBUTED_STUB_FILENAME, ''); + + $globalConstant = $parsedNamespace->getReflectionConstant('ATTRIBUTED_GLOBAL_CONSTANT'); + $this->assertInstanceOf(ReflectionConstant::class, $globalConstant); + $this->assertSame('ATTRIBUTED_GLOBAL_CONSTANT', $globalConstant->getName()); + $this->assertSame('ATTRIBUTED_GLOBAL_CONSTANT', $globalConstant->getShortName()); + $this->assertSame('', $globalConstant->getNamespaceName()); + $this->assertSame('global', $globalConstant->getValue()); + $this->assertTrue($globalConstant->isDeprecated()); + $this->assertFalse( + defined('ATTRIBUTED_GLOBAL_CONSTANT'), + 'PHP 8.5 stub file should never be loaded' + ); + } + + /** + * Constants defined via "define(...)" are created at runtime and should not be reflected + */ + public function testDefinedConstantsAreNotReflected(): void + { + $parsedNamespace = $this->getParsedFileNamespace('/Stub/FileWithGlobalNamespace.php', ''); + + $this->assertSame([], $parsedNamespace->getReflectionConstants()); + $this->assertFalse($parsedNamespace->getReflectionConstant('INT_CONST')); + $this->assertArrayHasKey('INT_CONST', $parsedNamespace->getConstants(true)); + } + + private function getParsedFileNamespace(string $stubFileName, string $namespaceName = self::STUB_NAMESPACE): ReflectionFileNamespace + { + $resolvedFileName = stream_resolve_include_path(__DIR__ . $stubFileName); + $this->assertIsString($resolvedFileName, 'Stub file ' . $stubFileName . ' should be available'); + + $reflectionFile = new ReflectionFile($resolvedFileName); + + return $reflectionFile->getFileNamespace($namespaceName); + } +} diff --git a/tests/Stub/FileWithAttributeHierarchy80.php b/tests/Stub/FileWithAttributeHierarchy80.php new file mode 100644 index 0000000..b3047c2 --- /dev/null +++ b/tests/Stub/FileWithAttributeHierarchy80.php @@ -0,0 +1,65 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection\Stub\AttributeHierarchy; + +use Attribute; + +interface MarkerInterface +{ +} + +interface ExtendedMarkerInterface extends MarkerInterface +{ +} + +#[Attribute(Attribute::TARGET_ALL)] +class BaseAttribute +{ + public function __construct(public readonly string $title = 'base') + { + } +} + +#[Attribute(Attribute::TARGET_ALL)] +class ChildAttribute extends BaseAttribute +{ +} + +#[Attribute(Attribute::TARGET_ALL)] +class GrandChildAttribute extends ChildAttribute +{ +} + +#[Attribute(Attribute::TARGET_ALL)] +class InterfaceAttribute implements ExtendedMarkerInterface +{ +} + +#[Attribute(Attribute::TARGET_ALL)] +class UnrelatedAttribute +{ +} + +#[ChildAttribute('class-level')] +#[UnrelatedAttribute] +class HookedClass +{ + #[GrandChildAttribute('property-level')] + public int $hookedProperty = 1; + + #[InterfaceAttribute] + #[UnrelatedAttribute] + public function hookedMethod(): void + { + } +} diff --git a/tests/Stub/FileWithAttributedConstants85.php b/tests/Stub/FileWithAttributedConstants85.php new file mode 100644 index 0000000..9b362af --- /dev/null +++ b/tests/Stub/FileWithAttributedConstants85.php @@ -0,0 +1,46 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + * Attributes on constants are allowed since PHP 8.5, therefore this file can only be parsed, + * it must never be included or required by the tests. + */ +declare(strict_types=1); + +namespace Go\ParserReflection\Stub { + + use Deprecated as LegacyMarker; + + /** + * Attribute that can be applied to the constants only + */ + #[\Attribute(\Attribute::TARGET_CONSTANT)] + final class ConstantMarker + { + public function __construct(public readonly string $tag = '') + { + } + } + + #[\Deprecated(message: 'Use ATTRIBUTED_CONSTANT_MODERN instead', since: '8.5')] + const ATTRIBUTED_CONSTANT_LEGACY = 'legacy'; + + #[LegacyMarker] + const ATTRIBUTED_CONSTANT_ALIASED = 'aliased'; + + #[ConstantMarker(tag: 'marked')] + const ATTRIBUTED_CONSTANT_MARKED = 'marked'; + + const ATTRIBUTED_CONSTANT_MODERN = 'modern'; +} + +namespace { + + #[\Deprecated] + const ATTRIBUTED_GLOBAL_CONSTANT = 'global'; +} diff --git a/tests/Stub/FileWithConstExprClosures85.php b/tests/Stub/FileWithConstExprClosures85.php new file mode 100644 index 0000000..7ef4243 --- /dev/null +++ b/tests/Stub/FileWithConstExprClosures85.php @@ -0,0 +1,68 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection\Stub; + +/** + * This file uses closures, arrow functions and first-class callables in constant expressions, which is + * allowed only since PHP 8.5, therefore it is intended to be analyzed statically only, never include it. + * + * @see https://wiki.php.net/rfc/closures_in_const_expr + */ + +const CLOSURE_CONST = static function (int $x): int { + return $x * 2; +}; + +const ARROW_FUNCTION_CONST = static fn (int $x): int => $x + 10; + +const FCC_CONST = \strlen(...); + +const UNQUALIFIED_FCC_CONST = strtoupper(...); + +const PLAIN_CONST = 'plain'; + +#[\Attribute] +class ConstExprClosureAttribute +{ + public function __construct(public \Closure $callback) + { + } +} + +#[ConstExprClosureAttribute(static fn (int $value): int => $value * 5)] +class ClassWithConstExprClosures +{ + public const CALLBACK = static fn (int $value): int => $value + 1; + + public const DOUBLER = static function (int $value): int { + return $value * 2; + }; + + public const UPPERCASE = \strtoupper(...); + + public const PLAIN = 'simple'; + + public \Closure $handler = static fn (string $value): string => \trim($value); + + public function methodWithClosureDefault(\Closure $callback = static function (): int { + return 1; + }): int + { + return $callback(); + } + + public function methodWithArrowFunctionDefault( + \Closure $callback = static fn (int $value): int => $value * 3 + ): int { + return $callback(1); + } +} diff --git a/tests/Stub/FileWithDeprecatedFeatures84.php b/tests/Stub/FileWithDeprecatedFeatures84.php new file mode 100644 index 0000000..6e06ba4 --- /dev/null +++ b/tests/Stub/FileWithDeprecatedFeatures84.php @@ -0,0 +1,70 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Go\ParserReflection\Stub; + +use Deprecated as ImportedDeprecated; + +/** + * @see https://wiki.php.net/rfc/deprecated_attribute + */ + +#[\Deprecated] +function php84DeprecatedFunction(): void +{ +} + +#[\Deprecated(message: 'use php84PlainFunction() instead', since: '4.0')] +function php84DeprecatedFunctionWithArguments(): void +{ +} + +#[ImportedDeprecated] +function php84ImportedDeprecatedFunction(): void +{ +} + +function php84PlainFunction(): void +{ +} + +class ClassWithPhp84DeprecatedFeatures +{ + #[\Deprecated] + public const DEPRECATED_CONSTANT = 'deprecated'; + + #[\Deprecated(message: 'use ACTUAL_CONSTANT instead', since: '4.0')] + protected const DEPRECATED_CONSTANT_WITH_ARGUMENTS = 'deprecated'; + + #[ImportedDeprecated] + public const IMPORTED_DEPRECATED_CONSTANT = 'deprecated'; + + public const ACTUAL_CONSTANT = 'actual'; + + #[\Deprecated] + public function deprecatedMethod(): void + { + } + + #[\Deprecated(message: 'use actualMethod() instead', since: '4.0')] + public static function deprecatedStaticMethod(): void + { + } + + #[ImportedDeprecated] + public function importedDeprecatedMethod(): void + { + } + + public function actualMethod(): void + { + } +} diff --git a/tests/Stub/FileWithEnumCases84.php b/tests/Stub/FileWithEnumCases84.php new file mode 100644 index 0000000..88c41d4 --- /dev/null +++ b/tests/Stub/FileWithEnumCases84.php @@ -0,0 +1,49 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Go\ParserReflection\Stub; + +/** + * Backed enum with a deprecated case + */ +enum EnumCaseSuit: string +{ + #[\Deprecated(message: 'Use Spades instead')] + case Hearts = 'H'; + + case Spades = 'S'; +} + +/** + * Pure unit enum with a deprecated case + */ +enum EnumCaseDirection +{ + case Up; + + #[\Deprecated] + case Down; +} + +/** + * Simple class used to verify the PHP 8.4 lazy-object API + */ +class ClassForLazyObjects +{ + public int $value = 0; + + public string $title = 'untouched'; + + public function __construct(int $value = 1) + { + $this->value = $value; + } +} diff --git a/tests/Stub/FileWithFinalPromoted85.php b/tests/Stub/FileWithFinalPromoted85.php new file mode 100644 index 0000000..52b39a0 --- /dev/null +++ b/tests/Stub/FileWithFinalPromoted85.php @@ -0,0 +1,30 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Go\ParserReflection\Stub; + +/** + * This file contains PHP 8.5 syntax and can not be loaded by an older runtime. + * It is intended to be analyzed statically only, never include it directly. + * + * @see https://wiki.php.net/rfc/final_promotion + */ + +class ClassWithFinalPromotedProperty85 +{ + public function __construct( + public final int $finalPromoted = 1, + public int $plainPromoted = 2, + protected final readonly string $finalReadonlyPromoted = 'value', + private(set) string $privateSetPromoted = 'implicitly final' + ) { + } +} diff --git a/tests/Stub/FileWithGlobalConstants84.php b/tests/Stub/FileWithGlobalConstants84.php new file mode 100644 index 0000000..ed37662 --- /dev/null +++ b/tests/Stub/FileWithGlobalConstants84.php @@ -0,0 +1,22 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Go\ParserReflection\Stub; + +const GLOBAL_CONSTANT_INT = 42; + +const GLOBAL_CONSTANT_EXPRESSION = 2 * 3 + 1; + +const GLOBAL_CONSTANT_STRING = 'parser' . '-' . 'reflection'; + +const GLOBAL_CONSTANT_ARRAY = [1, 2, 3]; + +const GLOBAL_CONSTANT_FIRST = 1, GLOBAL_CONSTANT_SECOND = 2; diff --git a/tests/Stub/FileWithNewInInitializers81.php b/tests/Stub/FileWithNewInInitializers81.php new file mode 100644 index 0000000..f6a68f2 --- /dev/null +++ b/tests/Stub/FileWithNewInInitializers81.php @@ -0,0 +1,35 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\ParserReflection\Stub; + +/** + * Stub file with "new in initializers" syntax, available since PHP 8.1. + * + * File name intentionally does not follow the PSR-4 name of the classes below, so these classes can not be + * found by the composer autoloader and have to be resolved via the registered locator instead. + */ + +class NewInInitializerDependency +{ + public function __construct(public readonly string $label = 'default') + { + } +} + +class ClassWithNewInInitializers +{ + public function withDependency( + NewInInitializerDependency $dependency = new NewInInitializerDependency('injected') + ): NewInInitializerDependency { + return $dependency; + } +} diff --git a/tests/Stub/FileWithPhp85Syntax.php b/tests/Stub/FileWithPhp85Syntax.php new file mode 100644 index 0000000..87f2c29 --- /dev/null +++ b/tests/Stub/FileWithPhp85Syntax.php @@ -0,0 +1,38 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Go\ParserReflection\Stub; + +/** + * This file contains PHP 8.5 syntax and can not be loaded by an older runtime. + * It is intended to be analyzed statically only, never include it directly. + * + * @see https://wiki.php.net/rfc/pipe-operator-v3 + */ + +class ClassWithPhp85PipeOperator +{ + public function normalize(string $value): string + { + return $value |> trim(...) |> strtoupper(...); + } + + public function splitAndTrim(string $sentence, string $separator = ','): array + { + return explode($separator, $sentence) + |> (static fn(array $parts): array => array_map(trim(...), $parts)); + } + + public static function firstOrNull(array $values): ?string + { + return $values |> array_values(...) |> (static fn(array $list): ?string => $list[0] ?? null); + } +} diff --git a/tests/Stub/FileWithPropertyHooks84.php b/tests/Stub/FileWithPropertyHooks84.php new file mode 100644 index 0000000..f500153 --- /dev/null +++ b/tests/Stub/FileWithPropertyHooks84.php @@ -0,0 +1,69 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ +declare(strict_types=1); + +namespace Go\ParserReflection\Stub; + +/** + * @see https://wiki.php.net/rfc/property-hooks + */ + +class ClassWithHookedAndPlainProperties +{ + private string $storage = 'stored'; + + public string $plainProperty = 'plain'; + + /** + * Backed property with both hooks, raw value is stored in the property itself + */ + public int $backedCounter = 1 { + get => $this->backedCounter + 1; + set => $value * 2; + } + + /** + * Virtual property with both hooks, there is no backing store at all + */ + public string $virtualWithBothHooks { + get => $this->storage; + set (string $value) { + $this->storage = strtolower($value); + } + } + + /** + * Virtual property that declares the set hook before the get one + */ + public string $reversedHookOrder { + set (string $value) { + $this->storage = trim($value); + } + get => $this->storage; + } + + /** + * Virtual property with a single get hook + */ + public string $virtualReadOnly { + get => strtoupper($this->storage); + } + + public function __construct(public string $promotedProperty = 'promoted') + { + } +} + +interface InterfaceWithAbstractHook +{ + public string $abstractHooked { + get; + } +}