fix(trait): validate adaptations and member values

master
韩天峰 1 day ago
parent 1db9423312
commit c5ba5afe65
  1. 21
      phpunit/src/Entity/ClassDefExtendedTest.php
  2. 2
      phpunit/src/PreprocessorTest.php
  3. 144
      phpunit/src/TraitAdaptationValidationTest.php
  4. 178
      phpunit/src/TraitMemberValueCompatibilityTest.php
  5. 17
      src/Entity/ClassDef.php
  6. 25
      src/Preprocessor.php
  7. 72
      src/Resolver/ClassConstantValueTrait.php
  8. 267
      src/Translator.php
  9. 45
      tests/compiler/trait/trait-member-equivalent-values.phpt

@ -59,12 +59,21 @@ class ClassDefExtendedTest extends TestCase
public function testTraitAliasesAndIgnoredCanBeSet(): void public function testTraitAliasesAndIgnoredCanBeSet(): void
{ {
$class = new ClassDef('User', Modifiers::PUBLIC); $class = new ClassDef('User', Modifiers::PUBLIC);
$class->traitAliases['Full\\Trait::method'] = ['alias' => 'newName']; $class->traitAliases['full\\trait::method'][] = [
$class->traitIgnored['Full\\Trait::other'] = true; 'group' => '1:0',
'trait' => 'Full\\Trait',
$this->assertArrayHasKey('Full\\Trait::method', $class->traitAliases); 'method' => 'method',
$this->assertArrayHasKey('Full\\Trait::other', $class->traitIgnored); 'newName' => 'newName',
$this->assertTrue($class->traitIgnored['Full\\Trait::other']); 'newModifier' => 0,
];
$class->traitIgnored['full\\trait::other'][] = [
'winnerTrait' => 'Full\\Winner',
'loserTrait' => 'Full\\Trait',
'method' => 'other',
];
$this->assertSame('newName', $class->traitAliases['full\\trait::method'][0]['newName']);
$this->assertSame('Full\\Winner', $class->traitIgnored['full\\trait::other'][0]['winnerTrait']);
} }
public function testExtendsCanBeSet(): void public function testExtendsCanBeSet(): void

@ -378,6 +378,8 @@ class PreprocessorTest extends TestCase
$this->assertArrayHasKey('aliasmodifieruser', $classes); $this->assertArrayHasKey('aliasmodifieruser', $classes);
$aliases = $classes['aliasmodifieruser']->traitAliases; $aliases = $classes['aliasmodifieruser']->traitAliases;
$this->assertArrayHasKey('aliasmodifiertrait::hello', $aliases); $this->assertArrayHasKey('aliasmodifiertrait::hello', $aliases);
$this->assertNull($aliases['aliasmodifiertrait::hello'][0]['trait']);
$this->assertSame('hello', $aliases['aliasmodifiertrait::hello'][0]['method']);
$this->assertSame('hello', $aliases['aliasmodifiertrait::hello'][0]['newName']); $this->assertSame('hello', $aliases['aliasmodifiertrait::hello'][0]['newName']);
$this->assertSame(Modifiers::PRIVATE, $aliases['aliasmodifiertrait::hello'][0]['newModifier']); $this->assertSame(Modifiers::PRIVATE, $aliases['aliasmodifiertrait::hello'][0]['newModifier']);
} }

