Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions src/Rules/Classes/ConflictingTraitMethodsRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Classes;

use PhpParser\Node;
use PhpParser\Node\Stmt\TraitUseAdaptation\Precedence;
use PHPStan\Analyser\Scope;
use PHPStan\DependencyInjection\RegisteredRule;
use PHPStan\Node\InClassNode;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use function array_key_exists;
use function array_keys;
use function count;
use function reset;
use function sprintf;
use function strtolower;

/**
* @implements Rule<InClassNode>
*/
#[RegisteredRule(level: 0)]
final class ConflictingTraitMethodsRule implements Rule
{

public function __construct(private ReflectionProvider $reflectionProvider)
{
}

public function getNodeType(): string
{
return InClassNode::class;
}

/**
* @return list<IdentifierRuleError>
*/
public function processNode(Node $node, Scope $scope): array
{
$classReflection = $node->getClassReflection();
$classLike = $node->getOriginalNode();
$traitUses = $classLike->getTraitUses();

if ($traitUses === []) {
return [];
}

// Collect methods defined directly on the class (not from traits)
$classOwnMethods = [];
foreach ($classLike->getMethods() as $method) {
$classOwnMethods[strtolower($method->name->name)] = true;
}

// Collect all insteadof adaptations
// Key: "traitName::methodName" that is being overridden
$insteadofResolutions = [];
foreach ($traitUses as $traitUse) {
foreach ($traitUse->adaptations as $adaptation) {
if (!$adaptation instanceof Precedence) {
continue;
}
$methodName = strtolower($adaptation->method->name);
foreach ($adaptation->insteadof as $insteadofTrait) {
$insteadofResolutions[strtolower((string) $insteadofTrait) . '::' . $methodName] = true;
}
}
}

// Collect methods from each trait
// Map: lowercased method name => [traitName => true]
$methodTraitMap = [];
foreach ($traitUses as $traitUse) {
foreach ($traitUse->traits as $traitName) {
$traitNameStr = (string) $traitName;
if (!$this->reflectionProvider->hasClass($traitNameStr)) {
continue;
}
$traitReflection = $this->reflectionProvider->getClass($traitNameStr);
if (!$traitReflection->isTrait()) {
continue;
}

foreach ($traitReflection->getNativeReflection()->getMethods() as $method) {
$lowerMethodName = strtolower($method->getName());
$methodTraitMap[$lowerMethodName][$traitReflection->getName()] = [
'name' => $method->getName(),
'abstract' => $method->isAbstract(),
];
}
}
}

$errors = [];
foreach ($methodTraitMap as $lowerMethodName => $traits) {
if (count($traits) <= 1) {
continue;
}

// If the class defines the method itself, no conflict
if (array_key_exists($lowerMethodName, $classOwnMethods)) {
continue;
}

// Filter out abstract methods - PHP allows abstract + concrete without conflict
$concreteTraits = [];
foreach ($traits as $traitName => $methodInfo) {
if ($methodInfo['abstract']) {
continue;
}

$concreteTraits[$traitName] = $methodInfo;
}

if (count($concreteTraits) <= 1) {
continue;
}

// Check which traits still have unresolved conflicts
$unresolvedTraits = [];
foreach ($concreteTraits as $traitName => $methodInfo) {
$key = strtolower($traitName) . '::' . $lowerMethodName;
if (array_key_exists($key, $insteadofResolutions)) {
continue;
}

$unresolvedTraits[$traitName] = $methodInfo['name'];
}

if (count($unresolvedTraits) <= 1) {
continue;
}

$traitNames = array_keys($unresolvedTraits);
$methodName = reset($unresolvedTraits);

$errors[] = RuleErrorBuilder::message(sprintf(
'Trait method %s::%s() has not been applied as %s::%s(), because of collision with %s::%s().',
$traitNames[1],
$methodName,
$classReflection->getDisplayName(),
$methodName,
$traitNames[0],
$methodName,
))
->identifier('class.traitMethodCollision')
->nonIgnorable()
->build();
}

return $errors;
}

}
49 changes: 49 additions & 0 deletions tests/PHPStan/Rules/Classes/ConflictingTraitMethodsRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Classes;

