Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions src/ReflectionParameter.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,18 @@ public function __construct(

if ($this->isDefaultValueAvailable() && $this->parameterNode->default !== null) {
$expressionSolver = new NodeExpressionResolver($context);
$expressionSolver->process($this->parameterNode->default);

$this->defaultValue = $expressionSolver->getValue();
$this->isDefaultValueConstant = $expressionSolver->isConstant();
$this->defaultValueConstantName = $expressionSolver->getConstantName();
try {
$expressionSolver->process($this->parameterNode->default);
$this->defaultValue = $expressionSolver->getValue();
$this->isDefaultValueConstant = $expressionSolver->isConstant();
$this->defaultValueConstantName = $expressionSolver->getConstantName();
} catch (ReflectionException $e) {
// For first-class callables and other const-expressible values that can't be evaluated
// at parse time, fall back to storing the string expression for code reconstruction.
if (!$expressionSolver->isConstExpression()) {
throw $e;
}
}
$this->isDefaultValueConstExpr = $expressionSolver->isConstExpression();
$this->defaultValueConstExpr = $expressionSolver->getConstExpression();
}
Expand Down Expand Up @@ -308,6 +315,20 @@ public function getDefaultValueConstantName(): null|string
return $this->defaultValueConstantName;
}

/**
* Returns the string representation of the default value expression, if it is a
* constant expression (e.g. first-class callable syntax or array expression).
*
* This is useful when the default value cannot be fully evaluated at parse time but
* its source-code representation is needed to reconstruct proxied code.
*
* @return string|null String expression (e.g. '\strlen(...)') or null if not applicable
*/
public function getDefaultValueExpression(): ?string
{
return $this->defaultValueConstExpr;
}

/**
* @inheritDoc
*/
Expand Down
40 changes: 33 additions & 7 deletions src/ReflectionProperty.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ final class ReflectionProperty extends BaseReflectionProperty

private ?string $defaultValueConstantName;

private bool $isDefaultValueConstExpr = false;

private ?string $defaultValueConstExpr;

/**
Expand Down Expand Up @@ -94,12 +96,20 @@ public function __construct(
// Both PropertyItem and Param has `default` property
if (isset($this->propertyItemOrPromotedParam->default) && $this->hasDefaultValue()) {
$expressionSolver = new NodeExpressionResolver($this->getDeclaringClass());
$expressionSolver->process($this->propertyItemOrPromotedParam->default);

$this->defaultValue = $expressionSolver->getValue();
$this->isDefaultValueConstant = $expressionSolver->isConstant();
$this->defaultValueConstantName = $expressionSolver->getConstantName();
$this->defaultValueConstExpr = $expressionSolver->getConstExpression();
try {
$expressionSolver->process($this->propertyItemOrPromotedParam->default);
$this->defaultValue = $expressionSolver->getValue();
$this->isDefaultValueConstant = $expressionSolver->isConstant();
$this->defaultValueConstantName = $expressionSolver->getConstantName();
} catch (ReflectionException $e) {
// For first-class callables and other const-expressible values that can't be evaluated
// at parse time, fall back to storing the string expression for code reconstruction.
if (!$expressionSolver->isConstExpression()) {
throw $e;
}
}
$this->isDefaultValueConstExpr = $expressionSolver->isConstExpression();
$this->defaultValueConstExpr = $expressionSolver->getConstExpression();
}

if ($this->hasType() && $this->propertyOrPromotedParam->type !== null) {
Expand Down Expand Up @@ -165,7 +175,9 @@ public function __toString(): string
$defaultValue = '';
if ($hasDefaultValue) {
// For constant fetch expressions, PHP renders now expression
if ($this->isDefaultValueConstant) {
if ($this->isDefaultValueConstExpr) {
$defaultValue = $this->defaultValueConstExpr;
} elseif ($this->isDefaultValueConstant) {
$defaultValue = $this->defaultValueConstantName;
} elseif (is_array($this->getDefaultValue())) {
$defaultValue = $this->defaultValueConstExpr;
Expand Down Expand Up @@ -382,6 +394,20 @@ public function getDefaultValue(): mixed
return $this->defaultValue;
}

/**
* Returns the string representation of the default value expression, if it is a
* constant expression (e.g. first-class callable syntax or array expression).
*
* This is useful when the default value cannot be fully evaluated at parse time but
* its source-code representation is needed to reconstruct proxied code.
*
* @return string|null String expression (e.g. '\strlen(...)') or null if not applicable
*/
public function getDefaultValueExpression(): ?string
{
return $this->defaultValueConstExpr;
}

