feat(php): 添加对交集类型的支持

- 在 CompilerBase 中添加 IntersectionType 的引入和处理逻辑
- 更新 upgradeToFullyQualifiedName 方法以处理交集类型
- 修改复杂类型处理逻辑,将交集类型统一按 mixed/var 处理并在运行时进行 typeCheck
- 在 Preprocessor 中添加对交集类型的参数和返回值检查支持
- 扩展 TypeCheckGenerator 以支持交集类型的类型检查代码生成
- 添加交集类型参数和返回值的运行时检查测试用例
- 实现交集类型的字符串表示转换功能
- 添加 allOf 类型条件生成器以处理多个类型约束组合
pull/5/head
韩天峰 2 months ago
parent 04610c5897
commit a0c9591e81
  1. 45
      phpunit/src/PreprocessorTest.php
  2. 11
      src/Php/CompilerBase.php
  3. 175
      src/Php/Generator/TypeCheckGenerator.php
  4. 5
      src/Php/Preprocessor.php
  5. 34
      tests/aot/type_decl/intersection-param-check.phpt
  6. 34
      tests/aot/type_decl/intersection-return-check.phpt

@ -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('<?php interface A {} interface B {} function demo(A&B $value): void {}');
$functionDef = $this->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('<?php interface A {} interface B {} function demo(): A&B { throw new \Exception(); }');
$functionDef = $this->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('<?php function demo(): ?int { return null; }');
$functionDef = $this->invokeMethod('parseFunctionDecl', $fn);
$this->assertSame('php::Var', $functionDef->returnType);
$this->assertNotEmpty($functionDef->returnTypeCheck);
$this->assertSame('?int', $functionDef->returnTypeStr);
}
}

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

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

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

@ -0,0 +1,34 @@
--TEST--
Intersection type: parameter runtime type checking
--FILE--
<?php
interface IA {}
interface IB {}
class Both implements IA, IB {}
class OnlyA implements IA {}
function expect_both(IA&IB $value): void {
var_dump(get_class($value));
}
function main() {
expect_both(new Both());
$errors = [];
try {
expect_both(new OnlyA());
} catch (\TypeError $e) {
$errors[] = $e->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"

@ -0,0 +1,34 @@
--TEST--
Intersection type: return runtime type checking
--FILE--
<?php
interface IA {}
interface IB {}
class Both implements IA, IB {}
class OnlyA implements IA {}
function return_both(object $value): IA&IB {
return $value;
}
function main() {
var_dump(get_class(return_both(new Both())));
$errors = [];
try {
return_both(new OnlyA());
} catch (\TypeError $e) {
$errors[] = $e->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"
Loading…
Cancel
Save