- Implement ConstantExpressionValidationVisitor and ConstantExpressionValidator - Add validation for attribute arguments, class constants, property defaults - Support runtime factories for dynamic expressions in PHP 8.5 - Enable new expressions, object casts, closures in attribute contexts - Add proper scope handling for attribute factory functions - Integrate validation into preprocessing and stub generation pipeline - Add comprehensive tests for valid and invalid constant expressions - Update compiler base to handle attribute factory scope correctly - Remove old unsupported attribute argument validation logicpull/34/head
parent
2fe2344d88
commit
ea02ec096f
17 changed files with 1172 additions and 44 deletions
@ -0,0 +1,55 @@ |
||||
<?php |
||||
|
||||
#[Attribute(Attribute::TARGET_PROPERTY)] |
||||
class PropertyAttributes |
||||
{ |
||||
public function __construct( |
||||
public readonly ?string $name = null, |
||||
public readonly ?string $label = null, |
||||
) {} |
||||
} |
||||
|
||||
#[Attribute(Attribute::TARGET_PROPERTY)] |
||||
class IntegerPropertyAttributes extends PropertyAttributes |
||||
{ |
||||
public function __construct( |
||||
?string $name = null, |
||||
?string $label = null, |
||||
public readonly ?int $default = null, |
||||
public readonly ?int $min = null, |
||||
public readonly ?int $max = null, |
||||
public readonly ?int $step = null, |
||||
public readonly ?object $object = null, |
||||
) { |
||||
parent::__construct($name, $label); |
||||
} |
||||
} |
||||
|
||||
#[Attribute(Attribute::TARGET_PROPERTY)] |
||||
class FloatPropertyAttributes extends PropertyAttributes |
||||
{ |
||||
public function __construct( |
||||
?string $name = null, |
||||
?string $label = null, |
||||
public readonly ?float $default = null, |
||||
public readonly ?float $min = null, |
||||
public readonly ?float $max = null, |
||||
) { |
||||
parent::__construct($name, $label); |
||||
} |
||||
} |
||||
|
||||
class MyClass |
||||
{ |
||||
#[IntegerPropertyAttributes('prop', 'property: ', 5, 0, 10, 1, new stdClass())] |
||||
public int $prop; |
||||
} |
||||
|
||||
$refl = new ReflectionProperty('MyClass', 'prop'); |
||||
$attributes = $refl->getAttributes(); |
||||
|
||||
foreach ($attributes as $attribute) { |
||||
var_dump($attribute->getName()); |
||||
var_dump($attribute->getArguments()); |
||||
var_dump($attribute->newInstance()); |
||||
} |
||||
@ -0,0 +1,11 @@ |
||||
<?php |
||||
|
||||
function loadClassConstantValue(): int |
||||
{ |
||||
return 1; |
||||
} |
||||
|
||||
class ClassConstantInvalidExpression |
||||
{ |
||||
public const VALUE = loadClassConstantValue(); |
||||
} |
||||
@ -0,0 +1,19 @@ |
||||
<?php |
||||
|
||||
#[Attribute(Attribute::TARGET_CLASS)] |
||||
final class PreprocessorInvalidExpressionAttribute |
||||
{ |
||||
public function __construct(public array $values) |
||||
{ |
||||
} |
||||
} |
||||
|
||||
function preprocessorLoadAttributeValue(): int |
||||
{ |
||||
return 1; |
||||
} |
||||
|
||||
#[PreprocessorInvalidExpressionAttribute([1, [preprocessorLoadAttributeValue()]])] |
||||
final class PreprocessorInvalidExpressionTarget |
||||
{ |
||||
} |
||||
@ -0,0 +1,11 @@ |
||||
<?php |
||||
|
||||
function loadPropertyDefaultValue(): int |
||||
{ |
||||
return 1; |
||||
} |
||||
|
||||
class PropertyDefaultInvalidExpression |
||||
{ |
||||
public int $value = loadPropertyDefaultValue(); |
||||
} |
||||
@ -0,0 +1,278 @@ |
||||
<?php |
||||
|
||||
use PhpParser\Node; |
||||
use PhpParser\NodeTraverser; |
||||
use PhpParser\ParserFactory; |
||||
use PhpParser\PhpVersion; |
||||
use TypePhp\Exception\SyntaxError; |
||||
use TypePhp\Transform\ConstantExpressionValidator; |
||||
use TypePhp\Transform\ConstantExpressionValidationVisitor; |
||||
use TypePhp\Transform\RuntimeAttributeFactoryLowering; |
||||
|
||||
final class ConstantExpressionValidatorTest extends PHPUnit\Framework\TestCase |
||||
{ |
||||
/** @dataProvider validExpressionProvider */ |
||||
public function testAcceptsPhpAttributeConstantExpressions(string $expression, string $phpVersion): void |
||||
{ |
||||
(new ConstantExpressionValidator($phpVersion))->validate( |
||||
$this->parseAttributeExpression($expression, $phpVersion), |
||||
true, |
||||
); |
||||
|
||||
$this->addToAssertionCount(1); |
||||
} |
||||
|
||||
public static function validExpressionProvider(): iterable |
||||
{ |
||||
yield 'literal array with unpack' => ['[1, ...VALUES, "key" => C::VALUE]', '8.4']; |
||||
yield 'new expression' => ['new Value(flag: true)', '8.4']; |
||||
yield 'new expression property fetch' => ['(new Value())->name', '8.4']; |
||||
yield 'PHP 8.5 cast' => ['(object) ["value" => 1]', '8.5']; |
||||
yield 'PHP 8.5 static closure' => ['static function (): int { return 1; }', '8.5']; |
||||
yield 'PHP 8.5 function callable' => ['strlen(...)', '8.5']; |
||||
yield 'PHP 8.5 static method callable' => ['Value::make(...)', '8.5']; |
||||
yield 'short-circuited invalid right operand' => ['true || loadValue()', '8.5']; |
||||
yield 'unselected invalid ternary branch' => ['true ? 1 : loadValue()', '8.5']; |
||||
yield 'unselected invalid coalesce operand' => ['1 ?? loadValue()', '8.5']; |
||||
} |
||||
|
||||
/** @dataProvider invalidExpressionProvider */ |
||||
public function testRejectsExpressionsExactlyAsPhpDoes( |
||||
string $expression, |
||||
string $phpVersion, |
||||
string $message, |
||||
): void { |
||||
$this->expectException(SyntaxError::class); |
||||
$this->expectExceptionMessage($message); |
||||
|
||||
(new ConstantExpressionValidator($phpVersion))->validate( |
||||
$this->parseAttributeExpression($expression, $phpVersion), |
||||
true, |
||||
); |
||||
} |
||||
|
||||
public static function invalidExpressionProvider(): iterable |
||||
{ |
||||
yield 'nested function call' => [ |
||||
'[1, [loadValue()]]', |
||||
'8.5', |
||||
'Constant expression contains invalid operations', |
||||
]; |
||||
yield 'ordinary call is invalid in PHP 8.5' => [ |
||||
'strlen("value")', |
||||
'8.5', |
||||
'Constant expression contains invalid operations', |
||||
]; |
||||
yield 'PHP 8.5 pipe expression' => [ |
||||
'"value" |> strlen(...)', |
||||
'8.5', |
||||
'Constant expression contains invalid operations', |
||||
]; |
||||
yield 'first-class callable before PHP 8.5' => [ |
||||
'strlen(...)', |
||||
'8.4', |
||||
'Constant expression contains invalid operations', |
||||
]; |
||||
yield 'closure before PHP 8.5' => [ |
||||
'static function (): int { return 1; }', |
||||
'8.4', |
||||
'Constant expression contains invalid operations', |
||||
]; |
||||
yield 'non-static closure' => [ |
||||
'function (): int { return 1; }', |
||||
'8.5', |
||||
'Closures in constant expressions must be static', |
||||
]; |
||||
yield 'closure use' => [ |
||||
'static function () use ($value): mixed { return $value; }', |
||||
'8.5', |
||||
'Cannot use(...) variables in constant expression', |
||||
]; |
||||
yield 'anonymous class' => [ |
||||
'new class {}', |
||||
'8.5', |
||||
'Cannot use anonymous class in constant expression', |
||||
]; |
||||
yield 'dynamic class' => [ |
||||
'new $className()', |
||||
'8.5', |
||||
'Cannot use dynamic class name in constant expression', |
||||
]; |
||||
yield 'new static' => [ |
||||
'new static()', |
||||
'8.5', |
||||
'"static" is not allowed in compile-time constants', |
||||
]; |
||||
yield 'argument unpacking' => [ |
||||
'new Value(...[])', |
||||
'8.5', |
||||
'Argument unpacking in constant expressions is not supported', |
||||
]; |
||||
yield 'empty array dimension' => [ |
||||
'[1][]', |
||||
'8.5', |
||||
'Cannot use [] for reading', |
||||
]; |
||||
yield 'positional after named' => [ |
||||
'new Value(first: 1, 2)', |
||||
'8.5', |
||||
'Cannot use positional argument after named argument', |
||||
]; |
||||
} |
||||
|
||||
public function testRejectsUnpackingInTheAttributeArgumentList(): void |
||||
{ |
||||
$this->expectException(SyntaxError::class); |
||||
$this->expectExceptionMessage('Cannot use unpacking in attribute argument list'); |
||||
|
||||
(new ConstantExpressionValidator('8.5'))->validateArguments( |
||||
$this->parseAttributeArguments('...VALUES', '8.5'), |
||||
true, |
||||
true, |
||||
); |
||||
} |
||||
|
||||
/** @dataProvider runtimeFactoryExpressionProvider */ |
||||
public function testPhp85DynamicExpressionsUseRuntimeFactories(string $expression): void |
||||
{ |
||||
$parser = (new ParserFactory())->createForVersion(PhpVersion::fromString('8.5')); |
||||
$statements = $parser->parse("<?php #[Test({$expression})] class Target {}");
|
||||
self::assertNotNull($statements); |
||||
|
||||
$traverser = new NodeTraverser(); |
||||
$traverser->addVisitor(new ConstantExpressionValidationVisitor('8.5')); |
||||
$traverser->addVisitor(new RuntimeAttributeFactoryLowering('test.php')); |
||||
$statements = $traverser->traverse($statements); |
||||
$class = $statements[0]; |
||||
self::assertInstanceOf(Node\Stmt\Class_::class, $class); |
||||
$value = $class->attrGroups[0]->attrs[0]->args[0]->value; |
||||
|
||||
self::assertNotSame('', $value->getAttribute(RuntimeAttributeFactoryLowering::FACTORY_NAME_ATTRIBUTE, '')); |
||||
self::assertTrue($value->getAttribute(RuntimeAttributeFactoryLowering::FACTORY_LAZY_VALUE_ATTRIBUTE, false)); |
||||
self::assertInstanceOf(Node\Stmt\Function_::class, $statements[1]); |
||||
} |
||||
|
||||
public static function runtimeFactoryExpressionProvider(): iterable |
||||
{ |
||||
yield 'new' => ['new Value()']; |
||||
yield 'object cast' => ['(object) ["value" => 1]']; |
||||
yield 'static closure' => ['static function (): int { return 1; }']; |
||||
yield 'function callable' => ['strlen(...)']; |
||||
yield 'static method callable' => ['Value::make(...)']; |
||||
} |
||||
|
||||
/** @dataProvider validDeclarationContextProvider */ |
||||
public function testDeclarationContextsMatchPhpAllowDynamicRules(string $code, string $phpVersion): void |
||||
{ |
||||
$this->validateCode($code, $phpVersion); |
||||
$this->addToAssertionCount(1); |
||||
} |
||||
|
||||
public static function validDeclarationContextProvider(): iterable |
||||
{ |
||||
yield 'parameter default allows new' => ['function f($value = new Value()) {}', '8.4']; |
||||
yield 'global const allows new' => ['const VALUE = new Value();', '8.4']; |
||||
yield 'static variable allows new' => ['function f() { static $value = new Value(); }', '8.4']; |
||||
yield 'PHP 8.5 class constant allows static closure' => [ |
||||
'class C { const VALUE = static function (): int { return 1; }; }', |
||||
'8.5', |
||||
]; |
||||
yield 'PHP 8.5 property allows scalar cast' => ['class C { public int $value = (int) 1.5; }', '8.5']; |
||||
} |
||||
|
||||
/** @dataProvider invalidDeclarationContextProvider */ |
||||
public function testDeclarationContextsRejectDisallowedExpressions( |
||||
string $code, |
||||
string $phpVersion, |
||||
string $message, |
||||
): void { |
||||
$this->expectException(SyntaxError::class); |
||||
$this->expectExceptionMessage($message); |
||||
$this->validateCode($code, $phpVersion); |
||||
} |
||||
|
||||
public static function invalidDeclarationContextProvider(): iterable |
||||
{ |
||||
yield 'class constant rejects new' => [ |
||||
'class C { const VALUE = new Value(); }', |
||||
'8.4', |
||||
'New expressions are not supported in this context', |
||||
]; |
||||
yield 'property rejects new' => [ |
||||
'class C { public mixed $value = new Value(); }', |
||||
'8.4', |
||||
'New expressions are not supported in this context', |
||||
]; |
||||
yield 'enum case rejects new' => [ |
||||
'enum E: int { case Value = new Value(); }', |
||||
'8.4', |
||||
'New expressions are not supported in this context', |
||||
]; |
||||
yield 'class constant rejects ordinary call' => [ |
||||
'class C { const VALUE = loadValue(); }', |
||||
'8.5', |
||||
'Constant expression contains invalid operations', |
||||
]; |
||||
yield 'property rejects ordinary call' => [ |
||||
'class C { public mixed $value = loadValue(); }', |
||||
'8.5', |
||||
'Constant expression contains invalid operations', |
||||
]; |
||||
yield 'property rejects PHP 8.5 object cast' => [ |
||||
'class C { public mixed $value = (object) []; }', |
||||
'8.5', |
||||
'Object casts are not supported in this context', |
||||
]; |
||||
yield 'class constant rejects closure before PHP 8.5' => [ |
||||
'class C { const VALUE = static function (): int { return 1; }; }', |
||||
'8.4', |
||||
'Constant expression contains invalid operations', |
||||
]; |
||||
} |
||||
|
||||
public function testExposesPhpStyleNodeWhitelist(): void |
||||
{ |
||||
$php84 = new ConstantExpressionValidator('8.4'); |
||||
$php85 = new ConstantExpressionValidator('8.5'); |
||||
|
||||
self::assertTrue($php84->isAllowedInConstantExpression( |
||||
$this->parseAttributeExpression('new Value()', '8.4'), |
||||
)); |
||||
self::assertFalse($php84->isAllowedInConstantExpression( |
||||
$this->parseAttributeExpression('strlen(...)', '8.4'), |
||||
)); |
||||
self::assertTrue($php85->isAllowedInConstantExpression( |
||||
$this->parseAttributeExpression('strlen(...)', '8.5'), |
||||
)); |
||||
self::assertFalse($php85->isAllowedInConstantExpression( |
||||
$this->parseAttributeExpression('"value" |> strlen(...)', '8.5'), |
||||
)); |
||||
} |
||||
|
||||
private function parseAttributeExpression(string $expression, string $phpVersion): Node\Expr |
||||
{ |
||||
return $this->parseAttributeArguments($expression, $phpVersion)[0]->value; |
||||
} |
||||
|
||||
/** @return list<Node\Arg> */ |
||||
private function parseAttributeArguments(string $arguments, string $phpVersion): array |
||||
{ |
||||
$parser = (new ParserFactory())->createForVersion(PhpVersion::fromString($phpVersion)); |
||||
$statements = $parser->parse("<?php #[Test({$arguments})] class Target {}");
|
||||
self::assertNotNull($statements); |
||||
$class = $statements[0]; |
||||
self::assertInstanceOf(Node\Stmt\Class_::class, $class); |
||||
|
||||
return $class->attrGroups[0]->attrs[0]->args; |
||||
} |
||||
|
||||
private function validateCode(string $code, string $phpVersion): void |
||||
{ |
||||
$parser = (new ParserFactory())->createForVersion(PhpVersion::fromString($phpVersion)); |
||||
$statements = $parser->parse("<?php {$code}");
|
||||
self::assertNotNull($statements); |
||||
$traverser = new NodeTraverser(); |
||||
$traverser->addVisitor(new ConstantExpressionValidationVisitor($phpVersion)); |
||||
$traverser->traverse($statements); |
||||
} |
||||
} |
||||
@ -0,0 +1,83 @@ |
||||
<?php |
||||
/** |
||||
* This file is part of TypePHP. |
||||
* |
||||
* @link https://www.swoole.com/ |
||||
* @contact service@swoole.com |
||||
*/ |
||||
|
||||
namespace TypePhp\Transform; |
||||
|
||||
use PhpParser\Node; |
||||
use PhpParser\NodeVisitorAbstract; |
||||
|
||||
/** |
||||
* Applies the allow_dynamic values used by php-src at each declaration site. |
||||
* |
||||
* false: class constants, property defaults and enum cases. |
||||
* true: attributes, parameter defaults, global constants and static variables. |
||||
*/ |
||||
final class ConstantExpressionValidationVisitor extends NodeVisitorAbstract |
||||
{ |
||||
private readonly ConstantExpressionValidator $validator; |
||||
|
||||
public function __construct(string $phpVersion) |
||||
{ |
||||
$this->validator = new ConstantExpressionValidator($phpVersion); |
||||
} |
||||
|
||||
public function enterNode(Node $node): null |
||||
{ |
||||
if ($node instanceof Node\Attribute) { |
||||
$this->validator->validateArguments( |
||||
$node->args, |
||||
allowDynamic: true, |
||||
attributeArgumentList: true, |
||||
); |
||||
return null; |
||||
} |
||||
|
||||
if ($node instanceof Node\Stmt\ClassConst) { |
||||
foreach ($node->consts as $constant) { |
||||
$this->validator->validate($constant->value, allowDynamic: false); |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
if ($node instanceof Node\Stmt\Property) { |
||||
foreach ($node->props as $property) { |
||||
if ($property->default !== null) { |
||||
$this->validator->validate($property->default, allowDynamic: false); |
||||
} |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
if ($node instanceof Node\Stmt\EnumCase && $node->expr !== null) { |
||||
$this->validator->validate($node->expr, allowDynamic: false); |
||||
return null; |
||||
} |
||||
|
||||
if ($node instanceof Node\Param && $node->default !== null) { |
||||
$this->validator->validate($node->default, allowDynamic: true); |
||||
return null; |
||||
} |
||||
|
||||
if ($node instanceof Node\Stmt\Const_) { |
||||
foreach ($node->consts as $constant) { |
||||
$this->validator->validate($constant->value, allowDynamic: true); |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
if ($node instanceof Node\Stmt\Static_) { |
||||
foreach ($node->vars as $variable) { |
||||
if ($variable->default !== null) { |
||||
$this->validator->validate($variable->default, allowDynamic: true); |
||||
} |
||||
} |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
} |
||||
@ -0,0 +1,334 @@ |
||||
<?php |
||||
/** |
||||
* This file is part of TypePHP. |
||||
* |
||||
* @link https://www.swoole.com/ |
||||
* @contact service@swoole.com |
||||
*/ |
||||
|
||||
namespace TypePhp\Transform; |
||||
|
||||
use PhpParser\ConstExprEvaluator; |
||||
use PhpParser\Node; |
||||
use PhpParser\Node\Expr; |
||||
use TypePhp\Exception\SyntaxError; |
||||
|
||||
/** |
||||
* Implements PHP's reusable constant-expression whitelist and validation. |
||||
* |
||||
* The allowDynamic context flag matches zend_const_expr_to_zval(): it controls |
||||
* `new` and object casts, while the node whitelist remains shared by attributes, |
||||
* constants, defaults and other constant-expression contexts. |
||||
*/ |
||||
final class ConstantExpressionValidator |
||||
{ |
||||
private readonly bool $php85; |
||||
|
||||
public function __construct(string $phpVersion) |
||||
{ |
||||
$this->php85 = version_compare($phpVersion, '8.5', '>='); |
||||
} |
||||
|
||||
public function validate(Expr $expression, bool $allowDynamic = false): void |
||||
{ |
||||
$this->validateExpression($expression, $allowDynamic); |
||||
} |
||||
|
||||
/** @param list<Node\Arg|Node\VariadicPlaceholder> $arguments */ |
||||
public function validateArguments( |
||||
array $arguments, |
||||
bool $allowDynamic = false, |
||||
bool $attributeArgumentList = false, |
||||
): void |
||||
{ |
||||
$usesNamedArguments = false; |
||||
$namedArguments = []; |
||||
foreach ($arguments as $argument) { |
||||
if (!$argument instanceof Node\Arg) { |
||||
$this->invalidOperation(); |
||||
} |
||||
if ($argument->unpack) { |
||||
throw new SyntaxError($attributeArgumentList |
||||
? 'Cannot use unpacking in attribute argument list' |
||||
: 'Argument unpacking in constant expressions is not supported'); |
||||
} |
||||
if ($argument->byRef) { |
||||
$this->invalidOperation(); |
||||
} |
||||
if ($argument->name !== null) { |
||||
$usesNamedArguments = true; |
||||
if ($attributeArgumentList) { |
||||
$name = $argument->name->toString(); |
||||
if (isset($namedArguments[$name])) { |
||||
throw new SyntaxError("Duplicate named parameter \${$name}"); |
||||
} |
||||
$namedArguments[$name] = true; |
||||
} |
||||
} elseif ($usesNamedArguments) { |
||||
throw new SyntaxError('Cannot use positional argument after named argument'); |
||||
} |
||||
$this->validateExpression($argument->value, $allowDynamic); |
||||
} |
||||
} |
||||
|
||||
/** Equivalent to php-src's zend_is_allowed_in_const_expr(). */ |
||||
public function isAllowedInConstantExpression(Expr $expression): bool |
||||
{ |
||||
return $expression instanceof Node\Scalar\Int_ |
||||
|| $expression instanceof Node\Scalar\Float_ |
||||
|| $expression instanceof Node\Scalar\String_ |
||||
|| $expression instanceof Node\Scalar\MagicConst |
||||
|| $expression instanceof Expr\ConstFetch |
||||
|| ($expression instanceof Expr\BinaryOp |
||||
&& !$expression instanceof Expr\BinaryOp\Pipe) |
||||
|| $expression instanceof Expr\UnaryPlus |
||||
|| $expression instanceof Expr\UnaryMinus |
||||
|| $expression instanceof Expr\BooleanNot |
||||
|| $expression instanceof Expr\BitwiseNot |
||||
|| $expression instanceof Expr\Ternary |
||||
|| $expression instanceof Expr\ArrayDimFetch |
||||
|| $expression instanceof Expr\Array_ |
||||
|| $expression instanceof Expr\ClassConstFetch |
||||
|| $expression instanceof Expr\PropertyFetch |
||||
|| $expression instanceof Expr\NullsafePropertyFetch |
||||
|| $expression instanceof Expr\New_ |
||||
|| ($this->php85 && ($expression instanceof Expr\Cast |
||||
|| $expression instanceof Expr\Closure |
||||
|| $expression instanceof Expr\FuncCall |
||||
|| $expression instanceof Expr\StaticCall)); |
||||
} |
||||
|
||||
private function validateExpression(Expr $expression, bool $allowDynamic): void |
||||
{ |
||||
if (!$this->isAllowedInConstantExpression($expression)) { |
||||
$this->invalidOperation(); |
||||
} |
||||
|
||||
if ($expression instanceof Node\Scalar |
||||
|| $expression instanceof Expr\ConstFetch) { |
||||
return; |
||||
} |
||||
|
||||
if ($expression instanceof Expr\BinaryOp\BooleanAnd |
||||
|| $expression instanceof Expr\BinaryOp\LogicalAnd |
||||
|| $expression instanceof Expr\BinaryOp\BooleanOr |
||||
|| $expression instanceof Expr\BinaryOp\LogicalOr) { |
||||
$this->validateExpression($expression->left, $allowDynamic); |
||||
[$known, $left] = $this->tryEvaluate($expression->left); |
||||
$isAnd = $expression instanceof Expr\BinaryOp\BooleanAnd |
||||
|| $expression instanceof Expr\BinaryOp\LogicalAnd; |
||||
if (!$known || ($isAnd ? (bool) $left : !(bool) $left)) { |
||||
$this->validateExpression($expression->right, $allowDynamic); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
if ($expression instanceof Expr\BinaryOp\Coalesce) { |
||||
$this->validateExpression($expression->left, $allowDynamic); |
||||
[$known, $left] = $this->tryEvaluate($expression->left); |
||||
if (!$known || $left === null) { |
||||
$this->validateExpression($expression->right, $allowDynamic); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
if ($expression instanceof Expr\BinaryOp) { |
||||
$this->validateExpression($expression->left, $allowDynamic); |
||||
$this->validateExpression($expression->right, $allowDynamic); |
||||
return; |
||||
} |
||||
|
||||
if ($expression instanceof Expr\UnaryPlus |
||||
|| $expression instanceof Expr\UnaryMinus |
||||
|| $expression instanceof Expr\BooleanNot |
||||
|| $expression instanceof Expr\BitwiseNot) { |
||||
$this->validateExpression($expression->expr, $allowDynamic); |
||||
return; |
||||
} |
||||
|
||||
if ($expression instanceof Expr\Ternary) { |
||||
$this->validateExpression($expression->cond, $allowDynamic); |
||||
[$known, $condition] = $this->tryEvaluate($expression->cond); |
||||
if ($known) { |
||||
if ((bool) $condition) { |
||||
if ($expression->if !== null) { |
||||
$this->validateExpression($expression->if, $allowDynamic); |
||||
} |
||||
} else { |
||||
$this->validateExpression($expression->else, $allowDynamic); |
||||
} |
||||
return; |
||||
} |
||||
if ($expression->if !== null) { |
||||
$this->validateExpression($expression->if, $allowDynamic); |
||||
} |
||||
$this->validateExpression($expression->else, $allowDynamic); |
||||
return; |
||||
} |
||||
|
||||
if ($expression instanceof Expr\ArrayDimFetch) { |
||||
$this->validateExpression($expression->var, $allowDynamic); |
||||
if ($expression->dim === null) { |
||||
throw new SyntaxError('Cannot use [] for reading'); |
||||
} |
||||
$this->validateExpression($expression->dim, $allowDynamic); |
||||
return; |
||||
} |
||||
|
||||
if ($expression instanceof Expr\Array_) { |
||||
foreach ($expression->items as $item) { |
||||
if ($item === null || $item->byRef) { |
||||
$this->invalidOperation(); |
||||
} |
||||
if ($item->key !== null) { |
||||
$this->validateExpression($item->key, $allowDynamic); |
||||
} |
||||
$this->validateExpression($item->value, $allowDynamic); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
if ($expression instanceof Expr\ClassConstFetch) { |
||||
$this->validateClassConstantFetch($expression, $allowDynamic); |
||||
return; |
||||
} |
||||
|
||||
if ($expression instanceof Expr\PropertyFetch |
||||
|| $expression instanceof Expr\NullsafePropertyFetch) { |
||||
$this->validateExpression($expression->var, $allowDynamic); |
||||
if ($expression->name instanceof Expr) { |
||||
$this->validateExpression($expression->name, $allowDynamic); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
if ($expression instanceof Expr\New_) { |
||||
if (!$allowDynamic) { |
||||
throw new SyntaxError('New expressions are not supported in this context'); |
||||
} |
||||
$this->validateNew($expression, $allowDynamic); |
||||
return; |
||||
} |
||||
|
||||
if ($this->php85 && $expression instanceof Expr\Cast) { |
||||
if ($expression instanceof Expr\Cast\Object_ && !$allowDynamic) { |
||||
throw new SyntaxError('Object casts are not supported in this context'); |
||||
} |
||||
$this->validateExpression($expression->expr, $allowDynamic); |
||||
return; |
||||
} |
||||
|
||||
if ($this->php85 && $expression instanceof Expr\Closure) { |
||||
if (!$expression->static) { |
||||
throw new SyntaxError('Closures in constant expressions must be static'); |
||||
} |
||||
if ($expression->uses !== []) { |
||||
throw new SyntaxError('Cannot use(...) variables in constant expression'); |
||||
} |
||||
// PHP compiles the closure body normally instead of treating its |
||||
// statements as children of the surrounding constant expression. |
||||
return; |
||||
} |
||||
|
||||
if ($this->php85 && ($expression instanceof Expr\FuncCall |
||||
|| $expression instanceof Expr\StaticCall)) { |
||||
$this->validateFirstClassCallable($expression); |
||||
return; |
||||
} |
||||
|
||||
$this->invalidOperation(); |
||||
} |
||||
|
||||
private function validateClassConstantFetch( |
||||
Expr\ClassConstFetch $expression, |
||||
bool $allowDynamic, |
||||
): void |
||||
{ |
||||
if (!$expression->class instanceof Node\Name) { |
||||
if ($expression->name instanceof Node\Identifier |
||||
&& strtolower($expression->name->toString()) === 'class') { |
||||
throw new SyntaxError('(expression)::class cannot be used in constant expressions'); |
||||
} |
||||
throw new SyntaxError('Dynamic class names are not allowed in compile-time class constant references'); |
||||
} |
||||
|
||||
$class = strtolower($expression->class->toString()); |
||||
if ($class === 'static') { |
||||
if ($expression->name instanceof Node\Identifier |
||||
&& strtolower($expression->name->toString()) === 'class') { |
||||
throw new SyntaxError('static::class cannot be used for compile-time class name resolution'); |
||||
} |
||||
throw new SyntaxError('"static::" is not allowed in compile-time constants'); |
||||
} |
||||
|
||||
if ($expression->name instanceof Expr) { |
||||
$this->validateExpression($expression->name, $allowDynamic); |
||||
} |
||||
} |
||||
|
||||
private function validateNew(Expr\New_ $expression, bool $allowDynamic): void |
||||
{ |
||||
if ($expression->class instanceof Node\Stmt\Class_) { |
||||
throw new SyntaxError('Cannot use anonymous class in constant expression'); |
||||
} |
||||
if (!$expression->class instanceof Node\Name) { |
||||
throw new SyntaxError('Cannot use dynamic class name in constant expression'); |
||||
} |
||||
if (strtolower($expression->class->toString()) === 'static') { |
||||
throw new SyntaxError('"static" is not allowed in compile-time constants'); |
||||
} |
||||
|
||||
if ($expression->isFirstClassCallable()) { |
||||
throw new SyntaxError('Cannot create Closure for new expression'); |
||||
} |
||||
$this->validateArguments($expression->args, $allowDynamic); |
||||
} |
||||
|
||||
/** @param Expr\FuncCall|Expr\StaticCall $expression */ |
||||
private function validateFirstClassCallable(Expr $expression): void |
||||
{ |
||||
if (!$expression->isFirstClassCallable()) { |
||||
$this->invalidOperation(); |
||||
} |
||||
|
||||
if ($expression instanceof Expr\FuncCall) { |
||||
if (!$expression->name instanceof Node\Name) { |
||||
throw new SyntaxError('Cannot use dynamic function name in constant expression'); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
if ($expression->class instanceof Node\Stmt\Class_) { |
||||
throw new SyntaxError('Cannot use anonymous class in constant expression'); |
||||
} |
||||
if (!$expression->class instanceof Node\Name) { |
||||
throw new SyntaxError('Cannot use dynamic class name in constant expression'); |
||||
} |
||||
if (strtolower($expression->class->toString()) === 'static') { |
||||
throw new SyntaxError('"static" is not allowed in compile-time constants'); |
||||
} |
||||
if (!$expression->name instanceof Node\Identifier) { |
||||
throw new SyntaxError('Cannot use dynamic method name in constant expression'); |
||||
} |
||||
} |
||||
|
||||
private function invalidOperation(): never |
||||
{ |
||||
throw new SyntaxError('Constant expression contains invalid operations'); |
||||
} |
||||
|
||||
/** @return array{bool, mixed} */ |
||||
private function tryEvaluate(Expr $expression): array |
||||
{ |
||||
try { |
||||
$value = (new ConstExprEvaluator( |
||||
static function (): never { |
||||
throw new \LogicException('Expression is not statically known'); |
||||
}, |
||||
))->evaluateDirectly($expression); |
||||
return [true, $value]; |
||||
} catch (\Throwable) { |
||||
return [false, null]; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,212 @@ |
||||
<?php |
||||
/** |
||||
* This file is part of TypePHP. |
||||
* |
||||
* @link https://www.swoole.com/ |
||||
* @contact service@swoole.com |
||||
*/ |
||||
|
||||
namespace TypePhp\Transform; |
||||
|
||||
use PhpParser\Node; |
||||
use PhpParser\Node\Expr; |
||||
use PhpParser\Node\Stmt; |
||||
use PhpParser\NodeTraverser; |
||||
use PhpParser\NodeFinder; |
||||
use PhpParser\NodeVisitor\CloningVisitor; |
||||
use PhpParser\NodeVisitorAbstract; |
||||
use PhpParser\PrettyPrinter\Standard; |
||||
|
||||
/** |
||||
* Generates hidden TypePHP functions for attribute values that Zend cannot |
||||
* persistently construct during MINIT. Reflection invokes the compiled native |
||||
* function at request time and receives an ordinary request-local php::Var. |
||||
*/ |
||||
final class RuntimeAttributeFactoryLowering extends NodeVisitorAbstract |
||||
{ |
||||
public const FACTORY_NAME_ATTRIBUTE = 'typephpRuntimeAttributeFactory'; |
||||
public const FACTORY_FUNCTION_ATTRIBUTE = 'typephpRuntimeAttributeFactoryFunction'; |
||||
public const FACTORY_SCOPE_ATTRIBUTE = 'typephpRuntimeAttributeFactoryScope'; |
||||
public const FACTORY_LAZY_VALUE_ATTRIBUTE = 'typephpRuntimeAttributeFactoryLazyValue'; |
||||
|
||||
/** @var list<array{namespace: string, parent: string}> */ |
||||
private array $classStack = []; |
||||
/** @var list<Stmt\Function_> */ |
||||
private array $globalFactories = []; |
||||
/** @var list<list<Stmt\Function_>> */ |
||||
private array $namespaceFactories = []; |
||||
private string $namespace = ''; |
||||
private int $sequence = 0; |
||||
|
||||
public function __construct(private readonly string $sourceFile = '') |
||||
{ |
||||
} |
||||
|
||||
public function enterNode(Node $node): null |
||||
{ |
||||
if ($node instanceof Stmt\Namespace_) { |
||||
$this->namespace = $node->name?->toString() ?? ''; |
||||
$this->namespaceFactories[] = []; |
||||
return null; |
||||
} |
||||
|
||||
if ($node instanceof Stmt\ClassLike) { |
||||
$class = $node->getAttribute('namespacedName'); |
||||
$parent = $node instanceof Stmt\Class_ ? $node->extends : null; |
||||
$this->classStack[] = [ |
||||
'namespace' => $class instanceof Node\Name |
||||
? $class->toString() |
||||
: ltrim($this->namespace . '\\' . ($node->name?->toString() ?? ''), '\\'), |
||||
'parent' => $parent?->toString() ?? '', |
||||
]; |
||||
return null; |
||||
} |
||||
|
||||
if (!$node instanceof Node\Attribute) { |
||||
return null; |
||||
} |
||||
if (CompileTimeAttributeRegistry::get($node->name->toString()) !== null) { |
||||
return null; |
||||
} |
||||
|
||||
foreach ($node->args as $argument) { |
||||
if (!$this->requiresFactory($argument->value)) { |
||||
continue; |
||||
} |
||||
|
||||
$factory = $this->createFactory($argument->value); |
||||
$argument->value->setAttribute(self::FACTORY_NAME_ATTRIBUTE, $factory['fullName']); |
||||
if ($this->requiresLazyValue($argument->value)) { |
||||
$argument->value->setAttribute(self::FACTORY_LAZY_VALUE_ATTRIBUTE, true); |
||||
} |
||||
if ($this->namespaceFactories !== []) { |
||||
$index = array_key_last($this->namespaceFactories); |
||||
$this->namespaceFactories[$index][] = $factory['node']; |
||||
} else { |
||||
$this->globalFactories[] = $factory['node']; |
||||
} |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
public function leaveNode(Node $node): null |
||||
{ |
||||
if ($node instanceof Stmt\ClassLike) { |
||||
array_pop($this->classStack); |
||||
} elseif ($node instanceof Stmt\Namespace_) { |
||||
$factories = array_pop($this->namespaceFactories); |
||||
if ($factories !== []) { |
||||
array_push($node->stmts, ...$factories); |
||||
} |
||||
$this->namespace = ''; |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
public function afterTraverse(array $nodes): ?array |
||||
{ |
||||
if ($this->globalFactories !== []) { |
||||
array_push($nodes, ...$this->globalFactories); |
||||
} |
||||
return $nodes; |
||||
} |
||||
|
||||
private function requiresFactory(Expr $value): bool |
||||
{ |
||||
return $this->requiresLazyValue($value) |
||||
|| $value instanceof Expr\ConstFetch |
||||
|| $value instanceof Expr\ClassConstFetch; |
||||
} |
||||
|
||||
private function requiresLazyValue(Expr $value): bool |
||||
{ |
||||
if ($value instanceof Expr\Array_ && $value->items !== []) { |
||||
return true; |
||||
} |
||||
|
||||
return (new NodeFinder())->findFirst($value, static function (Node $node): bool { |
||||
return $node instanceof Expr\New_ |
||||
|| $node instanceof Expr\Closure |
||||
|| $node instanceof Expr\Cast\Object_ |
||||
|| (($node instanceof Expr\FuncCall || $node instanceof Expr\StaticCall) |
||||
&& $node->isFirstClassCallable()); |
||||
}) !== null; |
||||
} |
||||
|
||||
/** @return array{fullName: string, node: Stmt\Function_} */ |
||||
private function createFactory(Expr $value): array |
||||
{ |
||||
$position = $value->getStartFilePos() . ':' . $value->getEndFilePos(); |
||||
$hash = substr(sha1($this->sourceFile . ':' . $position . ':' . $this->sequence++), 0, 20); |
||||
$name = '__typephp_attribute_factory_' . $hash; |
||||
$fullName = $this->namespace === '' ? $name : $this->namespace . '\\' . $name; |
||||
$expression = $this->cloneExpression($value); |
||||
$description = (new Standard())->prettyPrintExpr($expression); |
||||
$describeVariable = new Expr\Variable('__typephpDescribe'); |
||||
|
||||
$function = new Stmt\Function_( |
||||
new Node\Identifier($name), |
||||
[ |
||||
'params' => [new Node\Param( |
||||
var: $describeVariable, |
||||
type: new Node\Identifier('bool'), |
||||
)], |
||||
'returnType' => new Node\Identifier('mixed'), |
||||
'stmts' => [ |
||||
new Stmt\If_($describeVariable, [ |
||||
'stmts' => [new Stmt\Return_(new Node\Scalar\String_($description))], |
||||
]), |
||||
new Stmt\Return_($expression), |
||||
], |
||||
], |
||||
$value->getAttributes(), |
||||
); |
||||
$function->setAttribute(self::FACTORY_FUNCTION_ATTRIBUTE, true); |
||||
if ($this->classStack !== []) { |
||||
$context = $this->classStack[array_key_last($this->classStack)]; |
||||
$function->setAttribute(self::FACTORY_SCOPE_ATTRIBUTE, $context['namespace']); |
||||
} |
||||
$function->namespacedName = new Node\Name($fullName); |
||||
|
||||
return ['fullName' => $fullName, 'node' => $function]; |
||||
} |
||||
|
||||
private function cloneExpression(Expr $expression): Expr |
||||
{ |
||||
$traverser = new NodeTraverser(); |
||||
$traverser->addVisitor(new CloningVisitor()); |
||||
$context = $this->classStack === [] |
||||
? ['namespace' => '', 'parent' => ''] |
||||
: $this->classStack[array_key_last($this->classStack)]; |
||||
$traverser->addVisitor(new class($context['namespace'], $context['parent']) extends NodeVisitorAbstract { |
||||
public function __construct( |
||||
private readonly string $class, |
||||
private readonly string $parent, |
||||
) { |
||||
} |
||||
|
||||
public function enterNode(Node $node): ?Node |
||||
{ |
||||
if (($node instanceof Expr\ClassConstFetch || $node instanceof Expr\New_) |
||||
&& $node->class instanceof Node\Name) { |
||||
$name = strtolower($node->class->toString()); |
||||
if ($name === 'self' || $name === 'static') { |
||||
$node->class = new Node\Name\FullyQualified($this->class); |
||||
} elseif ($name === 'parent' && $this->parent !== '') { |
||||
$node->class = new Node\Name\FullyQualified($this->parent); |
||||
} |
||||
} |
||||
if ($node instanceof Node\Name) { |
||||
$resolved = $node->getAttribute('resolvedName'); |
||||
if ($resolved instanceof Node\Name) { |
||||
return new Node\Name\FullyQualified($resolved->toString(), $resolved->getAttributes()); |
||||
} |
||||
} |
||||
return null; |
||||
} |
||||
}); |
||||
/** @var Expr $clone */ |
||||
[$clone] = $traverser->traverse([$expression]); |
||||
return $clone; |
||||
} |
||||
} |
||||
Loading…
Reference in new issue