From c5ba5afe65e8d20ef58b1d56a59fcdafcd8b311c Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Fri, 4 Sep 2026 10:21:55 +0800 Subject: [PATCH] fix(trait): validate adaptations and member values --- phpunit/src/Entity/ClassDefExtendedTest.php | 21 +- phpunit/src/PreprocessorTest.php | 2 + phpunit/src/TraitAdaptationValidationTest.php | 144 ++++++++++ .../src/TraitMemberValueCompatibilityTest.php | 178 ++++++++++++ src/Entity/ClassDef.php | 17 +- src/Preprocessor.php | 25 +- src/Resolver/ClassConstantValueTrait.php | 72 ++++- src/Translator.php | 267 +++++++++++++++++- .../trait/trait-member-equivalent-values.phpt | 45 +++ 9 files changed, 728 insertions(+), 43 deletions(-) create mode 100644 phpunit/src/TraitAdaptationValidationTest.php create mode 100644 phpunit/src/TraitMemberValueCompatibilityTest.php create mode 100644 tests/compiler/trait/trait-member-equivalent-values.phpt diff --git a/phpunit/src/Entity/ClassDefExtendedTest.php b/phpunit/src/Entity/ClassDefExtendedTest.php index c0db2a0b..47208796 100644 --- a/phpunit/src/Entity/ClassDefExtendedTest.php +++ b/phpunit/src/Entity/ClassDefExtendedTest.php @@ -59,12 +59,21 @@ class ClassDefExtendedTest extends TestCase public function testTraitAliasesAndIgnoredCanBeSet(): void { $class = new ClassDef('User', Modifiers::PUBLIC); - $class->traitAliases['Full\\Trait::method'] = ['alias' => 'newName']; - $class->traitIgnored['Full\\Trait::other'] = true; - - $this->assertArrayHasKey('Full\\Trait::method', $class->traitAliases); - $this->assertArrayHasKey('Full\\Trait::other', $class->traitIgnored); - $this->assertTrue($class->traitIgnored['Full\\Trait::other']); + $class->traitAliases['full\\trait::method'][] = [ + 'group' => '1:0', + 'trait' => 'Full\\Trait', + 'method' => 'method', + 'newName' => 'newName', + '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 diff --git a/phpunit/src/PreprocessorTest.php b/phpunit/src/PreprocessorTest.php index 377f9029..ae072fe4 100644 --- a/phpunit/src/PreprocessorTest.php +++ b/phpunit/src/PreprocessorTest.php @@ -378,6 +378,8 @@ class PreprocessorTest extends TestCase $this->assertArrayHasKey('aliasmodifieruser', $classes); $aliases = $classes['aliasmodifieruser']->traitAliases; $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(Modifiers::PRIVATE, $aliases['aliasmodifiertrait::hello'][0]['newModifier']); } diff --git a/phpunit/src/TraitAdaptationValidationTest.php b/phpunit/src/TraitAdaptationValidationTest.php new file mode 100644 index 00000000..737ef1d7 --- /dev/null +++ b/phpunit/src/TraitAdaptationValidationTest.php @@ -0,0 +1,144 @@ +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("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' +prepareFile($file); + $compiler->composeTraitDeclarations([$file]); + $compiler->convertFile($file); + + self::assertFileExists($compiler->getCppFile($file)); + } + + public function testAliasCanTargetMethodFromNestedTrait(): void + { + [$compiler, $file] = $this->compilerFor(<<<'PHP' +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]; + } +} diff --git a/phpunit/src/TraitMemberValueCompatibilityTest.php b/phpunit/src/TraitMemberValueCompatibilityTest.php new file mode 100644 index 00000000..358011f8 --- /dev/null +++ b/phpunit/src/TraitMemberValueCompatibilityTest.php @@ -0,0 +1,178 @@ +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("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' +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' +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("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]; + } +} diff --git a/src/Entity/ClassDef.php b/src/Entity/ClassDef.php index 415420f8..a6385ba3 100644 --- a/src/Entity/ClassDef.php +++ b/src/Entity/ClassDef.php @@ -88,13 +88,24 @@ class ClassDef extends ClassLikeDef /** * FullMethodName -> alias list - * @var array> + * @var array> */ public array $traitAliases = []; /** - * FullMethodName -> true - * @var array + * Excluded FullMethodName -> precedence rules. + * + * 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> */ public array $traitIgnored = []; public int $flags; diff --git a/src/Preprocessor.php b/src/Preprocessor.php index c7780a3f..da7a4764 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -3111,9 +3111,10 @@ class Preprocessor extends CompilerBase 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) { $traits = []; + $requestedTrait = null; if (!$adaptation->trait) { // use THello1, THello2 { // hello as hello3; @@ -3122,16 +3123,21 @@ class Preprocessor extends CompilerBase $traits = $traitUse->traits; } else { $traits[] = $adaptation->trait; + $requestedTrait = $this->getNamespacedClassName($this->parseIdentifier($adaptation->trait)); } + $methodName = $adaptation->method->toString(); + $group = $traitUse->getStartFilePos() . ':' . $index; foreach ($traits as $trait) { $traitName = $this->getNamespacedClassName($this->parseIdentifier($trait)); - $methodName = $adaptation->method->toString(); /* * For example: * use TraitA { TraitA::method as newMethod} * This means TraitA::method() is renamed to TraitA::newMethod() */ $aliases[$this->getFullMethodName($traitName, $methodName)][] = [ + 'group' => $group, + 'trait' => $requestedTrait, + 'method' => $methodName, 'newName' => $adaptation->newName ? $adaptation->newName->toString() : $methodName, 'newModifier' => $adaptation->newModifier ?: 0, ]; @@ -3142,14 +3148,19 @@ class Preprocessor extends CompilerBase $this->fatalError($traitUse, 'Trait precedence cannot be used without a trait'); } $methodName = $adaptation->method->toString(); + $winnerTrait = $this->getNamespacedClassName($this->parseIdentifier($adaptation->trait)); /* * For example: * use TraitA { TraitA::method insteadof TraitB} * This means TraitB::method() is ignored, and TraitA::method() is actually executed */ foreach ($adaptation->insteadof as $trait2) { - $traitName = $this->getNamespacedClassName($this->parseIdentifier($trait2)); - $ignored[$this->getFullMethodName($traitName, $methodName)] = true; + $loserTrait = $this->getNamespacedClassName($this->parseIdentifier($trait2)); + $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->traitIgnored = array_merge($this->classDef->traitIgnored, $ignored); + foreach ($ignored as $fullMethodName => $rules) { + foreach ($rules as $rule) { + $this->classDef->traitIgnored[$fullMethodName][] = $rule; + } + } } } diff --git a/src/Resolver/ClassConstantValueTrait.php b/src/Resolver/ClassConstantValueTrait.php index 366cdade..283d1bd8 100644 --- a/src/Resolver/ClassConstantValueTrait.php +++ b/src/Resolver/ClassConstantValueTrait.php @@ -279,10 +279,15 @@ trait ClassConstantValueTrait 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 = 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) { $value = $evaluator->evaluateDirectly($expr->expr); return match (true) { @@ -300,17 +305,39 @@ trait ClassConstantValueTrait } if ($expr instanceof Node\Expr\ClassConstFetch && $expr->class instanceof Node\Name) { - $class = $this->resolveCompileTimeClassName($expr->class, $scope); $name = $expr->name instanceof Node\Identifier ? $expr->name->toString() : $evaluator->evaluateDirectly($expr->name); if (!is_string($name)) { 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) { 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)) { $this->fatalError( $expr, @@ -349,7 +376,7 @@ trait ClassConstantValueTrait } 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"); @@ -381,6 +408,10 @@ trait ClassConstantValueTrait $classDef, $classDef->getConstant($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, '\\'); @@ -391,7 +422,7 @@ trait ClassConstantValueTrait $constant = $this->findCompileTimeInterfaceConstant($class, $name); if ($constant !== null) { [$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, ConstantDef $constant, string $name, + ?ClassLikeDef $lateBoundScope = null, ): mixed { if (!$constant->valueExpr instanceof Node\Expr) { throw new \RuntimeException( @@ -425,7 +457,7 @@ trait ClassConstantValueTrait $this->classConstantEvaluationsInProgress[$key] = true; try { - return $this->evaluateCompileTimeExpression($constant->valueExpr, $scope); + return $this->evaluateCompileTimeExpression($constant->valueExpr, $scope, $lateBoundScope); } finally { 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(); if (strcasecmp($raw, 'self') === 0) { - return '\\' . $scope->getNamespacedName(false); + return '\\' . $lateBoundScope->getNamespacedName(false); } if (strcasecmp($raw, 'parent') === 0) { - if ($scope->extends === '') { + if ($lateBoundScope->extends === '') { throw new \RuntimeException('Cannot use parent:: without a parent class'); } - return '\\' . ltrim($scope->extends, '\\'); + return '\\' . ltrim($lateBoundScope->extends, '\\'); } if (strcasecmp($raw, 'static') === 0) { 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) { $expr instanceof Node\Scalar\MagicConst\Line => $expr->getStartLine(), $expr instanceof Node\Scalar\MagicConst\File => $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\Method, - $expr instanceof Node\Scalar\MagicConst\Function_, - $expr instanceof Node\Scalar\MagicConst\Trait_ => '', + $expr instanceof Node\Scalar\MagicConst\Function_ => '', + $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"), }; } diff --git a/src/Translator.php b/src/Translator.php index ec63dc7d..66212814 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -27,6 +27,7 @@ use TypePhp\Entity\ArgInfo; use TypePhp\Entity\ClassDef; use TypePhp\Entity\ClassLikeDef; use TypePhp\Entity\ConstantDef; +use TypePhp\Entity\EnumCaseRef; use TypePhp\Entity\FunctionDef; use TypePhp\Entity\InterfaceDef; use TypePhp\Entity\InterfacePropertyDef; @@ -35,6 +36,7 @@ use TypePhp\Entity\PropertyDef; use TypePhp\Exception\Redo; use TypePhp\Exception\Skip; use TypePhp\Exception\SyntaxError; +use TypePhp\Exception\TestError; use TypePhp\Generator\DefaultArgumentGenerator; use TypePhp\Generator\LibraryImportStubGenerator; use TypePhp\Generator\Symbol; @@ -3311,6 +3313,7 @@ CODE; $traitMethods = []; $traitConstants = []; $traitProperties = []; + $seenTraitMethods = []; $classDef = $this->getClass($className->toString()); $usingClassDef = $classDef; $compositionOwner = $classDef->getNamespacedName(false); @@ -3369,6 +3372,7 @@ CODE; $traitStmt->setAttribute(self::TRAIT_METHOD_ATTRIBUTE, $traitStmt->name->toString()); } $fullMethodName = $this->getFullMethodName($traitFullName, $methodName); + $seenTraitMethods[$fullMethodName] = true; // A trait method's `self`/`static`/`parent` return and parameter // types refer to the class that uses the trait, not the trait // itself. Re-resolve them on the cloned AST so the generated @@ -3518,9 +3522,20 @@ CODE; } if (isset($traitConstants[$constName])) { [$existingConstStmt, $existingConst] = $traitConstants[$constName]; + $existingOrigin = (string) $existingConstStmt->getAttribute( + self::TRAIT_ORIGIN_ATTRIBUTE, + $traitFullName, + ); if ($existingConstStmt->flags !== $traitStmt->flags || $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"); } unset($traitStmt->consts[$k2]); @@ -3547,11 +3562,20 @@ CODE; } if (isset($traitProperties[$propName])) { [$existingPropStmt, $existingProp] = $traitProperties[$propName]; - $existingDefault = $existingProp->default ? $this->printer->prettyPrintExpr($existingProp->default) : null; - $propDefault = $prop->default ? $this->printer->prettyPrintExpr($prop->default) : null; + $existingOrigin = (string) $existingPropStmt->getAttribute( + self::TRAIT_ORIGIN_ATTRIBUTE, + $traitFullName, + ); if ($existingPropStmt->flags !== $traitStmt->flags || $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"); } 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 $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; } + 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 * preprocessed method definition. Recursive trait composition tags every @@ -6818,7 +7001,11 @@ CODE; $traitDef = $this->getClass($traitFullName); foreach ($traitDef->constants as $const) { 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)}`"); } continue; @@ -6833,7 +7020,11 @@ CODE; ); } 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)}`"); } 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 && $existing->type === $incoming->type && $existing->class === $incoming->class && $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 - && $existing->type === $incoming->type - && $existing->class === $incoming->class - && $existing->nullable === $incoming->nullable - && $existing->default === $incoming->default - && $existing->arrayDef == $incoming->arrayDef; + if ($existing->flags !== $incoming->flags + || $existing->type !== $incoming->type + || $existing->class !== $incoming->class + || $existing->nullable !== $incoming->nullable + || $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 diff --git a/tests/compiler/trait/trait-member-equivalent-values.phpt b/tests/compiler/trait/trait-member-equivalent-values.phpt new file mode 100644 index 00000000..d63947f6 --- /dev/null +++ b/tests/compiler/trait/trait-member-equivalent-values.phpt @@ -0,0 +1,45 @@ +--TEST-- +Trait members compare evaluated values instead of source spelling +--FILE-- +items); + var_dump($values->status === Status::Active); +} +?> +--EXPECT-- +int(2) +array(2) { + [0]=> + int(1) + [1]=> + int(2) +} +bool(true)