use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;

/**
* @extends RuleTestCase<ConflictingTraitMethodsRule>
*/
class ConflictingTraitMethodsRuleTest extends RuleTestCase
{

protected function getRule(): Rule
{
return new ConflictingTraitMethodsRule(
self::getContainer()->getByType(ReflectionProvider::class),
);
}

public function testBug14332(): void
{
$this->analyse([__DIR__ . '/data/bug-14332.php'], [
[
'Trait method Bug14332\MyTrait2::doSomething() has not been applied as Bug14332\FooWithMultipleConflictingTraits::doSomething(), because of collision with Bug14332\MyTrait1::doSomething().',
20,
],
[
'Trait method Bug14332\MyTrait4::doSomething() has not been applied as Bug14332\FooWithMultipleConflicts::doSomething(), because of collision with Bug14332\MyTrait1::doSomething().',
75,
],
[
'Trait method Bug14332\MyTrait5::anotherMethod() has not been applied as Bug14332\FooWithMultipleConflicts::anotherMethod(), because of collision with Bug14332\MyTrait4::anotherMethod().',
75,
],
[
'Trait method Bug14332\MyTrait5::anotherMethod() has not been applied as Bug14332\FooWithPartialResolution::anotherMethod(), because of collision with Bug14332\MyTrait4::anotherMethod().',
81,
],
]);
}

public function testBug14332Abstract(): void
{
$this->analyse([__DIR__ . '/data/bug-14332-abstract.php'], []);
}

}
21 changes: 21 additions & 0 deletions tests/PHPStan/Rules/Classes/data/bug-14332-abstract.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php declare(strict_types = 1);

namespace Bug14332Abstract;

trait ConcreteTrait
{
public function doSomething(): void
{
}
}

trait AbstractTrait
{
abstract public function doSomething(): void;
}

// ok - abstract + concrete is allowed
class FooWithAbstractAndConcrete
{
use ConcreteTrait, AbstractTrait;
}
101 changes: 101 additions & 0 deletions tests/PHPStan/Rules/Classes/data/bug-14332.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php declare(strict_types = 1);

namespace Bug14332;

trait MyTrait1
{
public function doSomething(): void
{
}
}

trait MyTrait2
{
public function doSomething(): void
{
}
}

// error - conflicting methods without resolution
class FooWithMultipleConflictingTraits
{
use MyTrait1, MyTrait2;
}

// ok - resolved with insteadof
class FooWithInsteadof
{
use MyTrait1, MyTrait2 {
MyTrait1::doSomething insteadof MyTrait2;
}
}

// ok - class defines the method itself
class FooWithOwnMethod
{
use MyTrait1, MyTrait2;

public function doSomething(): void
{
}
}

trait MyTrait3
{
public function otherMethod(): void
{
}
}

// ok - no conflicting methods
class FooWithNoConflict
{
use MyTrait1, MyTrait3;
}

trait MyTrait4
{
public function doSomething(): void
{
}

public function anotherMethod(): void
{
}
}

trait MyTrait5
{
public function anotherMethod(): void
{
}
}

// error - two conflicting methods from different pairs of traits
class FooWithMultipleConflicts
{
use MyTrait1, MyTrait4, MyTrait5;
}

// error - partially resolved (only one conflict resolved)
class FooWithPartialResolution
{
use MyTrait1, MyTrait4, MyTrait5 {
MyTrait1::doSomething insteadof MyTrait4;
}
}

// ok - all conflicts resolved
class FooWithFullResolution
{
use MyTrait1, MyTrait4, MyTrait5 {
MyTrait1::doSomething insteadof MyTrait4;
MyTrait4::anotherMethod insteadof MyTrait5;
}
}

// ok - single trait, no conflict
class FooWithSingleTrait
{
use MyTrait1;
}
Loading