From ea02ec096f82a37342ecda4c2dbe9bd342082ba0 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Wed, 22 Jul 2026 17:40:48 +0800 Subject: [PATCH] feat(compiler): add constant expression validation and runtime attribute factories - 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 logic --- examples/attributes/test.php | 55 +++ phpunit/bootstrap.php | 1 + .../class-constant-invalid-expression.php | 11 + .../preprocessor/attribute_array_argument.php | 28 +- .../attribute_invalid_expression.php | 19 + .../property-default-invalid-expression.php | 11 + phpunit/src/ClassTest.php | 24 ++ .../src/ConstantExpressionValidatorTest.php | 278 +++++++++++++++ phpunit/src/PreprocessorTest.php | 26 +- src/CompilerBase.php | 13 +- src/Entity/FunctionDef.php | 4 + src/Preprocessor.php | 36 +- .../ConstantExpressionValidationVisitor.php | 83 +++++ src/Transform/ConstantExpressionValidator.php | 334 ++++++++++++++++++ .../RuntimeAttributeFactoryLowering.php | 212 +++++++++++ src/Translator.php | 35 +- src/gen_stub.php | 46 ++- 17 files changed, 1172 insertions(+), 44 deletions(-) create mode 100644 examples/attributes/test.php create mode 100644 phpunit/code/class-constant-invalid-expression.php create mode 100644 phpunit/code/preprocessor/attribute_invalid_expression.php create mode 100644 phpunit/code/property-default-invalid-expression.php create mode 100644 phpunit/src/ConstantExpressionValidatorTest.php create mode 100644 src/Transform/ConstantExpressionValidationVisitor.php create mode 100644 src/Transform/ConstantExpressionValidator.php create mode 100644 src/Transform/RuntimeAttributeFactoryLowering.php diff --git a/examples/attributes/test.php b/examples/attributes/test.php new file mode 100644 index 00000000..3a258f86 --- /dev/null +++ b/examples/attributes/test.php @@ -0,0 +1,55 @@ +getAttributes(); + +foreach ($attributes as $attribute) { + var_dump($attribute->getName()); + var_dump($attribute->getArguments()); + var_dump($attribute->newInstance()); +} \ No newline at end of file diff --git a/phpunit/bootstrap.php b/phpunit/bootstrap.php index a487b22d..ba6e7839 100644 --- a/phpunit/bootstrap.php +++ b/phpunit/bootstrap.php @@ -5,6 +5,7 @@ use TypePhp\CompilerTest; use TypePhp\Exception\TestError; require __DIR__ . '/../bin/bootstrap.php'; +require_once __DIR__ . '/../src/polyfills.php'; require __DIR__ . '/../src/gen_stub.php'; class BaseTest extends TestCase diff --git a/phpunit/code/class-constant-invalid-expression.php b/phpunit/code/class-constant-invalid-expression.php new file mode 100644 index 00000000..2da53352 --- /dev/null +++ b/phpunit/code/class-constant-invalid-expression.php @@ -0,0 +1,11 @@ + true, + 'limit' => 10, + 'ratio' => 1.5, + 'nullable' => null, + 'nested' => ['first', 7 => 'last'], + ], +)] class PreprocessorAttributeArrayArgumentController { } + +#[PreprocessorAttributeArrayArgument( + methods: self::METHODS, + options: self::OPTIONS, +)] +class PreprocessorAttributePrivateConstantController +{ + private const METHODS = ['PUT']; + private const OPTIONS = ['private' => true]; +} diff --git a/phpunit/code/preprocessor/attribute_invalid_expression.php b/phpunit/code/preprocessor/attribute_invalid_expression.php new file mode 100644 index 00000000..2d956bab --- /dev/null +++ b/phpunit/code/preprocessor/attribute_invalid_expression.php @@ -0,0 +1,19 @@ +compile('preprocessor/attribute_array_argument.php'); + } + + public function testRuntimeAttributesSupportNewExpressionArguments(): void + { + $this->compile('preprocessor/attribute_new_expression_argument.php'); + } + public function testGetterGeneratesPublicMethodsForInstanceProperties(): void { $this->compile('getter.php'); @@ -758,6 +768,20 @@ class ClassTest extends \BaseTest $this->compile('class-const-default-value.php'); } + public function testClassConstantRejectsNonConstantExpression(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('Constant expression contains invalid operations'); + $this->compile('class-constant-invalid-expression.php'); + } + + public function testPropertyDefaultRejectsNonConstantExpression(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('Constant expression contains invalid operations'); + $this->compile('property-default-invalid-expression.php'); + } + public function testPropertyDefaultArrayForIntTypeFailsAtCompileTime() { $this->exec( diff --git a/phpunit/src/ConstantExpressionValidatorTest.php b/phpunit/src/ConstantExpressionValidatorTest.php new file mode 100644 index 00000000..22798945 --- /dev/null +++ b/phpunit/src/ConstantExpressionValidatorTest.php @@ -0,0 +1,278 @@ +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("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 */ + private function parseAttributeArguments(string $arguments, string $phpVersion): array + { + $parser = (new ParserFactory())->createForVersion(PhpVersion::fromString($phpVersion)); + $statements = $parser->parse("attrGroups[0]->attrs[0]->args; + } + + private function validateCode(string $code, string $phpVersion): void + { + $parser = (new ParserFactory())->createForVersion(PhpVersion::fromString($phpVersion)); + $statements = $parser->parse("addVisitor(new ConstantExpressionValidationVisitor($phpVersion)); + $traverser->traverse($statements); + } +} diff --git a/phpunit/src/PreprocessorTest.php b/phpunit/src/PreprocessorTest.php index 140b6bfb..8e7eeee0 100644 --- a/phpunit/src/PreprocessorTest.php +++ b/phpunit/src/PreprocessorTest.php @@ -5,6 +5,7 @@ namespace TypePhp\Tests; use PHPUnit\Framework\TestCase; use TypePhp\CompilerTest; use TypePhp\Entity\ArgInfo; +use TypePhp\Exception\SyntaxError; use TypePhp\Exception\TestError; use PhpParser\Node; use PhpParser\Node\Stmt\Function_; @@ -402,22 +403,31 @@ class PreprocessorTest extends TestCase $this->compiler->prepareFile($file); } - public function testPrepareFileRejectsAttributeArrayArguments(): void + public function testPrepareFileAcceptsAttributeArrayArguments(): void { - $this->expectException(TestError::class); - $this->expectExceptionMessage('Array arguments to attributes are not supported'); - $file = __DIR__ . '/../code/preprocessor/attribute_array_argument.php'; $this->compiler->prepareFile($file); + + $classes = $this->getProperty('classes'); + $this->assertArrayHasKey('preprocessorattributearrayargumentcontroller', $classes); } - public function testPrepareFileRejectsAttributeNewExpressionArguments(): void + public function testPrepareFileAcceptsAttributeNewExpressionArguments(): void { - $this->expectException(TestError::class); - $this->expectExceptionMessage('New expressions in attribute arguments are not supported'); - $file = __DIR__ . '/../code/preprocessor/attribute_new_expression_argument.php'; $this->compiler->prepareFile($file); + + $classes = $this->getProperty('classes'); + $this->assertArrayHasKey('preprocessorattributesubscriber', $classes); + } + + public function testPrepareFileRejectsInvalidNestedAttributeExpression(): void + { + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('Constant expression contains invalid operations'); + + $file = __DIR__ . '/../code/preprocessor/attribute_invalid_expression.php'; + $this->compiler->prepareFile($file); } public function testIntersectionParamDeclFallsBackToVarWithRuntimeCheck(): void diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 845b8cf7..c183b5a9 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -3834,17 +3834,24 @@ class CompilerBase implements PropertyAccessContext protected function checkAccessible(ClassDef $classDef, int $flags): bool { + $scopeClassDef = $this->classDef; + if ($this->functionDef !== null + && $this->functionDef->attributeFactoryScope !== '' + && $this->hasClass($this->functionDef->attributeFactoryScope)) { + $scopeClassDef = $this->getClass($this->functionDef->attributeFactoryScope); + } // 私有方法,只能当前的类使用 if ($flags & Modifiers::PRIVATE) { - return $classDef->namespace === $this->namespace and $classDef->name == $this->class; + return $scopeClassDef !== null + && strcasecmp($classDef->getNamespacedName(false), $scopeClassDef->getNamespacedName(false)) === 0; } // 保护方法,只能当前类和子类使用 if ($flags & Modifiers::PROTECTED) { - if (!$this->classDef) { + if (!$scopeClassDef) { return false; } return $this->canAccessProtectedProperty( - $this->classDef->getNamespacedName(false), + $scopeClassDef->getNamespacedName(false), $classDef->getNamespacedName(false) ); } diff --git a/src/Entity/FunctionDef.php b/src/Entity/FunctionDef.php index 664738ba..78e8c01b 100644 --- a/src/Entity/FunctionDef.php +++ b/src/Entity/FunctionDef.php @@ -27,6 +27,10 @@ class FunctionDef public bool $stub = false; /** Whether this function is part of the public ABI of a library build. */ public bool $exported = true; + /** Hidden request-time factory used to materialize a runtime attribute value. */ + public bool $attributeFactory = false; + /** Original lexical class scope of an attribute factory, if any. */ + public string $attributeFactoryScope = ''; /** External library imported by the stub containing this function. */ public string $importLibrary = ''; public bool $returnTypeUndeclared = false; diff --git a/src/Preprocessor.php b/src/Preprocessor.php index ef7300a9..5c4d813b 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -24,6 +24,8 @@ use TypePhp\Transform\PrinterLowering; use TypePhp\Transform\ArrayableLowering; use TypePhp\Transform\ClassFieldSelection; use TypePhp\Transform\FunctionAttributeLowering; +use TypePhp\Transform\ConstantExpressionValidationVisitor; +use TypePhp\Transform\RuntimeAttributeFactoryLowering; use TypePhp\Transform\Visitor; use PhpParser\Modifiers; use PhpParser\ConstExprEvaluator; @@ -147,8 +149,9 @@ class Preprocessor extends CompilerBase fn (Node $node, string $message) => $this->warning($node, $message), $this->file, )); + $traverser->addVisitor(new ConstantExpressionValidationVisitor($this->phpVersion)); + $traverser->addVisitor(new RuntimeAttributeFactoryLowering($this->file)); $stmts = $traverser->traverse($ast); - $this->validateUnsupportedAttributeArguments($stmts); foreach ($stmts as $v) { $type = $v->getType(); @@ -192,26 +195,6 @@ class Preprocessor extends CompilerBase } } - /** - * @param array $stmts - */ - private function validateUnsupportedAttributeArguments(array $stmts): void - { - $nodeFinder = new NodeFinder(); - $attributes = $nodeFinder->findInstanceOf($stmts, Node\Attribute::class); - - foreach ($attributes as $attribute) { - foreach ($attribute->args as $arg) { - if ($arg->value instanceof Node\Expr\Array_ && count($arg->value->items) > 0) { - $this->fatalError($arg, 'Array arguments to attributes are not supported'); - } - if ($arg->value instanceof Node\Expr\New_) { - $this->fatalError($arg, 'New expressions in attribute arguments are not supported'); - } - } - } - } - /** @param array $stmts */ private function hasLibraryImportAnnotation(array $stmts): bool { @@ -659,6 +642,17 @@ class Preprocessor extends CompilerBase $this->fatalError($v, "The function `{$name}` is a built-in function and cannot be redefined"); } $functionDef = $this->parseFunctionDecl($v); + $functionDef->attributeFactory = (bool) $v->getAttribute( + RuntimeAttributeFactoryLowering::FACTORY_FUNCTION_ATTRIBUTE, + false, + ); + if ($functionDef->attributeFactory) { + $functionDef->exported = false; + $functionDef->attributeFactoryScope = (string) $v->getAttribute( + RuntimeAttributeFactoryLowering::FACTORY_SCOPE_ATTRIBUTE, + '', + ); + } $functionDef->sourceFile = $this->file; $functionDef->startLine = $v->getStartLine(); $this->addFunction($name, $functionDef); diff --git a/src/Transform/ConstantExpressionValidationVisitor.php b/src/Transform/ConstantExpressionValidationVisitor.php new file mode 100644 index 00000000..a59facdc --- /dev/null +++ b/src/Transform/ConstantExpressionValidationVisitor.php @@ -0,0 +1,83 @@ +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; + } +} diff --git a/src/Transform/ConstantExpressionValidator.php b/src/Transform/ConstantExpressionValidator.php new file mode 100644 index 00000000..16bcec3d --- /dev/null +++ b/src/Transform/ConstantExpressionValidator.php @@ -0,0 +1,334 @@ +php85 = version_compare($phpVersion, '8.5', '>='); + } + + public function validate(Expr $expression, bool $allowDynamic = false): void + { + $this->validateExpression($expression, $allowDynamic); + } + + /** @param list $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]; + } + } +} diff --git a/src/Transform/RuntimeAttributeFactoryLowering.php b/src/Transform/RuntimeAttributeFactoryLowering.php new file mode 100644 index 00000000..aac35aa5 --- /dev/null +++ b/src/Transform/RuntimeAttributeFactoryLowering.php @@ -0,0 +1,212 @@ + */ + private array $classStack = []; + /** @var list */ + private array $globalFactories = []; + /** @var list> */ + 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; + } +} diff --git a/src/Translator.php b/src/Translator.php index e4b2e6d4..b0b3cf61 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -42,6 +42,8 @@ use TypePhp\Resolver\Reflection; use TypePhp\Resolver\ClassConstantValueTrait; use TypePhp\Transform\Visitor; use TypePhp\Transform\ConstructorLowering; +use TypePhp\Transform\ConstantExpressionValidationVisitor; +use TypePhp\Transform\RuntimeAttributeFactoryLowering; use PhpParser\Modifiers; use PhpParser\Node; use PhpParser\NodeAbstract; @@ -843,6 +845,9 @@ CODE; } foreach ($this->symbols->functions() as $functionDef) { + if ($functionDef->attributeFactory) { + continue; + } if ($this->isBuildModeExt() and $functionDef->name === self::ENTRY_FUNCTION) { continue; } @@ -864,6 +869,9 @@ CODE; $code .= 'zend_try {' . PHP_EOL; $code .= '// class/interface class entries' . PHP_EOL; $code .= 'typephp_register_fiber_generator_class();' . PHP_EOL; + $code .= 'if (typephp_install_reflection_attribute_handlers() != SUCCESS) {' . PHP_EOL; + $code .= $this->getIndent() . 'return FAILURE;' . PHP_EOL; + $code .= '}' . PHP_EOL; $code .= $this->genClassPropertyInit() . PHP_EOL; $code .= '// register symbols' . PHP_EOL; @@ -875,6 +883,11 @@ CODE; $code .= '}' . PHP_EOL . PHP_EOL; // minit end + $code .= 'PHP_MSHUTDOWN_FUNCTION(' . $this->getModuleName() . ') {' . PHP_EOL; + $code .= 'typephp_uninstall_reflection_attribute_handlers();' . PHP_EOL; + $code .= 'return SUCCESS;' . PHP_EOL; + $code .= '}' . PHP_EOL . PHP_EOL; + $code .= 'THREAD_LOCAL zval globals_array;' . PHP_EOL; // php_app_init begin @@ -1045,7 +1058,7 @@ zend_module_entry {$moduleName}_module_entry = { "{$moduleName}", ext_functions, PHP_MINIT({$moduleName}), - nullptr, + PHP_MSHUTDOWN({$moduleName}), PHP_RINIT({$moduleName}), PHP_RSHUTDOWN({$moduleName}), nullptr, @@ -1257,6 +1270,7 @@ CODE; $job = $this->maxJob; $sourceFiles[] = $this->getPhpxDir() . '/src/misc/typephp_fiber_generator.cc'; + $sourceFiles[] = $this->getPhpxDir() . '/src/misc/typephp_helper.cc'; // embed 需要 main 函数,以及 cli 的内置函数定义 if ($this->isBuildModeEmbed()) { @@ -2293,6 +2307,8 @@ CODE; $traverser = new NodeTraverser(); $traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false])); $traverser->addVisitor(new Visitor(sourceFile: $this->file)); + $traverser->addVisitor(new ConstantExpressionValidationVisitor($this->phpVersion)); + $traverser->addVisitor(new RuntimeAttributeFactoryLowering($this->file)); $stmts = $traverser->traverse($ast); @@ -2349,6 +2365,9 @@ CODE; } foreach ($this->functionDefineInFile as $functionDef) { + if ($functionDef->attributeFactory) { + continue; + } $cppCode .= $this->genFunctionWrapper($functionDef); } @@ -4582,6 +4601,20 @@ CODE; return $cppCode; } + /** Return the generated C++ symbol for a hidden runtime-attribute factory. */ + public function getRuntimeAttributeFactoryNativeName(string $fullName): string + { + $fullName = ltrim($fullName, '\\'); + $separator = strrpos($fullName, '\\'); + if ($separator === false) { + return self::PREFIX . $this->getNativeName($fullName); + } + return self::PREFIX . $this->getNativeName( + substr($fullName, $separator + 1), + substr($fullName, 0, $separator), + ); + } + private function genClassNative(): string { $code = 'class ' . $this->class . ' { '; diff --git a/src/gen_stub.php b/src/gen_stub.php index 343879e7..5b65c0c5 100755 --- a/src/gen_stub.php +++ b/src/gen_stub.php @@ -104,7 +104,7 @@ function processStubFile(string $stubFile, Context $context, bool $includeOnly = if (!$fileInfo = $context->parsedFiles[$stubFile] ?? null) { initPhpParser(); $stubContent = $stubCode ?? file_get_contents($stubFile); - $fileInfo = FileInfo::parseStubFile($stubContent, $context->phpVersion); + $fileInfo = FileInfo::parseStubFile($stubContent, $context->phpVersion, $stubFile); $context->parsedFiles[$stubFile] = $fileInfo; foreach ($fileInfo->dependencies as $dependency) { @@ -3512,6 +3512,32 @@ class AttributeInfo { */ public function generateCode(string $invocation, string $nameSuffix, array $allConstInfos, ?int $phpVersionIdMinimumCompatibility, array &$declaredStrings = []): string { $escapedAttributeName = strtr($this->class, '\\', '_'); + $evaluatedValues = []; + $lazyValueFactories = []; + foreach ($this->args as $i => $arg) { + $factory = $arg->value->getAttribute( + TypePhp\Transform\RuntimeAttributeFactoryLowering::FACTORY_NAME_ATTRIBUTE, + ); + $requiresLazyValue = $arg->value->getAttribute( + TypePhp\Transform\RuntimeAttributeFactoryLowering::FACTORY_LAZY_VALUE_ATTRIBUTE, + false, + ); + if ($requiresLazyValue) { + if (!is_string($factory) || $factory === '') { + throw new Exception("Missing runtime factory for attribute argument"); + } + $lazyValueFactories[$i] = getTranslator()->getRuntimeAttributeFactoryNativeName($factory); + $evaluatedValues[$i] = null; + continue; + } + $evaluatedValues[$i] = EvaluatedValue::createFromExpression($arg->value, null, null, $allConstInfos); + if (is_array($evaluatedValues[$i]->value) && $evaluatedValues[$i]->value !== []) { + if (!is_string($factory) || $factory === '') { + throw new Exception("Missing runtime factory for non-empty array attribute argument"); + } + $lazyValueFactories[$i] = getTranslator()->getRuntimeAttributeFactoryNativeName($factory); + } + } [$stringInit, $nameCode, $stringRelease] = StringBuilder::getString( "attribute_name_{$escapedAttributeName}_$nameSuffix", addcslashes($this->class, "\\"), @@ -3525,7 +3551,10 @@ class AttributeInfo { foreach ($this->args as $i => $arg) { $initValue = ''; - if ($arg->value instanceof String_) { + if (isset($lazyValueFactories[$i])) { + $initValue = "\ttypephp_attribute_set_lazy_value_argument(" + . "attribute_{$escapedAttributeName}_{$nameSuffix}, $i, {$lazyValueFactories[$i]});\n"; + } elseif ($arg->value instanceof String_) { $strVal = $arg->value->value; [$strInit, $strUse, $strRelease] = StringBuilder::getString( 'unused', @@ -3540,10 +3569,7 @@ class AttributeInfo { } } if ($initValue === '') { - if ($arg->value instanceof Expr\Array_ && count($arg->value->items) > 0) { - getTranslator()->error("Array arguments to attributes are not supported"); - } - $value = EvaluatedValue::createFromExpression($arg->value, null, null, $allConstInfos); + $value = $evaluatedValues[$i]; $code .= $value->initializeZval( "attribute_{$escapedAttributeName}_{$nameSuffix}->args[$i].value", true, @@ -4504,14 +4530,16 @@ class FileInfo { return $legacyFileInfo; } - public static function parseStubFile(string $code, string $phpVersion = '8.5'): FileInfo { + public static function parseStubFile(string $code, string $phpVersion = '8.5', string $sourceFile = ''): FileInfo { $parser = (new PhpParser\ParserFactory())->createForVersion(PhpParser\PhpVersion::fromString($phpVersion)); $nodeTraverser = new PhpParser\NodeTraverser; $nodeTraverser->addVisitor(new PhpParser\NodeVisitor\NameResolver( null, ['preserveOriginalNames' => true] )); - $nodeTraverser->addVisitor(new TypePhp\Transform\Visitor()); + $nodeTraverser->addVisitor(new TypePhp\Transform\Visitor(sourceFile: $sourceFile)); + $nodeTraverser->addVisitor(new TypePhp\Transform\ConstantExpressionValidationVisitor($phpVersion)); + $nodeTraverser->addVisitor(new TypePhp\Transform\RuntimeAttributeFactoryLowering($sourceFile)); $prettyPrinter = new class extends Standard { protected function pName_FullyQualified(PhpParser\Node\Name\FullyQualified $node): string { return implode('\\', $node->getParts()); @@ -4519,7 +4547,7 @@ class FileInfo { }; $stmts = $parser->parse($code); - $nodeTraverser->traverse($stmts); + $stmts = $nodeTraverser->traverse($stmts); $fileTags = DocCommentTag::parseDocComments(self::getFileDocComments($stmts)); $fileInfo = new FileInfo($fileTags);