From a0c9591e81f7dba2c06a10be88712fd6b997c2e2 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Mon, 29 Jun 2026 14:16:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(php):=20=E6=B7=BB=E5=8A=A0=E5=AF=B9?= =?UTF-8?q?=E4=BA=A4=E9=9B=86=E7=B1=BB=E5=9E=8B=E7=9A=84=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 CompilerBase 中添加 IntersectionType 的引入和处理逻辑 - 更新 upgradeToFullyQualifiedName 方法以处理交集类型 - 修改复杂类型处理逻辑,将交集类型统一按 mixed/var 处理并在运行时进行 typeCheck - 在 Preprocessor 中添加对交集类型的参数和返回值检查支持 - 扩展 TypeCheckGenerator 以支持交集类型的类型检查代码生成 - 添加交集类型参数和返回值的运行时检查测试用例 - 实现交集类型的字符串表示转换功能 - 添加 allOf 类型条件生成器以处理多个类型约束组合 --- phpunit/src/PreprocessorTest.php | 45 +++++ src/Php/CompilerBase.php | 11 +- src/Php/Generator/TypeCheckGenerator.php | 175 ++++++++++++------ src/Php/Preprocessor.php | 5 +- .../type_decl/intersection-param-check.phpt | 34 ++++ .../type_decl/intersection-return-check.phpt | 34 ++++ 6 files changed, 243 insertions(+), 61 deletions(-) create mode 100644 tests/aot/type_decl/intersection-param-check.phpt create mode 100644 tests/aot/type_decl/intersection-return-check.phpt diff --git a/phpunit/src/PreprocessorTest.php b/phpunit/src/PreprocessorTest.php index ff3ddb0d..2f743a98 100644 --- a/phpunit/src/PreprocessorTest.php +++ b/phpunit/src/PreprocessorTest.php @@ -6,6 +6,8 @@ use PHPUnit\Framework\TestCase; use PhpAot\Php\CompilerTest; use PhpAot\Php\ArgInfo; use PhpParser\Node; +use PhpParser\Node\Stmt\Function_; +use PhpParser\ParserFactory; class PreprocessorTest extends TestCase { @@ -57,6 +59,19 @@ class PreprocessorTest extends TestCase $prop->setValue($this->compiler, $value); } + private function parseFunctionNode(string $code): Function_ + { + $parser = (new ParserFactory())->createForHostVersion(); + $stmts = $parser->parse($code); + $this->assertNotNull($stmts); + foreach ($stmts as $stmt) { + if ($stmt instanceof Function_) { + return $stmt; + } + } + $this->fail('No function node found'); + } + // ======================================================================== // genArgumentDeclaration // ======================================================================== @@ -210,4 +225,34 @@ class PreprocessorTest extends TestCase // Empty array stays empty or nearly empty $this->assertIsArray($files); } + + public function testIntersectionParamDeclFallsBackToVarWithRuntimeCheck(): void + { + $fn = $this->parseFunctionNode('invokeMethod('parseFunctionDecl', $fn); + + $this->assertSame('php::Var', $functionDef->argInfoList[0]->type); + $this->assertNotEmpty($functionDef->argInfoList[0]->typeCheck); + $this->assertSame('A&B', $functionDef->argInfoList[0]->typeStr); + } + + public function testIntersectionReturnDeclFallsBackToVarWithRuntimeCheck(): void + { + $fn = $this->parseFunctionNode('invokeMethod('parseFunctionDecl', $fn); + + $this->assertSame('php::Var', $functionDef->returnType); + $this->assertNotEmpty($functionDef->returnTypeCheck); + $this->assertSame('A&B', $functionDef->returnTypeStr); + } + + public function testNullableReturnDeclFallsBackToVarWithRuntimeCheck(): void + { + $fn = $this->parseFunctionNode('invokeMethod('parseFunctionDecl', $fn); + + $this->assertSame('php::Var', $functionDef->returnType); + $this->assertNotEmpty($functionDef->returnTypeCheck); + $this->assertSame('?int', $functionDef->returnTypeStr); + } } diff --git a/src/Php/CompilerBase.php b/src/Php/CompilerBase.php index f9ac543a..86494cc6 100644 --- a/src/Php/CompilerBase.php +++ b/src/Php/CompilerBase.php @@ -50,6 +50,7 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\CallLike; use PhpParser\Node\Expr\Variable; use PhpParser\Node\FunctionLike; +use PhpParser\Node\IntersectionType; use PhpParser\Node\NullableType; use PhpParser\Node\Scalar\MagicConst; use PhpParser\Node\Stmt\Foreach_; @@ -957,6 +958,12 @@ class CompilerBase extends \PhpAot\Core\Translator } return $type; } + if ($type instanceof Node\IntersectionType) { + foreach ($type->types as $i => $subType) { + $type->types[$i] = $this->upgradeToFullyQualifiedName($subType); + } + return $type; + } if ($type instanceof Node\Name\FullyQualified) { return $type; } @@ -1095,8 +1102,8 @@ class CompilerBase extends \PhpAot\Core\Translator if ($type === null) { return self::TYPE_VAR; } - if ($type instanceof UnionType or $type instanceof NullableType) { - // 联合类型暂时不支持,使用 var 类型代替 + if ($type instanceof UnionType || $type instanceof NullableType || $type instanceof IntersectionType) { + // 复杂类型静态阶段统一按 mixed/var 处理,运行时再由 typeCheck 兜底。 return self::TYPE_VAR; } else { $typeName = $this->parseIdentifier($type); diff --git a/src/Php/Generator/TypeCheckGenerator.php b/src/Php/Generator/TypeCheckGenerator.php index ce7ac739..c8bb9d09 100644 --- a/src/Php/Generator/TypeCheckGenerator.php +++ b/src/Php/Generator/TypeCheckGenerator.php @@ -10,6 +10,7 @@ namespace PhpAot\Php\Generator; use PhpAot\Php\ArgInfo; use PhpParser\Node; +use PhpParser\Node\IntersectionType; use PhpParser\Node\NullableType; use PhpParser\Node\UnionType; use PhpParser\NodeAbstract; @@ -19,81 +20,123 @@ trait TypeCheckGenerator protected function buildTypeCheckFromNode(NodeAbstract $typeNode): array { $check = []; - $names = []; + $typeStr = $this->typeCheckNodeToString($typeNode); if ($typeNode instanceof NullableType) { - $subTypes = [$typeNode->type]; - $isNullable = true; + $check[] = ['kind' => 'isNull']; + $innerClause = $this->buildTypeCheckClause($typeNode->type); + if (!empty($innerClause)) { + $check[] = count($innerClause) === 1 ? $innerClause[0] : ['kind' => 'allOf', 'types' => $innerClause]; + } } elseif ($typeNode instanceof UnionType) { - $subTypes = $typeNode->types; - $isNullable = false; + foreach ($typeNode->types as $subType) { + $clause = $this->buildTypeCheckClause($subType); + if (empty($clause)) { + continue; + } + $check[] = count($clause) === 1 ? $clause[0] : ['kind' => 'allOf', 'types' => $clause]; + } + } elseif ($typeNode instanceof IntersectionType) { + $clause = $this->buildTypeCheckClause($typeNode); + if (!empty($clause)) { + $check[] = count($clause) === 1 ? $clause[0] : ['kind' => 'allOf', 'types' => $clause]; + } } else { return ['check' => [], 'typeStr' => '']; } - foreach ($subTypes as $subType) { - $name = $this->parseIdentifier($subType); - $nameLower = strtolower($name); - - if ($nameLower === 'void' or $nameLower === 'never') { - $this->fatalError($subType, "Type '{$nameLower}' cannot be part of a union type"); - } + if (empty($check)) { + return ['check' => [], 'typeStr' => $typeStr]; + } - if ($nameLower === 'mixed') { - // mixed accepts everything — don't add any check - $names[] = $name; - continue; - } + return ['check' => $check, 'typeStr' => $typeStr]; + } - $entry = match ($nameLower) { - 'int' => ['kind' => 'isInt'], - 'float', 'double' => ['kind' => 'isFloat'], - 'bool' => ['kind' => 'isBool'], - 'string' => ['kind' => 'isString'], - 'array' => ['kind' => 'isArray'], - 'object' => ['kind' => 'isObject'], - 'null' => ['kind' => 'isNull'], - 'true' => ['kind' => 'isTrue'], - 'false' => ['kind' => 'isFalse'], - 'resource' => ['kind' => 'isResource'], - 'callable' => ['kind' => 'callable'], - 'iterable' => ['kind' => 'iterable'], - default => null, - }; - - if ($entry !== null) { - $check[] = $entry; - } else { - // Class/interface type - if ($name === 'self') { - $class = $this->getFullClassName(); - } elseif ($name === 'parent') { - $class = $this->classDef->extends ?? ''; - } elseif ($name === 'static') { - $class = 'static'; - } else { - $class = $this->getNamespacedClassName($name); - } - if ($class) { - $check[] = ['kind' => 'instanceof', 'class' => $class]; + private function buildTypeCheckClause(NodeAbstract $typeNode): array + { + if ($typeNode instanceof IntersectionType) { + $clause = []; + foreach ($typeNode->types as $subType) { + foreach ($this->buildTypeCheckClause($subType) as $entry) { + $clause[] = $entry; } } - $names[] = $name; + return $clause; + } + + $name = $this->parseIdentifier($typeNode); + $nameLower = strtolower($name); + + if ($nameLower === 'void' || $nameLower === 'never') { + $this->fatalError($typeNode, "Type '{$nameLower}' cannot be part of a composite type"); + } + + if ($nameLower === 'mixed') { + return []; } - if ($isNullable) { - // NullableType: prepend null to both check array and typeStr - array_unshift($check, ['kind' => 'isNull']); - $typeStr = '?' . implode('|', $names); + $entry = match ($nameLower) { + 'int' => ['kind' => 'isInt'], + 'float', 'double' => ['kind' => 'isFloat'], + 'bool' => ['kind' => 'isBool'], + 'string' => ['kind' => 'isString'], + 'array' => ['kind' => 'isArray'], + 'object' => ['kind' => 'isObject'], + 'null' => ['kind' => 'isNull'], + 'true' => ['kind' => 'isTrue'], + 'false' => ['kind' => 'isFalse'], + 'resource' => ['kind' => 'isResource'], + 'callable' => ['kind' => 'callable'], + 'iterable' => ['kind' => 'iterable'], + default => null, + }; + + if ($entry !== null) { + return [$entry]; + } + + if ($name === 'self') { + $class = $this->getFullClassName(); + } elseif ($name === 'parent') { + $class = $this->classDef->extends ?? ''; + } elseif ($name === 'static') { + $class = 'static'; } else { - $typeStr = implode('|', $names); + $class = $this->getNamespacedClassName($name); } - if (empty($check)) { - return ['check' => [], 'typeStr' => $typeStr]; + return $class ? [['kind' => 'instanceof', 'class' => $class]] : []; + } + + private function typeCheckNodeToString(NodeAbstract $typeNode): string + { + if ($typeNode instanceof Node\Identifier) { + return $typeNode->name; + } + if ($typeNode instanceof Node\Name) { + return $typeNode->toString(); + } + if ($typeNode instanceof NullableType) { + return '?' . $this->typeCheckNodeToString($typeNode->type); + } + if ($typeNode instanceof UnionType) { + $parts = []; + foreach ($typeNode->types as $type) { + $parts[] = $this->typeCheckNodeToString($type); + } + sort($parts); + return implode('|', $parts); + } + if ($typeNode instanceof IntersectionType) { + $parts = []; + foreach ($typeNode->types as $type) { + $parts[] = $this->typeCheckNodeToString($type); + } + sort($parts); + return implode('&', $parts); } - return ['check' => $check, 'typeStr' => $typeStr]; + return $this->printer->prettyPrint([$typeNode]); } protected function genSingleTypeCondition(string $varName, array $entry): string @@ -112,6 +155,7 @@ trait TypeCheckGenerator 'isResource' => $v . '.isResource()', 'callable' => $v . '.isCallable()', 'iterable' => '(' . $v . '.isArray() || (' . $v . '.isObject() && php::instanceOf(' . $v . ', zend_ce_traversable)))', + 'allOf' => $this->genAllOfTypeCondition($varName, $entry['types']), 'instanceof' => $entry['class'] === 'static' ? '(' . $v . '.isObject() && php::instanceOf(' . $v . ', php_get_called_ce(this_)))' : '(' . $v . '.isObject() && php::instanceOf(' . $v . ', ' . $this->getClassEntryPtr($entry['class']) . '))', @@ -119,6 +163,23 @@ trait TypeCheckGenerator }; } + private function genAllOfTypeCondition(string $varName, array $types): string + { + $conditions = []; + foreach ($types as $type) { + $cond = $this->genSingleTypeCondition($varName, $type); + if ($cond !== '') { + $conditions[] = $cond; + } + } + + if (empty($conditions)) { + return ''; + } + + return '(' . implode(' && ', $conditions) . ')'; + } + protected function genUnionParamCheck(ArgInfo $argInfo, int $argIndex): string { if (empty($argInfo->typeCheck)) { diff --git a/src/Php/Preprocessor.php b/src/Php/Preprocessor.php index 8120abae..c9bc6693 100644 --- a/src/Php/Preprocessor.php +++ b/src/Php/Preprocessor.php @@ -19,6 +19,7 @@ use PhpAot\Php\Entity\PropertyDef; use PhpAot\Php\Exception\SyntaxError; use PhpParser\Modifiers; use PhpParser\Node; +use PhpParser\Node\IntersectionType; use PhpParser\Node\NullableType; use PhpParser\Node\UnionType; use PhpParser\NodeAbstract; @@ -284,7 +285,7 @@ class Preprocessor extends CompilerBase if ($param->type === null || $param->type instanceof NullableType) { $argInfo->nullable = true; } - if ($param->type instanceof NullableType or $param->type instanceof UnionType) { + if ($param->type instanceof NullableType || $param->type instanceof UnionType || $param->type instanceof IntersectionType) { $typeInfo = $this->buildTypeCheckFromNode($param->type); if (!empty($typeInfo['check'])) { $argInfo->typeCheck = $typeInfo['check']; @@ -360,7 +361,7 @@ class Preprocessor extends CompilerBase $functionDef->stub = $this->stubFile; $functionDef->returnTypeUndeclared = $v->returnType === null; - if ($v->returnType instanceof NullableType or $v->returnType instanceof UnionType) { + if ($v->returnType instanceof NullableType || $v->returnType instanceof UnionType || $v->returnType instanceof IntersectionType) { $typeInfo = $this->buildTypeCheckFromNode($v->returnType); if (!empty($typeInfo['check'])) { $functionDef->returnTypeCheck = $typeInfo['check']; diff --git a/tests/aot/type_decl/intersection-param-check.phpt b/tests/aot/type_decl/intersection-param-check.phpt new file mode 100644 index 00000000..ad052f9f --- /dev/null +++ b/tests/aot/type_decl/intersection-param-check.phpt @@ -0,0 +1,34 @@ +--TEST-- +Intersection type: parameter runtime type checking +--FILE-- +getMessage(); + } + + foreach ($errors as $err) { + var_dump($err); + } +} +?> +--EXPECT-- +string(4) "Both" +string(71) "expect_both(): Argument #1 ($value) must be of type IA&IB, object given" diff --git a/tests/aot/type_decl/intersection-return-check.phpt b/tests/aot/type_decl/intersection-return-check.phpt new file mode 100644 index 00000000..dca68c9d --- /dev/null +++ b/tests/aot/type_decl/intersection-return-check.phpt @@ -0,0 +1,34 @@ +--TEST-- +Intersection type: return runtime type checking +--FILE-- +getMessage(); + } + + foreach ($errors as $err) { + var_dump($err); + } +} +?> +--EXPECT-- +string(4) "Both" +string(63) "return_both(): Return value must be of type IA&IB, object given"