diff --git a/phpunit/code/trait_adaptations_valid.php b/phpunit/code/trait_adaptations_valid.php new file mode 100644 index 00000000..5e458716 --- /dev/null +++ b/phpunit/code/trait_adaptations_valid.php @@ -0,0 +1,46 @@ +compile('trait_adaptations_valid.php'); + } + + public function testUnqualifiedAliasForMissingMethod(): void + { + $this->exec( + 'An alias (`g`) was defined for method `missing()`, but this method does not exist', + 'trait_alias_missing_method.php', + ); + } + + public function testQualifiedAliasForMissingMethod(): void + { + $this->exec( + 'An alias was defined for `A::missing` but this method does not exist', + 'trait_alias_missing_qualified.php', + ); + } + + public function testAliasReferencingUnusedTrait(): void + { + $this->exec( + "Required Trait `B` wasn't added to `C`", + 'trait_alias_trait_not_used.php', + ); + } + + public function testPrecedenceRuleForMissingMethod(): void + { + $this->exec( + 'A precedence rule was defined for `B::f` but this method does not exist', + 'trait_insteadof_missing_method.php', + ); + } + + public function testPrecedenceRuleReferencingUnusedTrait(): void + { + $this->exec( + "Required Trait `D` wasn't added to `C`", + 'trait_insteadof_trait_not_used.php', + ); + } + + public function testEveryPrecedenceRuleForOneLoserIsValidated(): void + { + // The later `C::f insteadof B` names the same loser as the invalid + // `A::f insteadof B` and must not overwrite (and thereby absolve) it. + $this->exec( + 'A precedence rule was defined for `A::f` but this method does not exist', + 'trait_insteadof_overwritten_rule.php', + ); + } + + public function testMethodExcludedTwiceIsRejected(): void + { + $this->exec( + 'Failed to evaluate a trait precedence (`f`). Method of trait `B` was defined to be excluded multiple times', + 'trait_insteadof_excluded_twice.php', + ); + } + + public function testOneWinnerMayExcludeTwoLosers(): void + { + $this->compile('trait_insteadof_two_losers.php'); + } +} diff --git a/phpunit/src/TraitMemberValueConflictTest.php b/phpunit/src/TraitMemberValueConflictTest.php new file mode 100644 index 00000000..71c97435 --- /dev/null +++ b/phpunit/src/TraitMemberValueConflictTest.php @@ -0,0 +1,92 @@ +compile('trait_member_same_value_spelling.php'); + } + + public function testDifferentConstantValuesConflict(): void + { + $this->exec('constant `x` already exists', 'trait_const_value_conflict.php'); + } + + public function testDifferentPropertyDefaultsConflict(): void + { + $this->exec('property `p` already exists', 'trait_prop_value_conflict.php'); + } + + public function testValueComparisonIsIdentityNotEquality(): void + { + $this->exec('constant `x` already exists', 'trait_const_identity_conflict.php'); + } + + public function testSameEnumCaseInBothTraitsCompiles(): void + { + $this->compile('trait_const_enum_case_same.php'); + } + + public function testSelfConstantReferenceEvaluatesInNamespace(): void + { + $this->compile('trait_const_self_reference_namespaced.php'); + } + + public function testSameNamedCasesOfDifferentEnumsConflict(): void + { + $this->exec('constant `x` already exists', 'trait_const_enum_case_conflict.php'); + } + + public function testDifferentCasesOfSameEnumConflict(): void + { + $this->exec('constant `x` already exists', 'trait_const_enum_diff_case_conflict.php'); + } + + public function testSameBackingValueOfDifferentEnumsConflicts(): void + { + $this->exec('constant `x` already exists', 'trait_const_enum_backed_conflict.php'); + } + + public function testStringSpellingAnEnumCaseMarkerConflicts(): void + { + $this->exec('constant `x` already exists', 'trait_const_enum_marker_string_collision.php'); + } + + public function testSameEnumCaseInsideArraysCompiles(): void + { + $this->compile('trait_const_enum_case_array_same.php'); + } + + public function testDifferentEnumCasesInsideArraysConflict(): void + { + $this->exec('constant `x` already exists', 'trait_const_enum_case_array_conflict.php'); + } + + public function testSameEnumCaseThroughIndirectConstantCompiles(): void + { + $this->compile('trait_const_enum_case_indirect_same.php'); + } + + public function testDifferentEnumCasesThroughIndirectConstantsConflict(): void + { + $this->exec('constant `value` already exists', 'trait_const_enum_case_indirect_conflict.php'); + } + + public function testSameEnumCaseThroughInheritedConstantCompiles(): void + { + $this->compile('trait_const_enum_case_inherited_same.php'); + } + + public function testDifferentEnumCasesThroughInheritedConstantsConflict(): void + { + $this->exec('constant `value` already exists', 'trait_const_enum_case_inherited_conflict.php'); + } +} diff --git a/src/Entity/ClassDef.php b/src/Entity/ClassDef.php index f62a4dde..362e85ec 100644 --- a/src/Entity/ClassDef.php +++ b/src/Entity/ClassDef.php @@ -80,14 +80,21 @@ class ClassDef extends ClassLikeDef public array $usedTraits = []; /** - * FullMethodName -> alias list - * @var array> + * FullMethodName -> alias list. `group` identifies the source adaptation + * (an unqualified alias is registered under every used trait's key), + * `method` is the aliased method as written, and `trait` the explicit + * trait qualifier or null. + * @var array> */ public array $traitAliases = []; /** - * FullMethodName -> true - * @var array + * FullMethodName of the ignored (overridden) method -> EVERY precedence + * rule that named it as the loser, for existence validation (several + * rules may target one loser, and each winner must exist). Consumers + * test the key with isset(); legacy writers may store `true` instead of + * a rule list, so readers guard with is_array(). + * @var array|true> */ public array $traitIgnored = []; public int $flags; diff --git a/src/Entity/EnumCaseIdentity.php b/src/Entity/EnumCaseIdentity.php new file mode 100644 index 00000000..84d3d2bd --- /dev/null +++ b/src/Entity/EnumCaseIdentity.php @@ -0,0 +1,49 @@ + */ + private static array $instances = []; + + private function __construct( + public readonly string $enumClass, + public readonly string $caseName, + ) { + } + + /** + * @param string $enumClass fully qualified enum name (class names are + * case-insensitive; a leading `\` is ignored) + * @param string $caseName case name, compared case-sensitively as Zend does + */ + public static function intern(string $enumClass, string $caseName): self + { + $normalized = strtolower(ltrim($enumClass, '\\')); + return self::$instances[$normalized . '::' . $caseName] + ??= new self($normalized, $caseName); + } +} diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 389f5107..2e4f18d5 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -2746,7 +2746,12 @@ private function prepareInterfaceProperty(Node\Stmt\Property $property): void protected function parseTraitUseOptions(Node\Stmt\TraitUse $traitUse, array &$aliases, array &$ignored): void { - foreach ($traitUse->adaptations as $adaptation) { + // Adaptation identity used to verify during trait composition that + // every alias matched a real trait method (an unqualified alias is + // registered under every used trait's key, so its variants share one + // group and the group is satisfied when ANY variant matches). + $groupBase = $traitUse->getAttribute('startFilePos', $traitUse->getStartLine()) . '@'; + foreach ($traitUse->adaptations as $adaptationIndex => $adaptation) { if ($adaptation instanceof Node\Stmt\TraitUseAdaptation\Alias) { $traits = []; if (!$adaptation->trait) { @@ -2769,6 +2774,9 @@ protected function parseTraitUseOptions(Node\Stmt\TraitUse $traitUse, array &$al $aliases[$this->getFullMethodName($traitName, $methodName)][] = [ 'newName' => $adaptation->newName ? $adaptation->newName->toString() : $methodName, 'newModifier' => $adaptation->newModifier ?: 0, + 'group' => $groupBase . $adaptationIndex, + 'method' => $methodName, + 'trait' => $adaptation->trait ? $traitName : null, ]; } } @@ -2777,6 +2785,7 @@ protected function parseTraitUseOptions(Node\Stmt\TraitUse $traitUse, array &$al $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} @@ -2784,7 +2793,15 @@ protected function parseTraitUseOptions(Node\Stmt\TraitUse $traitUse, array &$al */ foreach ($adaptation->insteadof as $trait2) { $traitName = $this->getNamespacedClassName($this->parseIdentifier($trait2)); - $ignored[$this->getFullMethodName($traitName, $methodName)] = true; + // The value records EVERY rule targeting this loser method + // for existence validation during composition (several + // precedence rules may name the same loser, and each + // winner must exist); consumers only isset() the key. + $ignored[$this->getFullMethodName($traitName, $methodName)][] = [ + 'method' => $methodName, + 'winnerTrait' => $winnerTrait, + 'loserTrait' => $traitName, + ]; } } } @@ -2809,6 +2826,12 @@ protected function prepareTraitUse(Node\Stmt\TraitUse $v): void $this->classDef->traitAliases[$fullMethodName][] = $alias; } } - $this->classDef->traitIgnored = array_merge($this->classDef->traitIgnored, $ignored); + foreach ($ignored as $fullMethodName => $rules) { + // Append per key: array_merge() would drop the rules an earlier + // `use` clause registered for the same ignored method. + foreach ($rules as $rule) { + $this->classDef->traitIgnored[$fullMethodName][] = $rule; + } + } } } diff --git a/src/Resolver/ClassConstantValueTrait.php b/src/Resolver/ClassConstantValueTrait.php index 649f7a5b..128486c7 100644 --- a/src/Resolver/ClassConstantValueTrait.php +++ b/src/Resolver/ClassConstantValueTrait.php @@ -12,6 +12,7 @@ use PhpParser\Node; use PhpParser\NodeAbstract; use TypePhp\Entity\ConstantDef; +use TypePhp\Entity\EnumCaseIdentity; use TypePhp\Entity\EnumCaseRef; trait ClassConstantValueTrait @@ -21,7 +22,7 @@ public function getDefinedConstants(): array return $this->internalConstants; } - public function getClassConstValue(NodeAbstract $expr, string $_class, string $name, string $currentClass = ''): mixed + public function getClassConstValue(NodeAbstract $expr, string $_class, string $name, string $currentClass = '', bool $enumCasesAsIdentity = false): mixed { $namespace = $this->namespace; if (!$namespace and $currentClass and !str_contains($_class, '\\')) { @@ -37,12 +38,14 @@ public function getClassConstValue(NodeAbstract $expr, string $_class, string $n if ($nativeConst and $expr->hasAttribute('nativeConst')) { $constDef = $expr->getAttribute('nativeConst'); if ($constDef->valueExpr !== null) { - return $this->evaluateClassConstValue($expr, $constDef, $class, $name); + return $this->evaluateClassConstValue($expr, $constDef, $class, $name, $enumCasesAsIdentity); } if ($constDef->class !== '') { $refConst = $constDef->class . '::' . $name; if (defined($refConst)) { - return constant($refConst); + return $enumCasesAsIdentity + ? $this->internHostEnumCase(constant($refConst)) + : constant($refConst); } } } @@ -50,6 +53,9 @@ public function getClassConstValue(NodeAbstract $expr, string $_class, string $n $constName = $class . '::' . $name; if (defined($constName)) { $value = constant($constName); + if ($enumCasesAsIdentity) { + return $this->internHostEnumCase($value); + } // Internal enum cases (and internal constants holding one) // must keep their identity through constant evaluation. return $value instanceof \UnitEnum @@ -57,13 +63,24 @@ public function getClassConstValue(NodeAbstract $expr, string $_class, string $n : $value; } } - [$inheritedFound, $inherited] = $this->resolveInheritedClassConst($class, $name); + [$inheritedFound, $inherited] = $this->resolveInheritedClassConst($class, $name, $enumCasesAsIdentity); if ($inheritedFound) { return $inherited; } if ($this->hasClass($class)) { $classDef = $this->getClass($class); if ($classDef->enum && array_key_exists($name, $classDef->enumCases)) { + if ($enumCasesAsIdentity) { + // Each enum case is a distinct object in Zend: two cases + // are the same value only when both the enum class and + // the case name match, never through a shared case name + // or backing scalar. Callers comparing values for + // identity get an interned compiler-internal object that + // no user constant expression can construct or collide + // with (strings are binary-safe, so a marker string would + // still be spellable). + return EnumCaseIdentity::intern($classDef->getNamespacedName(false), $name); + } // The case IDENTITY is the constant's value; folding to the // backing scalar (or the case name) would make // `K::CONST === E::Case` false through every dynamic path. @@ -74,7 +91,7 @@ public function getClassConstValue(NodeAbstract $expr, string $_class, string $n } /** @return array{bool, mixed} */ - protected function resolveInheritedClassConst(string $class, string $name): array + protected function resolveInheritedClassConst(string $class, string $name, bool $enumCasesAsIdentity = false): array { $current = ltrim($class, '\\'); $visited = []; @@ -85,10 +102,11 @@ protected function resolveInheritedClassConst(string $class, string $name): arra if ($classDef->hasConstant($name)) { $constDef = $classDef->getConstant($name); if ($constDef->valueExpr !== null) { - return [true, $this->evaluateClassConstValue(null, $constDef, $current, $name)]; + return [true, $this->evaluateClassConstValue(null, $constDef, $current, $name, $enumCasesAsIdentity)]; } if ($constDef->class !== '' && defined($constDef->class . '::' . $name)) { - return [true, constant($constDef->class . '::' . $name)]; + $value = constant($constDef->class . '::' . $name); + return [true, $enumCasesAsIdentity ? $this->internHostEnumCase($value) : $value]; } } $current = $classDef->extends; @@ -98,6 +116,9 @@ protected function resolveInheritedClassConst(string $class, string $name): arra $constName = $current . '::' . $name; if (defined($constName)) { $value = constant($constName); + if ($enumCasesAsIdentity) { + return [true, $this->internHostEnumCase($value)]; + } return [true, $value instanceof \UnitEnum ? new EnumCaseRef(get_class($value), $value->name) : $value]; @@ -110,14 +131,29 @@ protected function resolveInheritedClassConst(string $class, string $name): arra return [false, null]; } - protected function evaluateClassConstValue(?NodeAbstract $origin, ConstantDef $constDef, string $class, string $name): mixed + /** + * A value read from the host runtime (constant() on an internal or + * already-linked constant) can be a live enum case object. Under identity + * semantics it must intern to the same EnumCaseIdentity a compiled enum + * case produces, so one case reached through a native, nested or + * inherited constant compares identical to the same case reached + * directly — and different cases never do. + */ + private function internHostEnumCase(mixed $value): mixed + { + return $value instanceof \UnitEnum + ? EnumCaseIdentity::intern(get_class($value), $value->name) + : $value; + } + + protected function evaluateClassConstValue(?NodeAbstract $origin, ConstantDef $constDef, string $class, string $name, bool $enumCasesAsIdentity = false): mixed { $valueExpr = $constDef->valueExpr; if (!$valueExpr instanceof Node\Expr) { $this->fatalError($origin, "Class constant `{$class}::{$name}` has no constant expression"); } - $evaluator = new ConstExprEvaluator(function (Node\Expr $expr) use ($origin, $class) { + $evaluator = new ConstExprEvaluator(function (Node\Expr $expr) use ($origin, $class, $enumCasesAsIdentity) { if ($expr instanceof Node\Expr\ConstFetch) { $constName = $expr->name->toString(); return match (strtolower($constName)) { @@ -125,7 +161,9 @@ protected function evaluateClassConstValue(?NodeAbstract $origin, ConstantDef $c 'false' => false, 'null' => null, default => defined($constName) - ? constant($constName) + ? ($enumCasesAsIdentity + ? $this->internHostEnumCase(constant($constName)) + : constant($constName)) : throw new \RuntimeException("Constant `{$constName}` not found"), }; } @@ -142,12 +180,24 @@ protected function evaluateClassConstValue(?NodeAbstract $origin, ConstantDef $c } return ltrim($this->getNamespacedClassName($className, $this->getNamespaceOfClass($class)), '\\'); } - if (strcasecmp($className, 'self') === 0) { + if (strcasecmp($className, 'self') === 0 || strcasecmp($className, 'static') === 0) { $className = $class; } elseif (strcasecmp($className, 'parent') === 0) { $className = $this->getParentClass($class); } - return $this->getClassConstValue($origin ?? $expr, $className, $constName, $class); + // A resolved self/parent/static target is already fully + // qualified, and a `\App3\C` source spelling is fully + // qualified even though Name::toString() strips the leading + // backslash. Mark both absolute so getClassConstValue() does + // not prepend the current file's namespace a second time + // (`TypePhp\TypePhp\...`, `App3\App3\...`). + if ($className !== '' + && ($expr->class instanceof Node\Name\FullyQualified + || strcasecmp($expr->class->toString(), $className) !== 0) + ) { + $className = '\\' . ltrim($className, '\\'); + } + return $this->getClassConstValue($origin ?? $expr, $className, $constName, $class, $enumCasesAsIdentity); } throw new \RuntimeException('Unsupported class constant expression'); }); diff --git a/src/Translator.php b/src/Translator.php index 5cc28030..6b1eee5f 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -3169,6 +3169,8 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) $traitMethods = []; $traitConstants = []; $traitProperties = []; + $usedTraits = []; + $seenTraitMethods = []; $classDef = $this->getClass($className->toString()); $usingClassDef = $classDef; $compositionOwner = $classDef->getNamespacedName(false); @@ -3207,6 +3209,7 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) if (!$traitDef->trait) { $this->fatalError($classStmt, "Trait `{$traitFullName}` not found"); } + $usedTraits[strtolower($traitFullName)] = $traitFullName; /** @var Node\Stmt\Trait_ $traitAst */ $traitAst = $this->cloneAstNode($traitDef->trait); @@ -3227,6 +3230,10 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) $traitStmt->setAttribute(self::TRAIT_METHOD_ATTRIBUTE, $traitStmt->name->toString()); } $fullMethodName = $this->getFullMethodName($traitFullName, $methodName); + // Methods arriving from nested traits are keyed under + // the directly-used trait, matching how adaptation + // keys are registered. + $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 @@ -3372,10 +3379,11 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) continue; } if (isset($traitConstants[$constName])) { - [$existingConstStmt, $existingConst] = $traitConstants[$constName]; + [$existingConstStmt, $existingConst, $existingConstTrait] = $traitConstants[$constName]; + $typeStr = $this->typeNodeToStringOrNull($traitStmt->type); if ($existingConstStmt->flags !== $traitStmt->flags || - $this->typeNodeToStringOrNull($existingConstStmt->type) !== $this->typeNodeToStringOrNull($traitStmt->type) || - $this->printer->prettyPrintExpr($existingConst->value) !== $this->printer->prettyPrintExpr($const->value)) { + $this->typeNodeToStringOrNull($existingConstStmt->type) !== $typeStr || + !$this->isSameTraitMemberValue($existingConst->value, $existingConstTrait, $const->value, $traitFullName, $typeStr)) { $this->fatalError($classStmt, "Trait `{$traitFullName}` constant `{$constName}` already exists"); } unset($traitStmt->consts[$k2]); @@ -3384,7 +3392,7 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) } continue; } - $traitConstants[$constName] = [$traitStmt, $const]; + $traitConstants[$constName] = [$traitStmt, $const, $traitFullName]; } } if ($traitStmt instanceof Node\Stmt\Property) { @@ -3401,12 +3409,13 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) continue; } 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; + [$existingPropStmt, $existingProp, $existingPropTrait] = $traitProperties[$propName]; + $typeStr = $this->typeNodeToStringOrNull($traitStmt->type); if ($existingPropStmt->flags !== $traitStmt->flags || - $this->typeNodeToStringOrNull($existingPropStmt->type) !== $this->typeNodeToStringOrNull($traitStmt->type) || - $existingDefault !== $propDefault) { + $this->typeNodeToStringOrNull($existingPropStmt->type) !== $typeStr || + ($existingProp->default === null) !== ($prop->default === null) || + ($prop->default !== null + && !$this->isSameTraitMemberValue($existingProp->default, $existingPropTrait, $prop->default, $traitFullName, $typeStr))) { $this->fatalError($classStmt, "Trait `{$traitFullName}` property `{$propName}` already exists"); } unset($traitStmt->props[$k2]); @@ -3430,7 +3439,7 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) "Readonly class `{$compositionOwner}` cannot use trait with a non-readonly property `{$traitFullName}::\${$prop->name->toString()}`", ); } - $traitProperties[$propName] = [$traitStmt, $prop]; + $traitProperties[$propName] = [$traitStmt, $prop, $traitFullName]; } } } @@ -3439,6 +3448,103 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) } } + $this->validateTraitAdaptations($stmt, $classDef, $usedTraits, $seenTraitMethods); + } + + /** + * After every trait is composed into $classDef, verify that each trait + * adaptation named a real trait and a real method, as Zend does when + * binding traits: + * + * - an alias must reference a used trait, and its method must exist in + * that trait (in any used trait when written without a qualifier); + * - a precedence rule's traits must all be used, and the preferred + * method must exist in the preferred trait (the overridden trait need + * not declare it). + * + * @param array $usedTraits lowercased name => full name + * @param array $seenTraitMethods "trait::method" keys seen + * during composition (nested trait methods + * are keyed under the directly-used trait) + */ + private function validateTraitAdaptations( + Node\Stmt\ClassLike $stmt, + ClassDef $classDef, + array $usedTraits, + array $seenTraitMethods + ): void { + if (!$classDef->traitAliases && !$classDef->traitIgnored) { + return; + } + $className = $classDef->getNamespacedName(false); + + // An unqualified alias is registered under every used trait's key (the + // Preprocessor cannot know which trait declares the method), so its + // variants share one group: the group is satisfied when ANY variant + // matched a composed method. + $aliasGroups = []; + foreach ($classDef->traitAliases as $fullMethodName => $aliasList) { + foreach ($aliasList as $alias) { + $group = $alias['group'] ?? $fullMethodName; + $aliasGroups[$group] ??= ['alias' => $alias, 'matched' => false]; + if (isset($seenTraitMethods[$fullMethodName])) { + $aliasGroups[$group]['matched'] = true; + } + } + } + foreach ($aliasGroups as $groupInfo) { + if ($groupInfo['matched']) { + continue; + } + $alias = $groupInfo['alias']; + $method = $alias['method'] ?? ''; + $explicitTrait = $alias['trait'] ?? null; + if ($explicitTrait !== null) { + if (!isset($usedTraits[strtolower($explicitTrait)])) { + $this->fatalError($stmt, + "Required Trait `{$explicitTrait}` wasn't added to `{$className}`"); + } + $this->fatalError($stmt, + "An alias was defined for `{$explicitTrait}::{$method}` but this method does not exist"); + } + $newName = $alias['newName'] ?? $method; + $this->fatalError($stmt, + "An alias (`{$newName}`) was defined for method `{$method}()`, but this method does not exist"); + } + + // Every precedence rule that named a loser method is validated: Zend + // checks each rule's winner individually, so a later `C::f insteadof + // B` never absolves an earlier `A::f insteadof B` whose winner method + // does not exist. + foreach ($classDef->traitIgnored as $rules) { + if (!is_array($rules)) { + continue; + } + foreach ($rules as $rule) { + foreach ([$rule['winnerTrait'], $rule['loserTrait']] as $traitName) { + if (!isset($usedTraits[strtolower($traitName)])) { + $this->fatalError($stmt, + "Required Trait `{$traitName}` wasn't added to `{$className}`"); + } + } + if (!isset($seenTraitMethods[$this->getFullMethodName($rule['winnerTrait'], $rule['method'])])) { + $this->fatalError($stmt, + "A precedence rule was defined for `{$rule['winnerTrait']}::{$rule['method']}` " . + 'but this method does not exist'); + } + } + } + // A trait method may be excluded only once, even across several `use` + // clauses; Zend reports duplicates after every rule passed the + // existence checks above. + foreach ($classDef->traitIgnored as $rules) { + if (is_array($rules) && count($rules) > 1) { + $rule = $rules[0]; + $this->fatalError($stmt, + "Failed to evaluate a trait precedence (`{$rule['method']}`). " . + "Method of trait `{$rule['loserTrait']}` was defined to be excluded multiple times"); + } + } } /** @@ -3482,6 +3588,51 @@ private function resolveTraitStmtMethodDef(Node\Stmt\ClassMethod $stmt, string $ return [$origin, $def]; } + /** + * Compare two trait data-member initializers by VALUE, as Zend does when + * flattening traits: `1 + 1` and `2`, or `[1, 2]` and `array(1, 2)`, are + * the same definition. Comparison is identity (===) after evaluating both + * constant expressions; an integer initializer of a float-typed member is + * coerced to float first, mirroring Zend's declaration-time coercion. + * Falls back to source-text equality when a value cannot be evaluated at + * compile time. + */ + private function isSameTraitMemberValue( + Node\Expr $existingValue, + string $existingClass, + Node\Expr $incomingValue, + string $incomingClass, + ?string $declaredTypeStr, + ): bool { + try { + $a = $this->evaluateTraitMemberValue($existingValue, $existingClass); + $b = $this->evaluateTraitMemberValue($incomingValue, $incomingClass); + } catch (\Throwable) { + return $this->printer->prettyPrintExpr($existingValue) === $this->printer->prettyPrintExpr($incomingValue); + } + if ($declaredTypeStr !== null + && (strcasecmp($declaredTypeStr, 'float') === 0 || strcasecmp($declaredTypeStr, '?float') === 0)) { + if (is_int($a)) { + $a = (float) $a; + } + if (is_int($b)) { + $b = (float) $b; + } + } + return $a === $b; + } + + private function evaluateTraitMemberValue(Node\Expr $expr, string $class): mixed + { + $constDef = new ConstantDef('', 0, '', ''); + $constDef->valueExpr = $expr; + // Enum cases must keep their (enum class, case name) identity here: + // Zend compares the case OBJECTS when flattening traits, so E1::Value + // and E2::Value are different definitions even though both would + // otherwise evaluate to the same case-name/backing scalar. + return $this->evaluateClassConstValue($expr, $constDef, $class, '', enumCasesAsIdentity: true); + } + /** * Validate that a concrete method satisfies an abstract requirement * declared by a trait, following Zend's trait-composition rules: the @@ -6533,20 +6684,56 @@ private function withTraitNameContext(string $traitName, callable $callback): mi private function isCompatibleTraitConstant(ConstantDef $existing, ConstantDef $incoming): bool { - return $existing->flags === $incoming->flags - && $existing->type === $incoming->type - && $existing->class === $incoming->class - && $existing->value === $incoming->value; + if ($existing->flags !== $incoming->flags + || $existing->type !== $incoming->type + || $existing->class !== $incoming->class + ) { + return false; + } + // Zend compares the EVALUATED definitions, so different spellings of + // one value (`1 + 1` and `2`) are compatible. The evaluated values are + // authoritative whenever both expressions are known: the lowered value + // string is not an identity (every non-literal initializer shares '', + // and E1::Value and E2::Value would both normalize to 'Value' even + // though Zend treats the two case objects as distinct). + if ($existing->valueExpr instanceof Node\Expr && $incoming->valueExpr instanceof Node\Expr) { + $floatOnly = $existing->declaredType === Type::FLOAT; + return $this->isSameTraitMemberValue( + $existing->valueExpr, + $this->getFullClassName(), + $incoming->valueExpr, + $this->getFullClassName(), + $floatOnly ? 'float' : null, + ); + } + return $existing->value === $incoming->value; } private function isCompatibleTraitProperty(PropertyDef $existing, PropertyDef $incoming): 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 + ) { + return false; + } + if ($existing->default === $incoming->default && $existing->arrayDef == $incoming->arrayDef) { + return true; + } + // Different spellings of the same default value (e.g. `1` and `1.0` + // on a float property, `[1, 2]` and `array(1, 2)`) are compatible in + // Zend; compare the evaluated values. + if ($existing->defaultExpr instanceof Node\Expr && $incoming->defaultExpr instanceof Node\Expr) { + return $this->isSameTraitMemberValue( + $existing->defaultExpr, + $this->getFullClassName(), + $incoming->defaultExpr, + $this->getFullClassName(), + $existing->type === Type::FLOAT ? 'float' : null, + ); + } + return false; } private function resolveLateBoundClass(ClassDef $usingClassDef, string $keyword): ?string