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
pull/34/head
韩天峰 1 month ago
parent 2fe2344d88
commit ea02ec096f
  1. 55
      examples/attributes/test.php
  2. 1
      phpunit/bootstrap.php
  3. 11
      phpunit/code/class-constant-invalid-expression.php
  4. 28
      phpunit/code/preprocessor/attribute_array_argument.php
  5. 19
      phpunit/code/preprocessor/attribute_invalid_expression.php
  6. 11
      phpunit/code/property-default-invalid-expression.php
  7. 24
      phpunit/src/ClassTest.php
  8. 278
      phpunit/src/ConstantExpressionValidatorTest.php
  9. 26
      phpunit/src/PreprocessorTest.php
  10. 13
      src/CompilerBase.php
  11. 4
      src/Entity/FunctionDef.php
  12. 36
      src/Preprocessor.php
  13. 83
      src/Transform/ConstantExpressionValidationVisitor.php
  14. 334
      src/Transform/ConstantExpressionValidator.php
  15. 212
      src/Transform/RuntimeAttributeFactoryLowering.php
  16. 35
      src/Translator.php
  17. 46
      src/gen_stub.php

@ -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());
}

@ -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

@ -0,0 +1,11 @@
<?php
function loadClassConstantValue(): int
{
return 1;
}
class ClassConstantInvalidExpression
{
public const VALUE = loadClassConstantValue();
}

@ -3,12 +3,36 @@
#[Attribute(Attribute::TARGET_CLASS)]
final class PreprocessorAttributeArrayArgument
{
public function __construct(public array $methods = [])
public function __construct(public array $methods = [], public array $options = [])
{
}
}
#[PreprocessorAttributeArrayArgument(methods: ['GET', 'POST'])]
final class PreprocessorAttributeArrayValues
{
public const METHODS = ['GET', 'POST'];
}
#[PreprocessorAttributeArrayArgument(
methods: PreprocessorAttributeArrayValues::METHODS,
options: [
'enabled' => 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];
}

@ -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();
}

@ -2,6 +2,16 @@
class ClassTest extends \BaseTest
{
public function testRuntimeAttributesSupportLiteralAndConstantArrays(): void
{
$this->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(

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

@ -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

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

@ -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;

@ -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<Node> $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<Node> $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);

@ -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;
}
}

@ -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 . ' { ';

@ -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);

Loading…
Cancel
Save