diff --git a/extension.neon b/extension.neon index d6fa0368..f69f7bdb 100644 --- a/extension.neon +++ b/extension.neon @@ -76,6 +76,11 @@ services: - class: mglaman\PHPStanDrupal\Reflection\EntityFieldsViaMagicReflectionExtension tags: [phpstan.broker.propertiesClassReflectionExtension] + - + class: mglaman\PHPStanDrupal\Rules\Classes\ClassExtendsInternalClassRule + tags: [phpstan.rules.rule] + arguments: + reflectionProvider: @reflectionProvider - class: mglaman\PHPStanDrupal\Rules\Drupal\LoadIncludes tags: [phpstan.rules.rule] diff --git a/src/Internal/ClassHelper.php b/src/Internal/ClassHelper.php new file mode 100644 index 00000000..a39cdec0 --- /dev/null +++ b/src/Internal/ClassHelper.php @@ -0,0 +1,47 @@ +namespacedName)) { + return false; + } + + return 'Drupal' === (string) $class->namespacedName->slice(0, 1); + } + + public static function isSharedNamespace(Class_ $class): bool + { + if (!isset($class->extends)) { + return false; + } + + if (!isset($class->namespacedName)) { + return false; + } + + if (!self::isInDrupalNamespace($class)) { + return false; + } + + $classNamespaceBase = (string) $class->namespacedName->slice(0, 2); + $extendedClassNamespaceBase = (string) $class->extends->slice(0, 2); + + if ('Drupal\Core' === $classNamespaceBase && 'Drupal\Component' === $extendedClassNamespaceBase) { + return true; + } + if ('Drupal\Component' === $classNamespaceBase && 'Drupal\Core' === $extendedClassNamespaceBase) { + return true; + } + + return $classNamespaceBase === $extendedClassNamespaceBase; + } +} diff --git a/src/Rules/Classes/ClassExtendsInternalClassRule.php b/src/Rules/Classes/ClassExtendsInternalClassRule.php new file mode 100644 index 00000000..a3284041 --- /dev/null +++ b/src/Rules/Classes/ClassExtendsInternalClassRule.php @@ -0,0 +1,74 @@ +reflectionProvider = $reflectionProvider; + } + + public function getNodeType(): string + { + return Class_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + /** @var Class_ $node */ + if (!isset($node->extends)) { + return []; + } + + $extendedClassName = $node->extends->toString(); + if (!$this->reflectionProvider->hasClass($extendedClassName)) { + return []; + } + + $extendedClassReflection = $this->reflectionProvider->getClass($extendedClassName); + if (!$extendedClassReflection->isInternal()) { + return []; + } + + if (!isset($node->namespacedName)) { + return $this->buildError(null, $extendedClassName); + } + + $currentClassName = $node->namespacedName->toString(); + + if (!ClassHelper::isInDrupalNamespace($node)) { + return $this->buildError($currentClassName, $extendedClassName); + } + + if (ClassHelper::isSharedNamespace($node)) { + return []; + } + + return $this->buildError($currentClassName, $extendedClassName); + } + + private function buildError(?string $currentClassName, string $extendedClassName): array + { + return [ + RuleErrorBuilder::message(\sprintf( + '%s extends @internal class %s.', + $currentClassName !== null ? \sprintf('Class %s', $currentClassName) : 'Anonymous class', + $extendedClassName + ))->build() + ]; + } +} diff --git a/tests/fixtures/drupal/modules/module_with_internal_classes/module_with_internal_classes.info.yml b/tests/fixtures/drupal/modules/module_with_internal_classes/module_with_internal_classes.info.yml new file mode 100644 index 00000000..ad32006e --- /dev/null +++ b/tests/fixtures/drupal/modules/module_with_internal_classes/module_with_internal_classes.info.yml @@ -0,0 +1,3 @@ +name: module_with_internal_classes +type: module +core: 8.x diff --git a/tests/fixtures/drupal/modules/module_with_internal_classes/src/Foo/ExternalClass.php b/tests/fixtures/drupal/modules/module_with_internal_classes/src/Foo/ExternalClass.php new file mode 100644 index 00000000..eb049e59 --- /dev/null +++ b/tests/fixtures/drupal/modules/module_with_internal_classes/src/Foo/ExternalClass.php @@ -0,0 +1,7 @@ +createReflectionProvider()); + } + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + self::cleanDrupalCoreStubs(); + foreach (self::getDrupalCoreStubs() as $filepath => $content) { + $directory = \dirname($filepath); + if (!is_dir($directory)) { + \mkdir($directory, 0777, true); + } + \file_put_contents($filepath, $content); + } + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + + self::cleanDrupalCoreStubs(); + } + + /** + * @dataProvider pluginData + */ + public function testRule(string $path, array $errorMessages): void + { + $this->analyse([$path], $errorMessages); + } + + public function pluginData(): \Generator + { + yield 'extends an internal class from a non-shared namespace: phpstan_fixtures extends an internal class from Drupal core.' => [ + __DIR__ . '/../../fixtures/drupal/modules/phpstan_fixtures/src/Internal/ExtendsDrupalCoreInternalClass.php', + [ + [ + 'Class Drupal\phpstan_fixtures\Internal\ExtendsDrupalCoreInternalClass extends @internal class Drupal\Core\InternalClass.', + 7 + ], + ], + ]; + yield 'extends an internal class from a non-shared namespace: phpstan_fixtures extends an internal class from Drupal core with a deeper namespace.' => [ + __DIR__ . '/../../fixtures/drupal/modules/phpstan_fixtures/src/Internal/ExtendsDrupalCorePHPStanDrupalTestsInternalClass.php', + [ + [ + 'Class Drupal\phpstan_fixtures\Internal\ExtendsDrupalCorePHPStanDrupalTestsInternalClass extends @internal class Drupal\Core\PHPStanDrupalTests\InternalClass.', + 7 + ], + ], + ]; + yield 'extends an internal class from a non-shared namespace: phpstan_fixtures extends an internal class from module module_with_internal_classes.' => [ + __DIR__ . '/../../fixtures/drupal/modules/phpstan_fixtures/src/Internal/ExtendsPHPStanDrupalModuleWithInternalClassesInternalClass.php', + [ + [ + 'Class Drupal\phpstan_fixtures\Internal\ExtendsPHPStanDrupalModuleWithInternalClassesInternalClass extends @internal class Drupal\module_with_internal_classes\Foo\InternalClass.', + 7 + ], + ], + ]; + yield 'extends an internal class from a non-shared namespace: anonymous class extends from an internal class from module module_with_internal_classes.' => [ + __DIR__ . '/../../fixtures/drupal/modules/phpstan_fixtures/src/Internal/WithAnAnonymousClassExtendingAnInternalClass.php', + [ + [ + 'Anonymous class extends @internal class Drupal\module_with_internal_classes\Foo\InternalClass.', + 13 + ], + ], + ]; + yield 'extends an internal class from a non-shared namespace: Drupal Core extends an internal class from module phpstan_fixtures.' => [ + __DIR__ . '/../../fixtures/drupal/core/lib/Drupal/Core/PHPStanDrupalTests/ExtendsPhpStanFixturesInternalClass.php', + [ + [ + 'Class Drupal\Core\PHPStanDrupalTests\ExtendsPhpStanFixturesInternalClass extends @internal class Drupal\phpstan_fixtures\InternalClass.', + 7 + ], + ], + ]; + yield 'extends an internal class from a shared namespace: Drupal Core extends from an internal class from itself.' => [ + __DIR__ . '/../../fixtures/drupal/core/lib/Drupal/Core/PHPStanDrupalTests/ExtendsRootInternalClass.php', + [], + ]; + yield 'extends an internal class from a shared namespace: Drupal Core extends from an internal class from itself with a deeper namespace.' => [ + __DIR__ . '/../../fixtures/drupal/core/lib/Drupal/Core/PHPStanDrupalTests/ExtendsInternalClass.php', + [], + ]; + yield 'extends an internal class from a shared namespace: Drupal Core extends from an internal class from Drupal Component.' => [ + __DIR__ . '/../../fixtures/drupal/core/lib/Drupal/Core/PHPStanDrupalTests/ExtendsDrupalComponentInternalClass.php', + [], + ]; + yield 'extends an internal class from a shared namespace: Drupal Component extends from an internal class from Drupal Core.' => [ + __DIR__ . '/../../fixtures/drupal/core/lib/Drupal/Component/PHPStanDrupalTests/ExtendsDrupalCoreInternalClass.php', + [], + ]; + yield 'extends an internal class from a shared namespace: phpstan_fixtures extends from an internal class from itself.' => [ + __DIR__ . '/../../fixtures/drupal/modules/phpstan_fixtures/src/Internal/ExtendsRootInternalClass.php', + [], + ]; + yield 'extends an internal class from a shared namespace: phpstan_fixtures extends from an internal class from itself with a deeper namespace.' => [ + __DIR__ . '/../../fixtures/drupal/modules/phpstan_fixtures/src/Internal/ExtendsInternalClass.php', + [], + ]; + yield 'does not extend an internal class: does not extends any class.' => [ + __DIR__ . '/../../fixtures/drupal/modules/phpstan_fixtures/src/Internal/DoesNotExtendsAnyClass.php', + [], + ]; + yield 'does not extend an internal class: phpstan_fixtures extends an external class from Drupal Core.' => [ + __DIR__ . '/../../fixtures/drupal/modules/phpstan_fixtures/src/Internal/ExtendsDrupalCoreExternalClass.php', + [], + ]; + yield 'does not extend an internal class: phpstan_fixtures extends an external class from module module_with_internal_classes.' => [ + __DIR__ . '/../../fixtures/drupal/modules/phpstan_fixtures/src/Internal/ExtendsPHPStanDrupalModuleWithInternalClassesExternalClass.php', + [], + ]; + } + + private static function cleanDrupalCoreStubs(): void { + foreach (\array_keys(\iterator_to_array(self::getDrupalCoreStubs())) as $filepath) { + if (!is_file($filepath)) { + continue; + } + \unlink($filepath); + } + + if (is_dir(self::DRUPAL_CORE_STUBS_MAIN_DIRECTORY)) { + \rmdir(self::DRUPAL_CORE_STUBS_MAIN_DIRECTORY); + } + } + + private static function getDrupalCoreStubs(): \Generator { + yield __DIR__ . '/../../fixtures/drupal/core/lib/Drupal/Core/InternalClass.php' => <<<'CODE' + <<<'CODE' + <<<'CODE' + <<<'CODE' + <<<'CODE' + <<<'CODE' + <<<'CODE' + <<<'CODE' + <<<'CODE' +