/**
* @inheritDoc
*/
Expand Down
90 changes: 90 additions & 0 deletions src/Resolver/NodeExpressionResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\Stmt\Expression;
use PhpParser\PrettyPrinter\Standard;
use Closure;
use ReflectionFunction;
use ReflectionFunctionAbstract;
use ReflectionMethod;

Expand Down Expand Up @@ -223,6 +225,27 @@ protected function resolveIdentifier(Node\Identifier $node): string
*/
protected function resolveExprFuncCall(Expr\FuncCall $node): mixed
{
// First-class callable syntax: strlen(...)
if ($node->isFirstClassCallable()) {
$functionName = $this->resolve($node->name);
if (!is_string($functionName)) {
throw new ReflectionException("Could not resolve function name for first-class callable.");
}
// Set isConstExpr so callers can reconstruct the expression as code even if resolution fails
$this->isConstExpr = true;
$reflectedFunction = new ReflectionFunction($functionName);
if (!$reflectedFunction->isInternal()) {
Comment thread
lisachenko marked this conversation as resolved.
throw new ReflectionException(
"First-class callable syntax for user-defined function '{$functionName}(...)' cannot be resolved " .
"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);
}

$functionName = $this->resolve($node->name);
$resolvedArgs = [];
foreach ($node->args as $argumentNode) {
Expand Down Expand Up @@ -253,6 +276,73 @@ protected function resolveExprFuncCall(Expr\FuncCall $node): mixed
return $reflectedFunction->invoke(...$resolvedArgs);
}

/**
* Resolves static method first-class callables (e.g. Foo::bar(...))
*
* @throws ReflectionException If the static method first-class callable cannot be resolved
*/
protected function resolveExprStaticCall(Expr\StaticCall $node): Closure
{
if (!$node->isFirstClassCallable()) {
throw new ReflectionException("Static method calls can only be resolved as first-class callables (e.g. Foo::bar(...)).");
}

// Resolve the class name
$classNode = $node->class;
if ($classNode instanceof Node\Name) {
if ($classNode->hasAttribute('resolvedName')) {
$resolvedName = $classNode->getAttribute('resolvedName');
if ($resolvedName instanceof Node\Name) {
$classNode = $resolvedName;
}
}
$className = $classNode->toString();
} else {
$className = $this->resolve($classNode);
if (!is_string($className)) {
throw new ReflectionException("Could not resolve class name for static method first-class callable.");
}
}

// Resolve the method name
if ($node->name instanceof Node\Identifier) {
$methodName = $node->name->toString();
} else {
$resolvedName = $this->resolve($node->name);
if (!is_string($resolvedName)) {
throw new ReflectionException("Could not resolve method name for static method first-class callable.");
}
$methodName = $resolvedName;
}

$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);
}

/**
* Handles instance method calls, including first-class callables (e.g. $obj->method(...))
*
* Instance method calls cannot be resolved statically; this method always throws.
*
* @throws ReflectionException Always, since instance method calls require a runtime object
*/
protected function resolveExprMethodCall(Expr\MethodCall $node): never
{
if ($node->isFirstClassCallable()) {
throw new ReflectionException(
"First-class callable syntax for instance methods (e.g. \$obj->method(...)) cannot be resolved " .
"statically, as it requires a runtime object instance."
);
}
throw new ReflectionException("Instance method calls cannot be statically resolved.");
}

/**
* Resolves new expression by instantiating the class with constructor arguments
*
Expand Down
68 changes: 68 additions & 0 deletions tests/ReflectionParameterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,74 @@ public function testParametersWithBackedEnumPropertyDefault(): void
$this->assertSame('get', $defaultValue);
}

/**
* Test that parameters with first-class callable syntax as default value are reflected correctly.
*
* The stub file is only parsed via AST (not included), because PHP runtime forbids
* FCC in constant-expression positions.
*/
public function testParameterWithFccDefaultValue(): void
{
// Parse the stub via AST only — do NOT include_once since PHP runtime rejects FCC defaults.
$fileName = __DIR__ . '/Stub/FileWithFunctionsFcc.php';
$fileNode = ReflectionEngine::parseFile($fileName);
$reflectionFile = new ReflectionFile($fileName, $fileNode);
$parsedFileNamespace = $reflectionFile->getFileNamespace('Go\ParserReflection\Stub');

// Test built-in FCC default: function functionWithBuiltinFccDefault($callable = \strlen(...))
$parsedFunction = $parsedFileNamespace->getFunction('functionWithBuiltinFccDefault');
$parsedParameter = $parsedFunction->getParameters()[0];

$this->assertSame('callable', $parsedParameter->getName());
$this->assertTrue($parsedParameter->isDefaultValueAvailable());
$this->assertFalse($parsedParameter->isDefaultValueConstant());

// Default value should be a Closure (resolved from the built-in FCC)
$defaultValue = $parsedParameter->getDefaultValue();
$this->assertInstanceOf(\Closure::class, $defaultValue);
$this->assertSame(6, $defaultValue('foobar'));

// getDefaultValueExpression() must return the fully-qualified FCC source expression.
// The FQN form (\strlen) is what PHP-Parser's NameResolver emits for built-in functions
// inside a namespace; this exact string is needed by proxy generators for code reconstruction.
$this->assertSame('\strlen(...)', $parsedParameter->getDefaultValueExpression());

// __toString must contain the FCC expression
$this->assertStringContainsString('\strlen(...)', (string) $parsedParameter);
}

/**
* Test that parameters with a user-defined static method FCC default are reflected correctly.
*
* The stub file is only parsed via AST (not included), because PHP runtime forbids
* FCC in constant-expression positions.
*/
public function testParameterWithStaticMethodFccDefaultValue(): void
{
$fileName = __DIR__ . '/Stub/FileWithFunctionsFcc.php';
$fileNode = ReflectionEngine::parseFile($fileName);
$reflectionFile = new ReflectionFile($fileName, $fileNode);
$parsedFileNamespace = $reflectionFile->getFileNamespace('Go\ParserReflection\Stub');

$parsedFunction = $parsedFileNamespace->getFunction('functionWithStaticMethodFccDefault');
$parsedParameter = $parsedFunction->getParameters()[0];

$this->assertSame('callable', $parsedParameter->getName());
$this->assertTrue($parsedParameter->isDefaultValueAvailable());

// Default value should be a Closure wrapping the static method
$defaultValue = $parsedParameter->getDefaultValue();
$this->assertInstanceOf(\Closure::class, $defaultValue);

// getDefaultValueExpression() must return the FQN source expression
$expression = $parsedParameter->getDefaultValueExpression();
$this->assertNotNull($expression);
$this->assertStringContainsString('ReflectionEngine::locateClassFile(...)', $expression);

// __toString must contain the FCC expression
$this->assertStringContainsString('ReflectionEngine::locateClassFile(...)', (string) $parsedParameter);
}

/**
* @inheritDoc
*/
Expand Down
13 changes: 13 additions & 0 deletions tests/ReflectionPropertyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -300,4 +300,17 @@ protected static function getGettersToCheck(): array

return $getters;
}

/**
* Test that getDefaultValueExpression() returns null for a property with a plain scalar default,
* and verifies the method contract is exercised correctly.
*/
public function testGetDefaultValueExpressionForRegularProperty(): void
{
$parsedProperty = $this->parsedRefClass->getProperty('protectedProperty');
$this->assertInstanceOf(ReflectionProperty::class, $parsedProperty);

// Regular literal scalar default ('a'): getDefaultValueExpression() must return null
$this->assertNull($parsedProperty->getDefaultValueExpression());
}
}
Loading
Loading