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', + ); + } +} diff --git a/phpunit/src/TraitMemberValueConflictTest.php b/phpunit/src/TraitMemberValueConflictTest.php new file mode 100644 index 00000000..560827c8 --- /dev/null +++ b/phpunit/src/TraitMemberValueConflictTest.php @@ -0,0 +1,32 @@ +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'); + } +} diff --git a/src/Entity/ClassDef.php b/src/Entity/ClassDef.php index 5d9bb163..b2abfdc7 100644 --- a/src/Entity/ClassDef.php +++ b/src/Entity/ClassDef.php @@ -78,14 +78,18 @@ class ClassDef extends ClassLikeDef public array $traitUseConstants = []; /** - * 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 -> precedence rule + * info for existence validation. Consumers test the key with isset(). + * @var array */ public array $traitIgnored = []; public int $flags; diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 18b99a6e..2ad4472f 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -2438,7 +2438,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) { @@ -2461,6 +2466,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, ]; } } @@ -2469,6 +2477,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} @@ -2476,7 +2485,13 @@ 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 the rule for existence validation + // during composition; consumers only use isset() on the key. + $ignored[$this->getFullMethodName($traitName, $methodName)] = [ + 'method' => $methodName, + 'winnerTrait' => $winnerTrait, + 'loserTrait' => $traitName, + ]; } } } diff --git a/src/Translator.php b/src/Translator.php index be6aa21d..c6103c40 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -3068,6 +3068,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); @@ -3106,6 +3108,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); @@ -3126,6 +3129,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 @@ -3271,10 +3278,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]); @@ -3283,7 +3291,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) { @@ -3297,12 +3305,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]); @@ -3311,7 +3320,7 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className) } continue; } - $traitProperties[$propName] = [$traitStmt, $prop]; + $traitProperties[$propName] = [$traitStmt, $prop, $traitFullName]; } } } @@ -3320,6 +3329,86 @@ 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"); + } + + foreach ($classDef->traitIgnored as $rule) { + if (!is_array($rule)) { + continue; + } + 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'); + } + } } /** @@ -3363,6 +3452,47 @@ 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; + return $this->evaluateClassConstValue($expr, $constDef, $class, ''); + } + /** * Validate that a concrete method satisfies an abstract requirement * declared by a trait, following Zend's trait-composition rules: the @@ -5642,20 +5772,55 @@ 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; + } + if ($existing->value === $incoming->value) { + return true; + } + // Different spellings of the same value (e.g. `1 + 1` and `2`) are + // compatible in Zend; compare the evaluated values. + 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 false; } 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