@ -0,0 +1,144 @@
<?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;
/**
* @internal
* @coversNothing
*/
final class TraitAdaptationValidationTest extends PHPUnit\Framework\TestCase
{
private string $testRoot;
protected function setUp(): void
{
$this->testRoot = sys_get_temp_dir() . '/typephp-trait-adaptation-' . 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 invalidAdaptationProvider */
public function testInvalidAdaptationIsRejectedDuringComposition(string $body, string $message): void
{
[$compiler, $file] = $this->compilerFor("<?php\n{$body}\nfunction main(): void {}\n");
$compiler->prepareFile($file);
$this->expectException(TestError::class);
$this->expectExceptionMessage($message);
$compiler->composeTraitDeclarations([$file]);
}
public static function invalidAdaptationProvider(): iterable
{
yield 'unqualified alias names a missing method' => [
'trait A { public function f(): void {} } class C { use A { missing as renamed; } }',
'alias was defined for method `missing()`',
];
yield 'trait declared after consumer is still validated' => [
'class C { use A { missing as renamed; } } trait A { public function f(): void {} }',
'alias was defined for method `missing()`',
];
yield 'qualified alias names a missing method' => [
'trait A { public function f(): void {} } class C { use A { A::missing as renamed; } }',
'alias was defined for method `A::missing()`',
];
yield 'alias names a trait not used by the class' => [
'trait A { public function f(): void {} } trait B { public function f(): void {} } class C { use A { B::f as renamed; } }',
"Required Trait `B` wasn't added to `C`",
];
yield 'precedence winner has no method' => [
'trait A {} trait B { public function f(): void {} } class C { use A, B { A::f insteadof B; } }',
'precedence rule was defined for `A::f()`',
];
yield 'precedence winner trait is not used' => [
'trait A { public function f(): void {} } trait B { public function f(): void {} } class C { use B { A::f insteadof B; } }',
"Required Trait `A` wasn't added to `C`",
];
yield 'precedence loser trait is not used' => [
'trait A { public function f(): void {} } trait B { public function f(): void {} } class C { use A { A::f insteadof B; } }',
"Required Trait `B` wasn't added to `C`",
];
yield 'same method is excluded twice' => [
'trait A { public function f(): void {} } trait B { public function f(): void {} } class C { use A, B { A::f insteadof B; A::f insteadof B; } }',
'was excluded multiple times',
];
yield 'unqualified alias is ambiguous' => [
'trait A { public function f(): void {} } trait B { public function f(): void {} } class C { use A, B { f as renamed; A::f insteadof B; } }',
'exists in multiple traits',
];
}
public function testValidAdaptationsRemainSupported(): void
{
[$compiler, $file] = $this->compilerFor(<<<'PHP'
<?php
trait A { public function f(): void {} }
trait B {}
trait D { public function f(): void {} }
class C {
use A;
use B, D {
A::f insteadof B, D;
D::f as other;
}
}
function main(): void {}
PHP);
$compiler->prepareFile($file);
$compiler->composeTraitDeclarations([$file]);
$compiler->convertFile($file);
self::assertFileExists($compiler->getCppFile($file));
}
public function testAliasCanTargetMethodFromNestedTrait(): void
{
[$compiler, $file] = $this->compilerFor(<<<'PHP'
<?php
trait Inner { public function f(): void {} }
trait Outer { use Inner; }
class C { use Outer { f as renamed; } }
function main(): void {}
PHP);
$compiler->prepareFile($file);
$compiler->composeTraitDeclarations([$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,178 @@
<?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;
/**
* @internal
* @coversNothing
*/
final class TraitMemberValueCompatibilityTest extends PHPUnit\Framework\TestCase
{
private string $testRoot;
protected function setUp(): void
{
$this->testRoot = sys_get_temp_dir() . '/typephp-trait-values-' . 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 compatibleMemberProvider */
public function testEquivalentMemberValuesAreAccepted(string $declarations): void
{
[$compiler, $file] = $this->compilerFor("<?php\n{$declarations}\nfunction main(): void {}\n");
$compiler->prepareFile($file);
$compiler->composeTraitDeclarations([$file]);
$compiler->convertFile($file);
self::assertFileExists($compiler->getCppFile($file));
}
public static function compatibleMemberProvider(): iterable
{
yield 'equivalent constant expressions' => [
'trait A { public const X = 1 + 1; } trait B { public const X = 2; } class C { use A, B; }',
];
yield 'equivalent property arrays' => [
'trait A { public array $value = [1, 2]; } trait B { public array $value = array(1, 2); } class C { use A, B; }',
];
yield 'integer coerces to declared float' => [
'trait A { public float $value = 1; public const float X = 1; } trait B { public float $value = 1.0; public const float X = 1.0; } class C { use A, B; }',
];
yield 'same enum case identity' => [
"enum Status: string { case Active = 'active'; case Disabled = 'disabled'; } trait A { public const X = Status::Active; } trait B { public const X = Status::Active; } class C { use A, B; }",
];
yield 'same enum case nested in array' => [
"enum Status: string { case Active = 'active'; } trait A { public const X = ['case' => Status::Active]; } trait B { public const X = array('case' => Status::Active); } class C { use A, B; }",
];
yield 'indirect enum case identity' => [
"enum Status: string { case Active = 'active'; } const CURRENT = Status::Active; trait A { public const X = CURRENT; } trait B { public const X = Status::Active; } class C { use A, B; }",
];
yield 'inherited constant retaining enum identity' => [
"enum Status: string { case Active = 'active'; } class Base { public const X = Status::Active; } class Values extends Base {} trait A { public const X = Values::X; } trait B { public const X = Status::Active; } class C { use A, B; }",
];
yield 'enum case property defaults' => [
"enum Status: string { case Active = 'active'; } trait A { public Status \$value = Status::Active; } trait B { public Status \$value = Status::Active; } class C { use A, B; }",
];
yield 'trait self constant reference' => [
'trait A { public const BASE = 1; public const X = self::BASE + 1; } trait B { public const BASE = 1; public const X = 2; } class C { use A, B; }',
];
yield 'class declaration and trait expression are equivalent' => [
'trait A { public const X = 1 + 1; public array $value = [1, 2]; } class C { use A; public const X = 2; public array $value = array(1, 2); }',
];
}
public function testClassMagicConstantIsComparedInConsumerScope(): void
{
[$compiler, $file] = $this->compilerFor(<<<'PHP'
<?php
trait A { public const X = __CLASS__; }
trait B { public const X = __CLASS__; }
class C { use A, B; }
function main(): void {}
PHP);
$compiler->prepareFile($file);
$compiler->composeTraitDeclarations([$file]);
// gen_stub.php does not currently lower __CLASS__ in a class constant;
// this assertion deliberately protects only Trait compatibility.
self::addToAssertionCount(1);
}
public function testLexicalImportsAreUsedWhileComparingTraitConstants(): void
{
[$compiler, $file] = $this->compilerFor(<<<'PHP'
<?php
namespace Domain;
enum Status: string { case Active = 'active'; }
namespace App;
use Domain\Status as State;
use Domain\Status as Current;
trait A { public const X = State::Active; }
trait B { public const X = Current::Active; }
class C { use A, B; }
function main(): void {}
PHP);
$compiler->prepareFile($file);
$compiler->composeTraitDeclarations([$file]);
// Stub registration still owns the separate lowering of imported
// names; this test isolates compatibility evaluation.
self::addToAssertionCount(1);
}
/** @dataProvider incompatibleMemberProvider */
public function testDifferentMemberValuesAreRejected(string $declarations): void
{
[$compiler, $file] = $this->compilerFor("<?php\n{$declarations}\nfunction main(): void {}\n");
$compiler->prepareFile($file);
try {
$compiler->composeTraitDeclarations([$file]);
$compiler->convertFile($file);
self::fail('Compilation unexpectedly succeeded');
} catch (TestError $error) {
self::assertMatchesRegularExpression('/conflict|already exists/i', $error->getMessage());
}
}
public static function incompatibleMemberProvider(): iterable
{
yield 'different enum cases' => [
"enum Status: string { case Active = 'active'; case Disabled = 'disabled'; } trait A { public const X = Status::Active; } trait B { public const X = Status::Disabled; } class C { use A, B; }",
];
yield 'different enums with equal backing scalars' => [
"enum First: string { case Active = 'active'; } enum Second: string { case Active = 'active'; } trait A { public const X = First::Active; } trait B { public const X = Second::Active; } class C { use A, B; }",
];
yield 'different enum case inside array' => [
"enum Status: string { case Active = 'active'; case Disabled = 'disabled'; } trait A { public const X = [Status::Active]; } trait B { public const X = [Status::Disabled]; } class C { use A, B; }",
];
yield 'different enum property defaults' => [
"enum Status: string { case Active = 'active'; case Disabled = 'disabled'; } trait A { public Status \$value = Status::Active; } trait B { public Status \$value = Status::Disabled; } class C { use A, B; }",
];
yield '__TRAIT__ remains lexical' => [
'trait A { public const X = __TRAIT__; } trait B { public const X = __TRAIT__; } class C { use A, B; }',
];
yield 'class property and trait property differ' => [
'trait A { public int $value = 1; } class C { use A; public int $value = 2; }',
];
yield 'class constant and trait constant differ' => [
'trait A { public const X = 1; } class C { use A; public const X = 2; }',
];
}
/** @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];
}
}

@ -88,13 +88,24 @@ class ClassDef extends ClassLikeDef
/** /**
* FullMethodName -> alias list * FullMethodName -> alias list
* @var array<string, array<int, array{newName: string, newModifier: int}>> * @var array<string, list<array{
* group: string,
* trait: string|null,
* method: string,
* newName: string,
* newModifier: int
* }>>
*/ */
public array $traitAliases = []; public array $traitAliases = [];
/** /**
* FullMethodName -> true * Excluded FullMethodName -> precedence rules.
* @var array<string, bool> *
* A list is required here: PHP rejects excluding the same method more
* than once, so overwriting duplicate rules would hide an invalid
* declaration before the composition phase can diagnose it.
*
* @var array<string, list<array{winnerTrait: string, loserTrait: string, method: string}>>
*/ */
public array $traitIgnored = []; public array $traitIgnored = [];
public int $flags; public int $flags;

@ -3111,9 +3111,10 @@ class Preprocessor extends CompilerBase
protected function parseTraitUseOptions(Node\Stmt\TraitUse $traitUse, array &$aliases, array &$ignored): void protected function parseTraitUseOptions(Node\Stmt\TraitUse $traitUse, array &$aliases, array &$ignored): void
{ {
foreach ($traitUse->adaptations as $adaptation) { foreach ($traitUse->adaptations as $index => $adaptation) {
if ($adaptation instanceof Node\Stmt\TraitUseAdaptation\Alias) { if ($adaptation instanceof Node\Stmt\TraitUseAdaptation\Alias) {
$traits = []; $traits = [];
$requestedTrait = null;
if (!$adaptation->trait) { if (!$adaptation->trait) {
// use THello1, THello2 { // use THello1, THello2 {
// hello as hello3; // hello as hello3;
@ -3122,16 +3123,21 @@ class Preprocessor extends CompilerBase
$traits = $traitUse->traits; $traits = $traitUse->traits;
} else { } else {
$traits[] = $adaptation->trait; $traits[] = $adaptation->trait;
$requestedTrait = $this->getNamespacedClassName($this->parseIdentifier($adaptation->trait));
} }
$methodName = $adaptation->method->toString();
$group = $traitUse->getStartFilePos() . ':' . $index;
foreach ($traits as $trait) { foreach ($traits as $trait) {
$traitName = $this->getNamespacedClassName($this->parseIdentifier($trait)); $traitName = $this->getNamespacedClassName($this->parseIdentifier($trait));
$methodName = $adaptation->method->toString();
/* /*
* For example: * For example:
* use TraitA { TraitA::method as newMethod} * use TraitA { TraitA::method as newMethod}
* This means TraitA::method() is renamed to TraitA::newMethod() * This means TraitA::method() is renamed to TraitA::newMethod()
*/ */
$aliases[$this->getFullMethodName($traitName, $methodName)][] = [ $aliases[$this->getFullMethodName($traitName, $methodName)][] = [
'group' => $group,
'trait' => $requestedTrait,
'method' => $methodName,
'newName' => $adaptation->newName ? $adaptation->newName->toString() : $methodName, 'newName' => $adaptation->newName ? $adaptation->newName->toString() : $methodName,
'newModifier' => $adaptation->newModifier ?: 0, 'newModifier' => $adaptation->newModifier ?: 0,
]; ];
@ -3142,14 +3148,19 @@ class Preprocessor extends CompilerBase
$this->fatalError($traitUse, 'Trait precedence cannot be used without a trait'); $this->fatalError($traitUse, 'Trait precedence cannot be used without a trait');
} }
$methodName = $adaptation->method->toString(); $methodName = $adaptation->method->toString();
$winnerTrait = $this->getNamespacedClassName($this->parseIdentifier($adaptation->trait));
/* /*
* For example: * For example:
* use TraitA { TraitA::method insteadof TraitB} * use TraitA { TraitA::method insteadof TraitB}
* This means TraitB::method() is ignored, and TraitA::method() is actually executed * This means TraitB::method() is ignored, and TraitA::method() is actually executed
*/ */
foreach ($adaptation->insteadof as $trait2) { foreach ($adaptation->insteadof as $trait2) {
$traitName = $this->getNamespacedClassName($this->parseIdentifier($trait2)); $loserTrait = $this->getNamespacedClassName($this->parseIdentifier($trait2));
$ignored[$this->getFullMethodName($traitName, $methodName)] = true; $ignored[$this->getFullMethodName($loserTrait, $methodName)][] = [
'winnerTrait' => $winnerTrait,
'loserTrait' => $loserTrait,
'method' => $methodName,
];
} }
} }
} }
@ -3174,6 +3185,10 @@ class Preprocessor extends CompilerBase
$this->classDef->traitAliases[$fullMethodName][] = $alias; $this->classDef->traitAliases[$fullMethodName][] = $alias;
} }
} }
$this->classDef->traitIgnored = array_merge($this->classDef->traitIgnored, $ignored); foreach ($ignored as $fullMethodName => $rules) {
foreach ($rules as $rule) {
$this->classDef->traitIgnored[$fullMethodName][] = $rule;
}
}
} }
} }

@ -279,10 +279,15 @@ trait ClassConstantValueTrait
return $value; return $value;
} }
private function evaluateCompileTimeExpression(Node\Expr $expression, ClassLikeDef $scope): mixed private function evaluateCompileTimeExpression(
Node\Expr $expression,
ClassLikeDef $scope,
?ClassLikeDef $lateBoundScope = null,
): mixed
{ {
$lateBoundScope ??= $scope;
$evaluator = null; $evaluator = null;
$evaluator = new ConstExprEvaluator(function (Node\Expr $expr) use (&$evaluator, $scope): mixed { $evaluator = new ConstExprEvaluator(function (Node\Expr $expr) use (&$evaluator, $scope, $lateBoundScope): mixed {
if ($expr instanceof Node\Expr\Cast) { if ($expr instanceof Node\Expr\Cast) {
$value = $evaluator->evaluateDirectly($expr->expr); $value = $evaluator->evaluateDirectly($expr->expr);
return match (true) { return match (true) {
@ -300,17 +305,39 @@ trait ClassConstantValueTrait
} }
if ($expr instanceof Node\Expr\ClassConstFetch && $expr->class instanceof Node\Name) { if ($expr instanceof Node\Expr\ClassConstFetch && $expr->class instanceof Node\Name) {
$class = $this->resolveCompileTimeClassName($expr->class, $scope);
$name = $expr->name instanceof Node\Identifier $name = $expr->name instanceof Node\Identifier
? $expr->name->toString() ? $expr->name->toString()
: $evaluator->evaluateDirectly($expr->name); : $evaluator->evaluateDirectly($expr->name);
if (!is_string($name)) { if (!is_string($name)) {
throw new \RuntimeException('A compile-time class constant name must evaluate to string'); throw new \RuntimeException('A compile-time class constant name must evaluate to string');
} }
$class = $this->resolveCompileTimeClassName($expr->class, $scope, $lateBoundScope);
if (strcasecmp($name, 'class') === 0) { if (strcasecmp($name, 'class') === 0) {
return ltrim($class, '\\'); return ltrim($class, '\\');
} }
$value = $this->evaluateCompileTimeClassConstantFetch($expr, $class, $name, $scope); // A Trait's self::CONST first refers to a constant declared by
// that Trait template. If it has no such declaration, resolve
// it against the consuming class (including its parents).
if (strcasecmp($expr->class->toString(), 'self') === 0
&& $scope instanceof ClassDef
&& $scope->trait !== null
&& $scope->hasConstant($name)
) {
$value = $this->evaluateCompileTimeClassConstant(
$expr,
$scope,
$scope->getConstant($name),
$name,
$lateBoundScope,
);
} else {
$value = $this->evaluateCompileTimeClassConstantFetch(
$expr,
$class,
$name,
$scope,
);
}
if ($value instanceof EnumCaseRef && $this->isEnumCaseBackingEvaluationInProgress($value)) { if ($value instanceof EnumCaseRef && $this->isEnumCaseBackingEvaluationInProgress($value)) {
$this->fatalError( $this->fatalError(
$expr, $expr,
@ -349,7 +376,7 @@ trait ClassConstantValueTrait
} }
if ($expr instanceof Node\Scalar\MagicConst) { if ($expr instanceof Node\Scalar\MagicConst) {
return $this->evaluateCompileTimeMagicConstant($expr, $scope); return $this->evaluateCompileTimeMagicConstant($expr, $scope, $lateBoundScope);
} }
throw new \RuntimeException("Expression `{$expr->getType()}` is not compile-time evaluable"); throw new \RuntimeException("Expression `{$expr->getType()}` is not compile-time evaluable");
@ -381,6 +408,10 @@ trait ClassConstantValueTrait
$classDef, $classDef,
$classDef->getConstant($name), $classDef->getConstant($name),
$name, $name,
// An explicit TraitName::CONST fetch evaluates in the
// Trait's own scope. Only a copied member's self::
// path above carries the consuming-class scope.
$classDef,
); );
} }
$current = ltrim($classDef->extends, '\\'); $current = ltrim($classDef->extends, '\\');
@ -391,7 +422,7 @@ trait ClassConstantValueTrait
$constant = $this->findCompileTimeInterfaceConstant($class, $name); $constant = $this->findCompileTimeInterfaceConstant($class, $name);
if ($constant !== null) { if ($constant !== null) {
[$interface, $constantDef] = $constant; [$interface, $constantDef] = $constant;
return $this->evaluateCompileTimeClassConstant($origin, $interface, $constantDef, $name); return $this->evaluateCompileTimeClassConstant($origin, $interface, $constantDef, $name, $interface);
} }
} }
@ -408,6 +439,7 @@ trait ClassConstantValueTrait
ClassLikeDef $scope, ClassLikeDef $scope,
ConstantDef $constant, ConstantDef $constant,
string $name, string $name,
?ClassLikeDef $lateBoundScope = null,
): mixed { ): mixed {
if (!$constant->valueExpr instanceof Node\Expr) { if (!$constant->valueExpr instanceof Node\Expr) {
throw new \RuntimeException( throw new \RuntimeException(
@ -425,7 +457,7 @@ trait ClassConstantValueTrait
$this->classConstantEvaluationsInProgress[$key] = true; $this->classConstantEvaluationsInProgress[$key] = true;
try { try {
return $this->evaluateCompileTimeExpression($constant->valueExpr, $scope); return $this->evaluateCompileTimeExpression($constant->valueExpr, $scope, $lateBoundScope);
} finally { } finally {
unset($this->classConstantEvaluationsInProgress[$key]); unset($this->classConstantEvaluationsInProgress[$key]);
} }
@ -521,17 +553,21 @@ trait ClassConstantValueTrait
} }
} }
private function resolveCompileTimeClassName(Node\Name $name, ClassLikeDef $scope): string private function resolveCompileTimeClassName(
Node\Name $name,
ClassLikeDef $scope,
ClassLikeDef $lateBoundScope,
): string
{ {
$raw = $name->toString(); $raw = $name->toString();
if (strcasecmp($raw, 'self') === 0) { if (strcasecmp($raw, 'self') === 0) {
return '\\' . $scope->getNamespacedName(false); return '\\' . $lateBoundScope->getNamespacedName(false);
} }
if (strcasecmp($raw, 'parent') === 0) { if (strcasecmp($raw, 'parent') === 0) {
if ($scope->extends === '') { if ($lateBoundScope->extends === '') {
throw new \RuntimeException('Cannot use parent:: without a parent class'); throw new \RuntimeException('Cannot use parent:: without a parent class');
} }
return '\\' . ltrim($scope->extends, '\\'); return '\\' . ltrim($lateBoundScope->extends, '\\');
} }
if (strcasecmp($raw, 'static') === 0) { if (strcasecmp($raw, 'static') === 0) {
throw new \RuntimeException('static:: is not compile-time evaluable'); throw new \RuntimeException('static:: is not compile-time evaluable');
@ -571,17 +607,23 @@ trait ClassConstantValueTrait
]); ]);
} }
private function evaluateCompileTimeMagicConstant(Node\Scalar\MagicConst $expr, ClassLikeDef $scope): int|string private function evaluateCompileTimeMagicConstant(
Node\Scalar\MagicConst $expr,
ClassLikeDef $scope,
ClassLikeDef $lateBoundScope,
): int|string
{ {
return match (true) { return match (true) {
$expr instanceof Node\Scalar\MagicConst\Line => $expr->getStartLine(), $expr instanceof Node\Scalar\MagicConst\Line => $expr->getStartLine(),
$expr instanceof Node\Scalar\MagicConst\File => $scope->sourceFile, $expr instanceof Node\Scalar\MagicConst\File => $scope->sourceFile,
$expr instanceof Node\Scalar\MagicConst\Dir => dirname($scope->sourceFile), $expr instanceof Node\Scalar\MagicConst\Dir => dirname($scope->sourceFile),
$expr instanceof Node\Scalar\MagicConst\Class_ => $scope->getNamespacedName(false), $expr instanceof Node\Scalar\MagicConst\Class_ => $lateBoundScope->getNamespacedName(false),
$expr instanceof Node\Scalar\MagicConst\Namespace_ => $scope->namespace, $expr instanceof Node\Scalar\MagicConst\Namespace_ => $scope->namespace,
$expr instanceof Node\Scalar\MagicConst\Method, $expr instanceof Node\Scalar\MagicConst\Method,
$expr instanceof Node\Scalar\MagicConst\Function_, $expr instanceof Node\Scalar\MagicConst\Function_ => '',
$expr instanceof Node\Scalar\MagicConst\Trait_ => '', $expr instanceof Node\Scalar\MagicConst\Trait_ => $scope instanceof ClassDef && $scope->trait !== null
? $scope->getNamespacedName(false)
: '',
default => throw new \RuntimeException("Magic constant `{$expr->getType()}` is not compile-time evaluable"), default => throw new \RuntimeException("Magic constant `{$expr->getType()}` is not compile-time evaluable"),
}; };
} }

@ -27,6 +27,7 @@ use TypePhp\Entity\ArgInfo;
use TypePhp\Entity\ClassDef; use TypePhp\Entity\ClassDef;
use TypePhp\Entity\ClassLikeDef; use TypePhp\Entity\ClassLikeDef;
use TypePhp\Entity\ConstantDef; use TypePhp\Entity\ConstantDef;
use TypePhp\Entity\EnumCaseRef;
use TypePhp\Entity\FunctionDef; use TypePhp\Entity\FunctionDef;
use TypePhp\Entity\InterfaceDef; use TypePhp\Entity\InterfaceDef;
use TypePhp\Entity\InterfacePropertyDef; use TypePhp\Entity\InterfacePropertyDef;
@ -35,6 +36,7 @@ use TypePhp\Entity\PropertyDef;
use TypePhp\Exception\Redo; use TypePhp\Exception\Redo;
use TypePhp\Exception\Skip; use TypePhp\Exception\Skip;
use TypePhp\Exception\SyntaxError; use TypePhp\Exception\SyntaxError;
use TypePhp\Exception\TestError;
use TypePhp\Generator\DefaultArgumentGenerator; use TypePhp\Generator\DefaultArgumentGenerator;
use TypePhp\Generator\LibraryImportStubGenerator; use TypePhp\Generator\LibraryImportStubGenerator;
use TypePhp\Generator\Symbol; use TypePhp\Generator\Symbol;
@ -3311,6 +3313,7 @@ CODE;
$traitMethods = []; $traitMethods = [];
$traitConstants = []; $traitConstants = [];
$traitProperties = []; $traitProperties = [];
$seenTraitMethods = [];
$classDef = $this->getClass($className->toString()); $classDef = $this->getClass($className->toString());
$usingClassDef = $classDef; $usingClassDef = $classDef;
$compositionOwner = $classDef->getNamespacedName(false); $compositionOwner = $classDef->getNamespacedName(false);
@ -3369,6 +3372,7 @@ CODE;
$traitStmt->setAttribute(self::TRAIT_METHOD_ATTRIBUTE, $traitStmt->name->toString()); $traitStmt->setAttribute(self::TRAIT_METHOD_ATTRIBUTE, $traitStmt->name->toString());
} }
$fullMethodName = $this->getFullMethodName($traitFullName, $methodName); $fullMethodName = $this->getFullMethodName($traitFullName, $methodName);
$seenTraitMethods[$fullMethodName] = true;
// A trait method's `self`/`static`/`parent` return and parameter // A trait method's `self`/`static`/`parent` return and parameter
// types refer to the class that uses the trait, not the trait // types refer to the class that uses the trait, not the trait
// itself. Re-resolve them on the cloned AST so the generated // itself. Re-resolve them on the cloned AST so the generated
@ -3518,9 +3522,20 @@ CODE;
} }
if (isset($traitConstants[$constName])) { if (isset($traitConstants[$constName])) {
[$existingConstStmt, $existingConst] = $traitConstants[$constName]; [$existingConstStmt, $existingConst] = $traitConstants[$constName];
$existingOrigin = (string) $existingConstStmt->getAttribute(
self::TRAIT_ORIGIN_ATTRIBUTE,
$traitFullName,
);
if ($existingConstStmt->flags !== $traitStmt->flags || if ($existingConstStmt->flags !== $traitStmt->flags ||
$this->typeNodeToStringOrNull($existingConstStmt->type) !== $this->typeNodeToStringOrNull($traitStmt->type) || $this->typeNodeToStringOrNull($existingConstStmt->type) !== $this->typeNodeToStringOrNull($traitStmt->type) ||
$this->printer->prettyPrintExpr($existingConst->value) !== $this->printer->prettyPrintExpr($const->value)) { !$this->traitMemberExpressionsAreIdentical(
$existingConst->value,
$existingOrigin,
$const->value,
$traitFullName,
$usingClassDef,
$this->typeNodeToStringOrNull($traitStmt->type),
)) {
$this->fatalError($classStmt, "Trait `{$traitFullName}` constant `{$constName}` already exists"); $this->fatalError($classStmt, "Trait `{$traitFullName}` constant `{$constName}` already exists");
} }
unset($traitStmt->consts[$k2]); unset($traitStmt->consts[$k2]);
@ -3547,11 +3562,20 @@ CODE;
} }
if (isset($traitProperties[$propName])) { if (isset($traitProperties[$propName])) {
[$existingPropStmt, $existingProp] = $traitProperties[$propName]; [$existingPropStmt, $existingProp] = $traitProperties[$propName];
$existingDefault = $existingProp->default ? $this->printer->prettyPrintExpr($existingProp->default) : null; $existingOrigin = (string) $existingPropStmt->getAttribute(
$propDefault = $prop->default ? $this->printer->prettyPrintExpr($prop->default) : null; self::TRAIT_ORIGIN_ATTRIBUTE,
$traitFullName,
);
if ($existingPropStmt->flags !== $traitStmt->flags || if ($existingPropStmt->flags !== $traitStmt->flags ||
$this->typeNodeToStringOrNull($existingPropStmt->type) !== $this->typeNodeToStringOrNull($traitStmt->type) || $this->typeNodeToStringOrNull($existingPropStmt->type) !== $this->typeNodeToStringOrNull($traitStmt->type) ||
$existingDefault !== $propDefault) { !$this->traitMemberExpressionsAreIdentical(
$existingProp->default,
$existingOrigin,
$prop->default,
$traitFullName,
$usingClassDef,
$this->typeNodeToStringOrNull($traitStmt->type),
)) {
$this->fatalError($classStmt, "Trait `{$traitFullName}` property `{$propName}` already exists"); $this->fatalError($classStmt, "Trait `{$traitFullName}` property `{$propName}` already exists");
} }
unset($traitStmt->props[$k2]); unset($traitStmt->props[$k2]);
@ -3584,6 +3608,95 @@ CODE;
} }
} }
$this->validateTraitAdaptations($stmt, $classDef, $seenTraitMethods);
}
/**
* Validate adaptation declarations after all directly used traits have
* been expanded. Preprocessing intentionally cannot do this: a referenced
* trait may be declared in a source file prepared later.
*
* @param array<string, true> $seenTraitMethods
*/
private function validateTraitAdaptations(
Node\Stmt\ClassLike $origin,
ClassDef $classDef,
array $seenTraitMethods,
): void {
$usedTraits = [];
foreach ($classDef->usedTraits as $trait) {
$usedTraits[strtolower($trait)] = $trait;
}
$aliasGroups = [];
foreach ($classDef->traitAliases as $fullMethodName => $aliases) {
foreach ($aliases as $alias) {
$group = $alias['group'];
$aliasGroups[$group] ??= [
'trait' => $alias['trait'],
'method' => $alias['method'],
'candidates' => [],
];
$aliasGroups[$group]['candidates'][$fullMethodName] = true;
}
}
foreach ($aliasGroups as $alias) {
$requestedTrait = $alias['trait'];
if ($requestedTrait !== null && !isset($usedTraits[strtolower($requestedTrait)])) {
$this->fatalError(
$origin,
"Required Trait `{$requestedTrait}` wasn't added to `{$classDef->getNamespacedName(false)}`",
);
}
$matches = 0;
foreach ($alias['candidates'] as $fullMethodName => $_) {
if (isset($seenTraitMethods[$fullMethodName])) {
$matches++;
}
}
if ($matches === 0) {
$qualifier = $requestedTrait !== null ? "{$requestedTrait}::" : '';
$this->fatalError(
$origin,
"An alias was defined for method `{$qualifier}{$alias['method']}()`, but this method does not exist",
);
}
if ($requestedTrait === null && $matches > 1) {
$this->fatalError(
$origin,
"An alias was defined for method `{$alias['method']}()`, which exists in multiple traits; qualify the source trait",
);
}
}
foreach ($classDef->traitIgnored as $fullMethodName => $rules) {
foreach ($rules as $rule) {
foreach (['winnerTrait', 'loserTrait'] as $role) {
$trait = $rule[$role];
if (!isset($usedTraits[strtolower($trait)])) {
$this->fatalError(
$origin,
"Required Trait `{$trait}` wasn't added to `{$classDef->getNamespacedName(false)}`",
);
}
}
$winnerMethod = $this->getFullMethodName($rule['winnerTrait'], $rule['method']);
if (!isset($seenTraitMethods[$winnerMethod])) {
$this->fatalError(
$origin,
"A precedence rule was defined for `{$rule['winnerTrait']}::{$rule['method']}()`, but this method does not exist",
);
}
}
if (count($rules) > 1) {
$this->fatalError(
$origin,
"Failed to evaluate trait precedence for `{$rules[0]['method']}()`: method `{$fullMethodName}` was excluded multiple times",
);
}
}
} }
/** /**
@ -3600,6 +3713,76 @@ CODE;
return $flags | $newModifier; return $flags | $newModifier;
} }
private function traitMemberExpressionsAreIdentical(
?Node\Expr $left,
string $leftOrigin,
?Node\Expr $right,
string $rightOrigin,
ClassDef $usingClass,
?string $declaredType,
): bool {
if ($left === null || $right === null) {
return $left === $right;
}
try {
$leftValue = $this->evaluateTraitMemberExpression($left, $leftOrigin, $usingClass);
$rightValue = $this->evaluateTraitMemberExpression($right, $rightOrigin, $usingClass);
if ($declaredType === 'float' || $declaredType === Type::FLOAT) {
$leftValue = is_int($leftValue) ? (float) $leftValue : $leftValue;
$rightValue = is_int($rightValue) ? (float) $rightValue : $rightValue;
}
return $this->compileTimeValuesAreIdentical($leftValue, $rightValue);
} catch (TestError $error) {
throw $error;
} catch (\Throwable) {
// Keep compatibility with expressions accepted by the lowering
// pipeline but not yet understood by the compile-time evaluator.
// Identical source remains safe; non-identical source remains a
// conflict until its semantics can be proven.
return $this->printer->prettyPrintExpr($left) === $this->printer->prettyPrintExpr($right);
}
}
private function evaluateTraitMemberExpression(
Node\Expr $expression,
string $origin,
ClassDef $usingClass,
): mixed {
if ($origin !== '' && $this->hasClass($origin)) {
$scope = $this->getClass($origin);
if ($scope->trait !== null) {
return $this->withTraitNameContext(
$origin,
fn(): mixed => $this->evaluateCompileTimeExpression($expression, $scope, $usingClass),
);
}
}
return $this->evaluateCompileTimeExpression($expression, $usingClass, $usingClass);
}
private function compileTimeValuesAreIdentical(mixed $left, mixed $right): bool
{
if ($left instanceof EnumCaseRef || $right instanceof EnumCaseRef) {
return $left instanceof EnumCaseRef
&& $right instanceof EnumCaseRef
&& strcasecmp($left->enumClass, $right->enumClass) === 0
&& $left->caseName === $right->caseName;
}
if (is_array($left) || is_array($right)) {
if (!is_array($left) || !is_array($right) || array_keys($left) !== array_keys($right)) {
return false;
}
foreach ($left as $key => $value) {
if (!$this->compileTimeValuesAreIdentical($value, $right[$key])) {
return false;
}
}
return true;
}
return $left === $right;
}
/** /**
* Resolve the trait a flattened method statement originated from and its * Resolve the trait a flattened method statement originated from and its
* preprocessed method definition. Recursive trait composition tags every * preprocessed method definition. Recursive trait composition tags every
@ -6818,7 +7001,11 @@ CODE;
$traitDef = $this->getClass($traitFullName); $traitDef = $this->getClass($traitFullName);
foreach ($traitDef->constants as $const) { foreach ($traitDef->constants as $const) {
if ($classDef->hasConstant($const->name)) { if ($classDef->hasConstant($const->name)) {
if (!$this->isCompatibleTraitConstant($classDef->getConstant($const->name), $const)) { if (!$this->isCompatibleTraitConstant(
$classDef->getConstant($const->name),
$const,
$traitFullName,
)) {
$this->fatalError($v, "Trait `{$traitFullName}` constant `{$const->name}` conflicts with class `{$classDef->getNamespacedName(false)}`"); $this->fatalError($v, "Trait `{$traitFullName}` constant `{$const->name}` conflicts with class `{$classDef->getNamespacedName(false)}`");
} }
continue; continue;
@ -6833,7 +7020,11 @@ CODE;
); );
} }
if ($classDef->hasProperty($prop->name)) { if ($classDef->hasProperty($prop->name)) {
if (!$this->isCompatibleTraitProperty($classDef->getProperty($prop->name), $prop)) { if (!$this->isCompatibleTraitProperty(
$classDef->getProperty($prop->name),
$prop,
$traitFullName,
)) {
$this->fatalError($v, "Trait `{$traitFullName}` property `{$prop->name}` conflicts with class `{$classDef->getNamespacedName(false)}`"); $this->fatalError($v, "Trait `{$traitFullName}` property `{$prop->name}` conflicts with class `{$classDef->getNamespacedName(false)}`");
} }
continue; continue;
@ -6964,22 +7155,70 @@ CODE;
} }
} }
private function isCompatibleTraitConstant(ConstantDef $existing, ConstantDef $incoming): bool private function isCompatibleTraitConstant(
ConstantDef $existing,
ConstantDef $incoming,
string $incomingOrigin,
): bool
{ {
if ($existing->flags !== $incoming->flags
|| $existing->type !== $incoming->type
|| $existing->class !== $incoming->class
) {
return false;
}
$existingOrigin = $existing->traitOrigin !== ''
? $existing->traitOrigin
: $this->classDef->getNamespacedName(false);
if ($existing->valueExpr instanceof Node\Expr && $incoming->valueExpr instanceof Node\Expr) {
return $this->traitMemberExpressionsAreIdentical(
$existing->valueExpr,
$existingOrigin,
$incoming->valueExpr,
$incomingOrigin,
$this->classDef,
$existing->type,
);
}
return $existing->flags === $incoming->flags return $existing->flags === $incoming->flags
&& $existing->type === $incoming->type && $existing->type === $incoming->type
&& $existing->class === $incoming->class && $existing->class === $incoming->class
&& $existing->value === $incoming->value; && $existing->value === $incoming->value;
} }
private function isCompatibleTraitProperty(PropertyDef $existing, PropertyDef $incoming): bool private function isCompatibleTraitProperty(
PropertyDef $existing,
PropertyDef $incoming,
string $incomingOrigin,
): bool
{ {
return $existing->flags === $incoming->flags if ($existing->flags !== $incoming->flags
&& $existing->type === $incoming->type || $existing->type !== $incoming->type
&& $existing->class === $incoming->class || $existing->class !== $incoming->class
&& $existing->nullable === $incoming->nullable || $existing->nullable !== $incoming->nullable
&& $existing->default === $incoming->default || $existing->arrayDef != $incoming->arrayDef
&& $existing->arrayDef == $incoming->arrayDef; ) {
return false;
}
$originAttribute = $existing->node?->getAttribute(self::TRAIT_ORIGIN_ATTRIBUTE);
$existingOrigin = is_string($originAttribute) && $originAttribute !== ''
? $originAttribute
: $this->classDef->getNamespacedName(false);
if ($existing->defaultExpr instanceof Node\Expr || $incoming->defaultExpr instanceof Node\Expr) {
return $this->traitMemberExpressionsAreIdentical(
$existing->defaultExpr instanceof Node\Expr ? $existing->defaultExpr : null,
$existingOrigin,
$incoming->defaultExpr instanceof Node\Expr ? $incoming->defaultExpr : null,
$incomingOrigin,
$this->classDef,
$existing->type,
);
}
return $existing->default === $incoming->default;
} }
private function resolveLateBoundClass(ClassDef $usingClassDef, string $keyword): ?string private function resolveLateBoundClass(ClassDef $usingClassDef, string $keyword): ?string

@ -0,0 +1,45 @@
--TEST--
Trait members compare evaluated values instead of source spelling
--FILE--
<?php
enum Status: string
{
case Active = 'active';
}
trait FirstValues
{
public const int SCORE = 1 + 1;
public array $items = [1, 2];
public Status $status = Status::Active;
}
trait SecondValues
{
public const int SCORE = 2;
public array $items = array(1, 2);
public Status $status = Status::Active;
}
class Values
{
use FirstValues, SecondValues;
}
function main(): void
{
$values = new Values();
var_dump(Values::SCORE);
var_dump($values->items);
var_dump($values->status === Status::Active);
}
?>
--EXPECT--
int(2)
array(2) {
[0]=>
int(1)
[1]=>
int(2)
}
bool(true)
Loading…
Cancel
Save