parent
1e70321553
commit
b4919c7e76
6 changed files with 631 additions and 40 deletions
@ -0,0 +1,290 @@ |
||||
<?php |
||||
/** |
||||
* This file is part of TypePHP(AOT). |
||||
* |
||||
* @link https://www.swoole.com/aot/ |
||||
* @contact service@swoole.com |
||||
*/ |
||||
|
||||
use TypePhp\CompilerTest; |
||||
use TypePhp\Exception\TestError; |
||||
|
||||
/** |
||||
* Compound declarations must be rejected by the TypePHP front end, rather |
||||
* than leaking into gen_stub.php or the C++ compiler. |
||||
* @internal |
||||
* @coversNothing |
||||
*/ |
||||
final class CompoundTypeDeclarationValidationTest extends PHPUnit\Framework\TestCase |
||||
{ |
||||
private string $testRoot; |
||||
|
||||
protected function setUp(): void |
||||
{ |
||||
$this->testRoot = sys_get_temp_dir() . '/typephp-compound-type-' . bin2hex(random_bytes(8)); |
||||
mkdir($this->testRoot, 0777, true); |
||||
} |
||||
|
||||
protected function tearDown(): void |
||||
{ |
||||
if (!is_dir($this->testRoot)) { |
||||
return; |
||||
} |
||||
$iterator = new RecursiveIteratorIterator( |
||||
new RecursiveDirectoryIterator($this->testRoot, FilesystemIterator::SKIP_DOTS), |
||||
RecursiveIteratorIterator::CHILD_FIRST, |
||||
); |
||||
foreach ($iterator as $entry) { |
||||
$entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); |
||||
} |
||||
rmdir($this->testRoot); |
||||
} |
||||
|
||||
/** @dataProvider invalidNamedDeclarationProvider */ |
||||
public function testInvalidNamedDeclarationFailsDuringPrepare(string $declaration, string $diagnostic): void |
||||
{ |
||||
[$compiler, $file] = $this->compilerFor("<?php\n{$declaration}\nfunction main(): void {}\n");
|
||||
|
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage($diagnostic); |
||||
$compiler->prepareFile($file); |
||||
} |
||||
|
||||
public static function invalidNamedDeclarationProvider(): iterable |
||||
{ |
||||
yield 'duplicate builtin union member' => [ |
||||
'function broken(int|string|int $value): void {}', |
||||
'Duplicate type int is redundant', |
||||
]; |
||||
yield 'duplicate resolved class union member' => [ |
||||
'namespace App; use Vendor\Model as Item; function broken(Item|\Vendor\Model $value): void {}', |
||||
'Duplicate type Vendor\Model is redundant', |
||||
]; |
||||
yield 'iterable includes array' => [ |
||||
'function broken(iterable|array $value): void {}', |
||||
'Duplicate type array is redundant', |
||||
]; |
||||
yield 'iterable includes Traversable' => [ |
||||
'function broken(\Traversable|iterable $value): void {}', |
||||
'Duplicate type Traversable is redundant', |
||||
]; |
||||
yield 'bool includes false' => [ |
||||
'function broken(false|bool $value): void {}', |
||||
'Duplicate type false is redundant', |
||||
]; |
||||
yield 'true and false must use bool' => [ |
||||
'function broken(true|false $value): void {}', |
||||
'Type contains both true and false, bool must be used instead', |
||||
]; |
||||
yield 'mixed in union' => [ |
||||
'function broken(mixed|string $value): void {}', |
||||
'Type mixed can only be used as a standalone type', |
||||
]; |
||||
yield 'void in union' => [ |
||||
'function broken(): void|string {}', |
||||
'Type void can only be used as a standalone type', |
||||
]; |
||||
yield 'never in union' => [ |
||||
'function broken(): never|string {}', |
||||
'Type never can only be used as a standalone type', |
||||
]; |
||||
yield 'nullable mixed' => [ |
||||
'function broken(?mixed $value): void {}', |
||||
'Type mixed cannot be marked as nullable since mixed already includes null', |
||||
]; |
||||
yield 'nullable null' => [ |
||||
'function broken(?null $value): void {}', |
||||
'null cannot be marked as nullable', |
||||
]; |
||||
yield 'nullable void' => [ |
||||
'function broken(): ?void {}', |
||||
'Void can only be used as a standalone type', |
||||
]; |
||||
yield 'nullable never' => [ |
||||
'function broken(): ?never {}', |
||||
'never can only be used as a standalone type', |
||||
]; |
||||
yield 'scalar intersection member' => [ |
||||
'function broken(A&int $value): void {}', |
||||
'Type int cannot be part of an intersection type', |
||||
]; |
||||
yield 'callable intersection member' => [ |
||||
'function broken(A&callable $value): void {}', |
||||
'Type callable cannot be part of an intersection type', |
||||
]; |
||||
yield 'duplicate resolved intersection member' => [ |
||||
'namespace App; use Vendor\Contract as C; function broken(C&\Vendor\Contract $value): void {}', |
||||
'Duplicate type Vendor\Contract is redundant', |
||||
]; |
||||
yield 'permuted duplicate DNF member' => [ |
||||
'function broken((A&B)|(B&A) $value): void {}', |
||||
'Type B&A is redundant with type A&B', |
||||
]; |
||||
yield 'DNF strict superset after subset' => [ |
||||
'function broken((A&B)|(A&B&C) $value): void {}', |
||||
'Type A&B&C is redundant as it is more restrictive than type A&B', |
||||
]; |
||||
yield 'DNF strict superset before subset' => [ |
||||
'function broken((A&B&C)|(A&B) $value): void {}', |
||||
'Type A&B&C is redundant as it is more restrictive than type A&B', |
||||
]; |
||||
yield 'plain class subsumes DNF member' => [ |
||||
'function broken((A&B)|A $value): void {}', |
||||
'Type A&B is redundant as it is more restrictive than type A', |
||||
]; |
||||
yield 'object subsumes class' => [ |
||||
'function broken(object|A $value): void {}', |
||||
'contains both object and a class type, which is redundant', |
||||
]; |
||||
yield 'object subsumes DNF member' => [ |
||||
'function broken(object|(A&B) $value): void {}', |
||||
'contains both object and a class type, which is redundant', |
||||
]; |
||||
yield 'self in global function' => [ |
||||
'function broken(): self {}', |
||||
'Cannot use "self" when no class scope is active', |
||||
]; |
||||
yield 'self in global function union' => [ |
||||
'function broken(): self|A {}', |
||||
'Cannot use "self" when no class scope is active', |
||||
]; |
||||
yield 'self in global function DNF parameter' => [ |
||||
'function broken((self&A)|B $value): void {}', |
||||
'Cannot use "self" when no class scope is active', |
||||
]; |
||||
yield 'static in global function' => [ |
||||
'function broken(): static {}', |
||||
'Cannot use "static" when no class scope is active', |
||||
]; |
||||
yield 'static in global function union' => [ |
||||
'function broken(): static|A {}', |
||||
'Cannot use "static" when no class scope is active', |
||||
]; |
||||
yield 'parent in global function' => [ |
||||
'function broken(): parent {}', |
||||
'Cannot use "parent" when no class scope is active', |
||||
]; |
||||
yield 'parent method without parent class' => [ |
||||
'class A { public function broken(): parent {} }', |
||||
'Cannot use "parent" when current class scope has no parent', |
||||
]; |
||||
yield 'parent property without parent class' => [ |
||||
'class A { public parent $value; }', |
||||
'Cannot use "parent" when current class scope has no parent', |
||||
]; |
||||
yield 'parent constant without parent class' => [ |
||||
'class A { public const parent VALUE = null; }', |
||||
'Cannot use "parent" when current class scope has no parent', |
||||
]; |
||||
yield 'self in intersection inside class' => [ |
||||
'class A { public function broken(self&B $value): void {} }', |
||||
"Type 'self' cannot be part of an intersection type", |
||||
]; |
||||
yield 'self in DNF promoted property' => [ |
||||
'class A { public function __construct(public (self&B)|C $value) {} }', |
||||
"Type 'self' cannot be part of an intersection type", |
||||
]; |
||||
yield 'static in intersection return type' => [ |
||||
'class A { public function broken(): static&B {} }', |
||||
"Type 'static' cannot be part of an intersection type", |
||||
]; |
||||
yield 'duplicate class implements' => [ |
||||
'interface I {} class A implements I, I {}', |
||||
'Class A cannot implement previously implemented interface I', |
||||
]; |
||||
yield 'duplicate enum implements through alias' => [ |
||||
'namespace App; interface I {} use App\I as Contract; enum E implements I, Contract { case A; }', |
||||
'Enum App\E cannot implement previously implemented interface App\I', |
||||
]; |
||||
} |
||||
|
||||
/** @dataProvider invalidClosureDeclarationProvider */ |
||||
public function testInvalidClosureDeclarationFailsDuringConvert(string $body, string $diagnostic): void |
||||
{ |
||||
[$compiler, $file] = $this->compilerFor("<?php\nfunction main(): void {\n{$body}\n}\n");
|
||||
$compiler->prepareFile($file); |
||||
|
||||
$this->expectException(TestError::class); |
||||
$this->expectExceptionMessage($diagnostic); |
||||
$compiler->convertFile($file); |
||||
} |
||||
|
||||
public static function invalidClosureDeclarationProvider(): iterable |
||||
{ |
||||
yield 'closure duplicate union' => [ |
||||
'$fn = function (int|string|int $value): void {};', |
||||
'Duplicate type int is redundant', |
||||
]; |
||||
yield 'arrow function invalid intersection' => [ |
||||
'$fn = fn (A&int $value): int => 1;', |
||||
'Type int cannot be part of an intersection type', |
||||
]; |
||||
yield 'closure permuted DNF' => [ |
||||
'$fn = function ((A&B)|(B&A) $value): void {};', |
||||
'Type B&A is redundant with type A&B', |
||||
]; |
||||
yield 'global closure self intersection' => [ |
||||
'$fn = function (self&A $value): void {};', |
||||
"Type 'self' cannot be part of an intersection type", |
||||
]; |
||||
} |
||||
|
||||
public function testValidBoundaryDeclarationsCompile(): void |
||||
{ |
||||
[$compiler, $file] = $this->compilerFor(<<<'PHP' |
||||
<?php |
||||
namespace App; |
||||
|
||||
interface A {} |
||||
interface B {} |
||||
interface C {} |
||||
interface Traversable {} |
||||
|
||||
class Base {} |
||||
class Child extends Base |
||||
{ |
||||
public function choose((A&B)|(A&C)|self|parent $value): (A&B)|(A&C)|self|parent|static |
||||
{ |
||||
return $value; |
||||
} |
||||
} |
||||
|
||||
function localNameDoesNotOverlapBuiltin(iterable|Traversable $value): void {} |
||||
function main(): void {} |
||||
PHP); |
||||
$compiler->prepareFile($file); |
||||
$compiler->convertFile($file); |
||||
|
||||
self::assertFileExists($compiler->getCppFile($file)); |
||||
} |
||||
|
||||
public function testGlobalClosuresKeepBindableSelfAndStaticTypes(): void |
||||
{ |
||||
[$compiler, $file] = $this->compilerFor(<<<'PHP' |
||||
<?php |
||||
function main(): void |
||||
{ |
||||
$withSelf = function (self $value): self { return $value; }; |
||||
$withStatic = function (): static { throw new Exception('not invoked'); }; |
||||
} |
||||
PHP); |
||||
$compiler->prepareFile($file); |
||||
$compiler->convertFile($file); |
||||
|
||||
self::assertFileExists($compiler->getCppFile($file)); |
||||
} |
||||
|
||||
/** @return array{CompilerTest, string} */ |
||||
private function compilerFor(string $source): array |
||||
{ |
||||
$file = $this->testRoot . '/program.php'; |
||||
file_put_contents($file, $source); |
||||
|
||||
global $translator; |
||||
$compiler = CompilerTest::create($this->testRoot); |
||||
$translator = $compiler; |
||||
$compiler->addFiles([$file]); |
||||
|
||||
return [$compiler, $file]; |
||||
} |
||||
} |
||||
@ -0,0 +1,271 @@ |
||||
<?php |
||||
/** |
||||
* This file is part of TypePHP(AOT). |
||||
* |
||||
* @link https://www.swoole.com/aot/ |
||||
* @contact service@swoole.com |
||||
*/ |
||||
|
||||
namespace TypePhp\TypeSystem; |
||||
|
||||
use PhpParser\Node; |
||||
use PhpParser\Node\IntersectionType; |
||||
use PhpParser\Node\NullableType; |
||||
use PhpParser\Node\UnionType; |
||||
use PhpParser\NodeAbstract; |
||||
|
||||
/** |
||||
* Validates the structure of PHP compound type declarations before they are |
||||
* lowered to TypePHP's runtime type checks. |
||||
* |
||||
* This deliberately has no knowledge of the declaration context. Rules for |
||||
* self/parent/static outside a class-like scope remain in the preprocessor so |
||||
* closures, whose scope may be supplied later by Closure::bindTo(), are not |
||||
* incorrectly treated as ordinary global functions. |
||||
*/ |
||||
trait CompoundTypeDeclarationValidationTrait |
||||
{ |
||||
/** @var array<string, true> */ |
||||
private const array PHP_INTERSECTION_FORBIDDEN_TYPES = [ |
||||
'array' => true, |
||||
'bool' => true, |
||||
'callable' => true, |
||||
'false' => true, |
||||
'float' => true, |
||||
'int' => true, |
||||
'iterable' => true, |
||||
'mixed' => true, |
||||
'never' => true, |
||||
'null' => true, |
||||
'object' => true, |
||||
'parent' => true, |
||||
'self' => true, |
||||
'static' => true, |
||||
'string' => true, |
||||
'true' => true, |
||||
'void' => true, |
||||
]; |
||||
|
||||
protected function validateCompoundTypeDeclaration(?NodeAbstract $type): void |
||||
{ |
||||
if ($type instanceof NullableType) { |
||||
$name = strtolower($this->parseIdentifier($type->type)); |
||||
if ($name === 'mixed') { |
||||
$this->fatalError($type, 'Type mixed cannot be marked as nullable since mixed already includes null'); |
||||
} |
||||
if ($name === 'null') { |
||||
$this->fatalError($type, 'null cannot be marked as nullable'); |
||||
} |
||||
if ($name === 'void') { |
||||
$this->fatalError($type, 'Void can only be used as a standalone type'); |
||||
} |
||||
if ($name === 'never') { |
||||
$this->fatalError($type, 'never can only be used as a standalone type'); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
if ($type instanceof IntersectionType) { |
||||
$this->validateIntersectionTypeDeclaration($type); |
||||
return; |
||||
} |
||||
|
||||
if ($type instanceof UnionType) { |
||||
$this->validateUnionTypeDeclaration($type); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* @return array{members: array<string, true>, display: string} |
||||
*/ |
||||
private function validateIntersectionTypeDeclaration(IntersectionType $type): array |
||||
{ |
||||
$members = []; |
||||
$display = []; |
||||
foreach ($type->types as $member) { |
||||
[$key, $name, $classLike] = $this->getCompoundTypeMemberIdentity($member); |
||||
$lowerName = strtolower($name); |
||||
if (!$classLike || isset(self::PHP_INTERSECTION_FORBIDDEN_TYPES[$lowerName])) { |
||||
$message = in_array($lowerName, ['self', 'parent', 'static'], true) |
||||
? "Type '{$lowerName}' cannot be part of an intersection type" |
||||
: "Type {$name} cannot be part of an intersection type"; |
||||
$this->fatalError($member, $message); |
||||
} |
||||
if (isset($members[$key])) { |
||||
$this->fatalError($member, "Duplicate type {$name} is redundant"); |
||||
} |
||||
$members[$key] = true; |
||||
$display[] = $name; |
||||
} |
||||
|
||||
return ['members' => $members, 'display' => implode('&', $display)]; |
||||
} |
||||
|
||||
private function validateUnionTypeDeclaration(UnionType $type): void |
||||
{ |
||||
/** @var array<string, true> $seen */ |
||||
$seen = []; |
||||
/** @var list<array{members: array<string, true>, display: string}> $classGroups */ |
||||
$classGroups = []; |
||||
$hasIterable = false; |
||||
$hasArray = false; |
||||
$hasTraversable = false; |
||||
$hasBool = false; |
||||
$hasTrue = false; |
||||
$hasFalse = false; |
||||
$hasObject = false; |
||||
$hasClassType = false; |
||||
|
||||
foreach ($type->types as $member) { |
||||
if ($member instanceof IntersectionType) { |
||||
$group = $this->validateIntersectionTypeDeclaration($member); |
||||
$this->assertDnfGroupIsNotRedundant($member, $group, $classGroups); |
||||
$classGroups[] = $group; |
||||
$hasClassType = true; |
||||
continue; |
||||
} |
||||
|
||||
[$key, $name, $classLike] = $this->getCompoundTypeMemberIdentity($member); |
||||
$lowerName = strtolower($name); |
||||
if (in_array($lowerName, ['mixed', 'void', 'never'], true)) { |
||||
$this->fatalError($member, "Type {$name} can only be used as a standalone type"); |
||||
} |
||||
if (isset($seen[$key])) { |
||||
$this->fatalError($member, "Duplicate type {$name} is redundant"); |
||||
} |
||||
|
||||
if ($lowerName === 'iterable') { |
||||
if ($hasArray) { |
||||
$this->fatalError($member, 'Duplicate type array is redundant'); |
||||
} |
||||
if ($hasTraversable) { |
||||
$this->fatalError($member, 'Duplicate type Traversable is redundant'); |
||||
} |
||||
$hasIterable = true; |
||||
} elseif ($lowerName === 'array') { |
||||
if ($hasIterable) { |
||||
$this->fatalError($member, 'Duplicate type array is redundant'); |
||||
} |
||||
$hasArray = true; |
||||
} elseif ($key === 'class:traversable') { |
||||
if ($hasIterable) { |
||||
$this->fatalError($member, 'Duplicate type Traversable is redundant'); |
||||
} |
||||
$hasTraversable = true; |
||||
} |
||||
|
||||
if ($lowerName === 'bool') { |
||||
if ($hasTrue) { |
||||
$this->fatalError($member, 'Duplicate type true is redundant'); |
||||
} |
||||
if ($hasFalse) { |
||||
$this->fatalError($member, 'Duplicate type false is redundant'); |
||||
} |
||||
$hasBool = true; |
||||
} elseif ($lowerName === 'true') { |
||||
if ($hasBool) { |
||||
$this->fatalError($member, 'Duplicate type true is redundant'); |
||||
} |
||||
if ($hasFalse) { |
||||
$this->fatalError($member, 'Type contains both true and false, bool must be used instead'); |
||||
} |
||||
$hasTrue = true; |
||||
} elseif ($lowerName === 'false') { |
||||
if ($hasBool) { |
||||
$this->fatalError($member, 'Duplicate type false is redundant'); |
||||
} |
||||
if ($hasTrue) { |
||||
$this->fatalError($member, 'Type contains both true and false, bool must be used instead'); |
||||
} |
||||
$hasFalse = true; |
||||
} |
||||
|
||||
if ($lowerName === 'object') { |
||||
$hasObject = true; |
||||
} elseif ($classLike) { |
||||
$hasClassType = true; |
||||
$group = ['members' => [$key => true], 'display' => $name]; |
||||
$this->assertDnfGroupIsNotRedundant($member, $group, $classGroups); |
||||
$classGroups[] = $group; |
||||
} |
||||
|
||||
$seen[$key] = true; |
||||
} |
||||
|
||||
if ($hasObject && $hasClassType) { |
||||
$this->fatalError($type, 'Type ' . $this->compoundTypeToString($type) . ' contains both object and a class type, which is redundant'); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* @param array{members: array<string, true>, display: string} $group |
||||
* @param list<array{members: array<string, true>, display: string}> $previousGroups |
||||
*/ |
||||
private function assertDnfGroupIsNotRedundant(NodeAbstract $node, array $group, array $previousGroups): void |
||||
{ |
||||
foreach ($previousGroups as $previous) { |
||||
$sameMembers = count($group['members']) === count($previous['members']) |
||||
&& array_diff_key($group['members'], $previous['members']) === []; |
||||
if ($sameMembers) { |
||||
$this->fatalError( |
||||
$node, |
||||
"Type {$group['display']} is redundant with type {$previous['display']}", |
||||
); |
||||
} |
||||
|
||||
$groupContainsPrevious = array_diff_key($previous['members'], $group['members']) === []; |
||||
$previousContainsGroup = array_diff_key($group['members'], $previous['members']) === []; |
||||
if ($groupContainsPrevious || $previousContainsGroup) { |
||||
$moreRestrictive = count($group['members']) > count($previous['members']) ? $group : $previous; |
||||
$lessRestrictive = $moreRestrictive === $group ? $previous : $group; |
||||
$this->fatalError( |
||||
$node, |
||||
"Type {$moreRestrictive['display']} is redundant as it is more restrictive than type {$lessRestrictive['display']}", |
||||
); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* @return array{string, string, bool} canonical key, display name, class-like |
||||
*/ |
||||
private function getCompoundTypeMemberIdentity(NodeAbstract $member): array |
||||
{ |
||||
$name = $this->parseIdentifier($member); |
||||
$lowerName = strtolower($name); |
||||
if (in_array($lowerName, ['self', 'parent', 'static'], true)) { |
||||
return ['class:' . $lowerName, $lowerName, true]; |
||||
} |
||||
if ($member instanceof Node\Identifier || isset($this->zendTypeMap[$lowerName])) { |
||||
return ['builtin:' . $lowerName, $lowerName, false]; |
||||
} |
||||
|
||||
if ($member instanceof Node\Name) { |
||||
$resolvedName = $member->getAttribute('resolvedName'); |
||||
if ($resolvedName instanceof Node\Name) { |
||||
$name = $resolvedName->toString(); |
||||
} elseif ($member instanceof Node\Name\FullyQualified) { |
||||
$name = $member->toString(); |
||||
} else { |
||||
$name = $this->getNamespacedClassName($name); |
||||
} |
||||
} |
||||
|
||||
return ['class:' . strtolower(ltrim($name, '\\')), ltrim($name, '\\'), true]; |
||||
} |
||||
|
||||
private function compoundTypeToString(NodeAbstract $type): string |
||||
{ |
||||
if ($type instanceof UnionType) { |
||||
return implode('|', array_map(fn (NodeAbstract $member): string => $this->compoundTypeToString($member), $type->types)); |
||||
} |
||||
if ($type instanceof IntersectionType) { |
||||
return implode('&', array_map(fn (NodeAbstract $member): string => $this->compoundTypeToString($member), $type->types)); |
||||
} |
||||
if ($type instanceof NullableType) { |
||||
return '?' . $this->compoundTypeToString($type->type); |
||||
} |
||||
[, $name] = $this->getCompoundTypeMemberIdentity($type); |
||||
return $name; |
||||
} |
||||
} |
||||
Loading…
Reference in new issue