clean install

This commit is contained in:
2024-12-10 15:08:16 +01:00
commit e14eb2d8fd
31193 changed files with 3555714 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\DeprecatedScope;
use Drupal\Component\Utility\DeprecationHelper;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Rules\Deprecations\DeprecatedScopeResolver;
use function class_exists;
use function count;
final class DeprecationHelperScope implements DeprecatedScopeResolver
{
public function isScopeDeprecated(Scope $scope): bool
{
if (!class_exists(DeprecationHelper::class)) {
return false;
}
$callStack = $scope->getFunctionCallStackWithParameters();
if (count($callStack) === 0) {
return false;
}
[$function, $parameter] = $callStack[0];
if (!$function instanceof MethodReflection) {
return false;
}
if ($function->getName() !== 'backwardsCompatibleCall'
|| $function->getDeclaringClass()->getName() !== DeprecationHelper::class
) {
return false;
}
return $parameter !== null && $parameter->getName() === 'deprecatedCallable';
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\DeprecatedScope;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Deprecations\DeprecatedScopeResolver;
use function strpos;
final class GroupLegacyScope implements DeprecatedScopeResolver
{
public function isScopeDeprecated(Scope $scope): bool
{
if ($scope->isInClass()) {
$class = $scope->getClassReflection();
$phpDoc = $class->getResolvedPhpDoc();
if ($phpDoc !== null && strpos($phpDoc->getPhpDocString(), '@group legacy') !== false) {
return true;
}
}
$function = $scope->getFunction();
return $function !== null
&& $function->getDocComment() !== null
&& strpos($function->getDocComment(), '@group legacy') !== false;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\DeprecatedScope;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Deprecations\DeprecatedScopeResolver;
use PHPUnit\Framework\Attributes\IgnoreDeprecations;
final class IgnoreDeprecationsScope implements DeprecatedScopeResolver
{
public function isScopeDeprecated(Scope $scope): bool
{
if (!class_exists(IgnoreDeprecations::class)) {
return false;
}
if ($scope->isInClass()) {
$class = $scope->getClassReflection()->getNativeReflection();
if ($class->getAttributes(IgnoreDeprecations::class) !== []) {
return true;
}
$function = $scope->getFunction();
if ($function === null) {
return false;
}
$method = $class->getMethod($function->getName());
if ($method->getAttributes(IgnoreDeprecations::class) !== []) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,378 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Drupal;
use Composer\Autoload\ClassLoader;
use Drupal\Core\DependencyInjection\ContainerNotInitializedException;
use Drupal\Core\DrupalKernelInterface;
use Drupal\TestTools\PhpUnitCompatibility\PhpUnit8\ClassWriter;
use DrupalFinder\DrupalFinderComposerRuntime;
use Drush\Drush;
use PHPStan\DependencyInjection\Container;
use PHPUnit\Framework\Test;
use ReflectionClass;
use RuntimeException;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Yaml\Yaml;
use Throwable;
use function array_map;
use function array_merge;
use function array_walk;
use function class_exists;
use function dirname;
use function file_exists;
use function in_array;
use function interface_exists;
use function is_array;
use function is_dir;
use function is_string;
use function str_replace;
use function strpos;
use function strtr;
use function trigger_error;
use function ucwords;
use function usort;
class DrupalAutoloader
{
/**
* @var \Composer\Autoload\ClassLoader
*/
private $autoloader;
/**
* @var string
*/
private $drupalRoot;
/**
* List of available modules.
*
* @var Extension[]
*/
protected $moduleData = [];
/**
* List of available themes.
*
* @var Extension[]
*/
protected $themeData = [];
/**
* @var array<array<string, string>>
*/
private $serviceMap = [];
/**
* @var array<string, string>
*/
private $serviceYamls = [];
/**
* @var array<string, string>
*/
private $serviceClassProviders = [];
/**
* @var array
*/
private $namespaces = [];
public function register(Container $container): void
{
/**
* @var array{drupal_root: string|null, bleedingEdge: array{checkDeprecatedHooksInApiFiles: bool, checkCoreDeprecatedHooksInApiFiles: bool, checkContribDeprecatedHooksInApiFiles: bool}} $drupalParams
*/
$drupalParams = $container->getParameter('drupal');
// Trigger deprecation error if drupal_root is used.
if (is_string($drupalParams['drupal_root'])) {
trigger_error('The drupal_root parameter is deprecated. Remove it from your configuration. Drupal Root is discoverd automatically.', E_USER_DEPRECATED);
}
$finder = new DrupalFinderComposerRuntime();
$drupalRoot = $finder->getDrupalRoot();
$drupalVendorRoot = $finder->getVendorDir();
if (!(is_string($drupalRoot) && is_string($drupalVendorRoot))) {
throw new RuntimeException("Unable to detect Drupal with webflo/drupal-finder.");
}
$this->drupalRoot = $drupalRoot;
$this->autoloader = include $drupalVendorRoot . '/autoload.php';
$this->serviceYamls['core'] = $drupalRoot . '/core/core.services.yml';
$this->serviceClassProviders['core'] = '\Drupal\Core\CoreServiceProvider';
$this->serviceMap['service_provider.core.service_provider'] = ['class' => $this->serviceClassProviders['core']];
// Attach synthetic services
// @see \Drupal\Core\DrupalKernel::attachSynthetic
$this->serviceMap['kernel'] = ['class' => DrupalKernelInterface::class];
$this->serviceMap['class_loader'] = ['class' => ClassLoader::class];
$extensionDiscovery = new ExtensionDiscovery($this->drupalRoot);
$extensionDiscovery->setProfileDirectories([]);
$profiles = $extensionDiscovery->scan('profile');
$profile_directories = array_map(static function (Extension $profile) : string {
return $profile->getPath();
}, $profiles);
$extensionDiscovery->setProfileDirectories($profile_directories);
$this->moduleData = array_merge($extensionDiscovery->scan('module'), $profiles);
usort($this->moduleData, static function (Extension $a, Extension $b) {
return strpos($a->getName(), '_test') !== false ? 10 : 0;
});
$this->themeData = $extensionDiscovery->scan('theme');
$this->addCoreTestNamespaces();
$this->addModuleNamespaces();
$this->addThemeNamespaces();
$this->registerPs4Namespaces($this->namespaces);
$this->loadLegacyIncludes();
// Trigger deprecation error if checkDeprecatedHooksInApiFiles is enabled.
if ($drupalParams['bleedingEdge']['checkDeprecatedHooksInApiFiles']) {
trigger_error('The bleedingEdge.checkDeprecatedHooksInApiFiles parameter is deprecated and will be removed in a future release.', E_USER_DEPRECATED);
}
$checkDeprecatedHooksInApiFiles = $drupalParams['bleedingEdge']['checkDeprecatedHooksInApiFiles'];
$checkCoreDeprecatedHooksInApiFiles = $drupalParams['bleedingEdge']['checkCoreDeprecatedHooksInApiFiles'] || $checkDeprecatedHooksInApiFiles;
$checkContribDeprecatedHooksInApiFiles = $drupalParams['bleedingEdge']['checkContribDeprecatedHooksInApiFiles'] || $checkDeprecatedHooksInApiFiles;
foreach ($this->moduleData as $extension) {
$this->loadExtension($extension);
$module_name = $extension->getName();
$module_dir = $this->drupalRoot . '/' . $extension->getPath();
// Add .install
if (file_exists($module_dir . '/' . $module_name . '.install')) {
$ignored_install_files = ['entity_test', 'entity_test_update', 'update_test_schema'];
if (!in_array($module_name, $ignored_install_files, true)) {
$this->loadAndCatchErrors($module_dir . '/' . $module_name . '.install');
}
}
// Add .post_update.php
if (file_exists($module_dir . '/' . $module_name . '.post_update.php')) {
$this->loadAndCatchErrors($module_dir . '/' . $module_name . '.post_update.php');
}
// Add .api.php for core modules
if ($checkCoreDeprecatedHooksInApiFiles && $extension->origin === 'core' && file_exists($module_dir . '/' . $module_name . '.api.php')) {
$this->loadAndCatchErrors($module_dir . '/' . $module_name . '.api.php');
}
// Add .api.php for contrib modules
if ($checkContribDeprecatedHooksInApiFiles && $extension->origin !== 'core' && file_exists($module_dir . '/' . $module_name . '.api.php')) {
$this->loadAndCatchErrors($module_dir . '/' . $module_name . '.api.php');
}
// Add misc .inc that are magically allowed via hook_hook_info.
$magic_hook_info_includes = [
'views',
'views_execution',
'tokens',
'search_api',
'pathauto',
];
foreach ($magic_hook_info_includes as $hook_info_include) {
if (file_exists($module_dir . "/$module_name.$hook_info_include.inc")) {
$this->loadAndCatchErrors($module_dir . "/$module_name.$hook_info_include.inc");
}
}
}
foreach ($this->themeData as $extension) {
$this->loadExtension($extension);
$theme_dir = $this->drupalRoot . '/' . $extension->getPath();
$theme_settings_file = $theme_dir . '/theme-settings.php';
if (file_exists($theme_settings_file)) {
$this->loadAndCatchErrors($theme_settings_file);
}
}
if (class_exists(Drush::class)) {
$reflect = new ReflectionClass(Drush::class);
if ($reflect->getFileName() !== false) {
$levels = 2;
if (Drush::getMajorVersion() < 9) {
$levels = 3;
}
$drushDir = dirname($reflect->getFileName(), $levels);
/** @var \SplFileInfo $file */
foreach (Finder::create()->files()->name('*.inc')->in($drushDir . '/includes') as $file) {
require_once $file->getPathname();
}
}
}
foreach ($this->serviceYamls as $extension => $serviceYaml) {
$yaml = Yaml::parseFile($serviceYaml, Yaml::PARSE_CUSTOM_TAGS);
// Weed out service files which only provide parameters.
if (!isset($yaml['services']) || !is_array($yaml['services'])) {
continue;
}
foreach ($yaml['services'] as $serviceId => $serviceDefinition) {
// Check if this is an alias shortcut.
// @link https://symfony.com/doc/4.4/service_container/alias_private.html#aliasing
if (is_string($serviceDefinition)) {
$serviceDefinition = [
'alias' => str_replace('@', '', $serviceDefinition),
];
}
// Prevent \Nette\DI\ContainerBuilder::completeStatement from array_walk_recursive into the arguments
// and thinking these are real services for PHPStan's container.
if (isset($serviceDefinition['arguments']) && is_array($serviceDefinition['arguments'])) {
array_walk($serviceDefinition['arguments'], function (&$argument) : void {
if (is_array($argument) || !is_string($argument)) {
// @todo fix for @http_kernel.controller.argument_metadata_factory
$argument = '';
} else {
$argument = str_replace('@', '', $argument);
}
});
}
// @todo sanitize "calls" and "configurator" and "factory"
/**
jsonapi.params.enhancer:
class: Drupal\jsonapi\Routing\JsonApiParamEnhancer
calls:
- [setContainer, ['@service_container']]
tags:
- { name: route_enhancer }
*/
unset($serviceDefinition['tags'], $serviceDefinition['calls'], $serviceDefinition['configurator'], $serviceDefinition['factory']);
$this->serviceMap[$serviceId] = $serviceDefinition;
}
}
$service_map = $container->getByType(ServiceMap::class);
$service_map->setDrupalServices($this->serviceMap);
if (interface_exists(Test::class)
&& class_exists('Drupal\TestTools\PhpUnitCompatibility\PhpUnit8\ClassWriter')) {
ClassWriter::mutateTestBase($this->autoloader);
}
$extension_map = $container->getByType(ExtensionMap::class);
$extension_map->setExtensions($this->moduleData, $this->themeData, $profiles);
}
protected function loadLegacyIncludes(): void
{
/** @var \SplFileInfo $file */
foreach (Finder::create()->files()->name('*.inc')->in($this->drupalRoot . '/core/includes') as $file) {
require_once $file->getPathname();
}
}
protected function addCoreTestNamespaces(): void
{
// Add core test namespaces.
$core_tests_dir = $this->drupalRoot . '/core/tests/Drupal';
$this->namespaces['Drupal\\BuildTests'] = $core_tests_dir . '/BuildTests';
$this->namespaces['Drupal\\FunctionalJavascriptTests'] = $core_tests_dir . '/FunctionalJavascriptTests';
$this->namespaces['Drupal\\FunctionalTests'] = $core_tests_dir . '/FunctionalTests';
$this->namespaces['Drupal\\KernelTests'] = $core_tests_dir . '/KernelTests';
$this->namespaces['Drupal\\Tests'] = $core_tests_dir . '/Tests';
$this->namespaces['Drupal\\TestSite'] = $core_tests_dir . '/TestSite';
$this->namespaces['Drupal\\TestTools'] = $core_tests_dir . '/TestTools';
$this->namespaces['Drupal\\Tests\\TestSuites'] = $this->drupalRoot . '/core/tests/TestSuites';
}
protected function addModuleNamespaces(): void
{
foreach ($this->moduleData as $module) {
$module_name = $module->getName();
$module_dir = $this->drupalRoot . '/' . $module->getPath();
$this->namespaces["Drupal\\$module_name"] = $module_dir . '/src';
// Extensions can have a \Drupal\Tests\extension namespace for test cases, traits, and other classes such
// as those that extend \Drupal\TestSite\TestSetupInterface.
// @see drupal_phpunit_get_extension_namespaces()
$module_test_dir = $module_dir . '/tests/src';
if (is_dir($module_test_dir)) {
$this->namespaces["Drupal\\Tests\\$module_name"] = $module_test_dir;
}
$servicesFileName = $module_dir . '/' . $module_name . '.services.yml';
if (file_exists($servicesFileName)) {
$this->serviceYamls[$module_name] = $servicesFileName;
}
$camelized = $this->camelize($module_name);
$name = "{$camelized}ServiceProvider";
$class = "Drupal\\{$module_name}\\{$name}";
$this->serviceClassProviders[$module_name] = $class;
$serviceId = "service_provider.$module_name.service_provider";
$this->serviceMap[$serviceId] = ['class' => $class];
$this->registerExtensionTestNamespace($module);
}
}
protected function addThemeNamespaces(): void
{
foreach ($this->themeData as $theme_name => $theme) {
$theme_dir = $this->drupalRoot . '/' . $theme->getPath();
$this->namespaces["Drupal\\$theme_name"] = $theme_dir . '/src';
$this->registerExtensionTestNamespace($theme);
}
}
protected function registerExtensionTestNamespace(Extension $extension): void
{
$suite_names = ['Unit', 'Kernel', 'Functional', 'Build', 'FunctionalJavascript'];
$dir = $this->drupalRoot . '/' . $extension->getPath();
$test_dir = $dir . '/tests/src';
if (is_dir($test_dir)) {
foreach ($suite_names as $suite_name) {
$suite_dir = $test_dir . '/' . $suite_name;
if (is_dir($suite_dir)) {
// Register the PSR-4 directory for PHPUnit-based suites.
$this->namespaces['Drupal\\Tests\\' . $extension->getName() . '\\' . $suite_name . '\\'][] = $suite_dir;
}
}
// Extensions can have a \Drupal\Tests\extension\Traits namespace for
// cross-suite trait code.
$trait_dir = $test_dir . '/Traits';
if (is_dir($trait_dir)) {
$this->namespaces['Drupal\\Tests\\' . $extension->getName() . '\\Traits\\'][] = $trait_dir;
}
}
}
protected function registerPs4Namespaces(array $namespaces): void
{
foreach ($namespaces as $prefix => $paths) {
if (is_array($paths)) {
foreach ($paths as $key => $value) {
$paths[$key] = $value;
}
}
$this->autoloader->addPsr4($prefix . '\\', $paths);
}
}
protected function loadExtension(Extension $extension): void
{
try {
$extension->load();
} catch (Throwable $e) {
// Something prevented the extension file from loading.
// This can happen when drupal_get_path or drupal_get_filename are used outside of the scope of a function.
}
}
protected function loadAndCatchErrors(string $path): void
{
try {
require_once $path;
} catch (ContainerNotInitializedException $e) {
$path = str_replace(dirname($this->drupalRoot) . '/', '', $path);
// This can happen when drupal_get_path or drupal_get_filename are used outside the scope of a function.
@trigger_error("$path invoked the Drupal container outside of the scope of a function or class method. It was not loaded.", E_USER_WARNING);
} catch (Throwable $e) {
$path = str_replace(dirname($this->drupalRoot) . '/', '', $path);
// Something prevented the extension file from loading.
@trigger_error("$path failed loading due to {$e->getMessage()}", E_USER_WARNING);
}
}
protected function camelize(string $id): string
{
return strtr(ucwords(strtr($id, ['_' => ' ', '.' => '_ ', '\\' => '_ '])), [' ' => '']);
}
}

View File

@@ -0,0 +1,144 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Drupal;
use PHPStan\Type\ObjectType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use function count;
use function str_replace;
class DrupalServiceDefinition
{
/**
* @var string
*/
private $id;
/**
* @var string|null
*/
private $class;
/**
* @var bool
*/
private $public;
/**
* @var bool
*/
private $deprecated = false;
/**
* @var string|null
*/
private $deprecationTemplate;
/**
* @var string
*/
private static $defaultDeprecationTemplate = 'The "%service_id%" service is deprecated. You should stop using it, as it will soon be removed.';
/**
* @var string|null
*/
private $alias;
/**
* @var array<string, \mglaman\PHPStanDrupal\Drupal\DrupalServiceDefinition>
*/
private $decorators = [];
public function __construct(string $id, ?string $class, bool $public = true, ?string $alias = null)
{
$this->id = $id;
$this->class = $class;
$this->public = $public;
$this->alias = $alias;
}
public function setDeprecated(bool $status = true, ?string $template = null): void
{
$this->deprecated = $status;
$this->deprecationTemplate = $template;
}
/**
* @return string
*/
public function getId(): string
{
return $this->id;
}
/**
* @return string|null
*/
public function getClass(): ?string
{
return $this->class;
}
/**
* @return bool
*/
public function isPublic(): bool
{
return $this->public;
}
/**
* @return string|null
*/
public function getAlias(): ?string
{
return $this->alias;
}
public function isDeprecated(): bool
{
return $this->deprecated;
}
public function getDeprecatedDescription(): string
{
return str_replace('%service_id%', $this->id, $this->deprecationTemplate ?? self::$defaultDeprecationTemplate);
}
public function getType(): Type
{
// Work around Drupal misusing the SplString class for string
// pseudo-services such as 'app.root'.
// @see https://www.drupal.org/project/drupal/issues/3074585
if ($this->getClass() === 'SplString') {
return new StringType();
}
$decorating_services = $this->getDecorators();
if (count($decorating_services) !== 0) {
$combined_services = [];
$combined_services[] = new ObjectType($this->getClass() ?? $this->id);
foreach ($decorating_services as $service_id => $service_definition) {
$combined_services[] = $service_definition->getType();
}
return TypeCombinator::union(...$combined_services);
}
return new ObjectType($this->getClass() ?? $this->id);
}
public function addDecorator(DrupalServiceDefinition $definition): void
{
$this->decorators[$definition->getId()] = $definition;
}
/**
* @return array<string, \mglaman\PHPStanDrupal\Drupal\DrupalServiceDefinition>
*/
public function getDecorators(): array
{
return $this->decorators;
}
}

View File

@@ -0,0 +1,19 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Drupal;
use PHPStan\PhpDoc\StubFilesExtension;
use Symfony\Component\Finder\Finder;
final class DrupalStubFilesExtension implements StubFilesExtension
{
public function getFiles(): array
{
$files = [];
$finder = Finder::create()->files()->name('*.stub')->in(__DIR__ . '/../../stubs');
foreach ($finder as $file) {
$files[] = $file->getPathname();
}
return $files;
}
}

View File

@@ -0,0 +1,73 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Drupal;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Config\Entity\ConfigEntityStorageInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\ContentEntityStorageInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use mglaman\PHPStanDrupal\Type\EntityStorage\ConfigEntityStorageType;
use mglaman\PHPStanDrupal\Type\EntityStorage\ContentEntityStorageType;
use mglaman\PHPStanDrupal\Type\EntityStorage\EntityStorageType;
use PHPStan\Type\ObjectType;
final class EntityData
{
/**
* @var string
*/
private $entityTypeId;
/**
* @var string|null
*/
private $className;
/**
* @var string|null
*/
private $storageClassName;
public function __construct(string $entityTypeId, array $definition)
{
$this->entityTypeId = $entityTypeId;
$this->className = $definition['class'] ?? null;
$this->storageClassName = $definition['storage'] ?? null;
}
public function getClassType(): ?ObjectType
{
return $this->className === null ? null : new ObjectType($this->className);
}
public function getStorageType(): ?EntityStorageType
{
if ($this->storageClassName === null) {
$classType = $this->getClassType();
if ($classType === null) {
return null;
}
if ((new ObjectType(ConfigEntityInterface::class))->isSuperTypeOf($classType)->yes()) {
$this->storageClassName = 'Drupal\Core\Config\Entity\ConfigEntityStorage';
} elseif ((new ObjectType(ContentEntityInterface::class))->isSuperTypeOf($classType)->yes()) {
$this->storageClassName = 'Drupal\Core\Entity\Sql\SqlContentEntityStorage';
} else {
return null;
}
}
$storageType = new ObjectType($this->storageClassName);
if ((new ObjectType(EntityStorageInterface::class))->isSuperTypeOf($storageType)->no()) {
return null;
}
if ((new ObjectType(ConfigEntityStorageInterface::class))->isSuperTypeOf($storageType)->yes()) {
return new ConfigEntityStorageType($this->entityTypeId, $this->storageClassName);
}
if ((new ObjectType(ContentEntityStorageInterface::class))->isSuperTypeOf($storageType)->yes()) {
return new ContentEntityStorageType($this->entityTypeId, $this->storageClassName);
}
return new EntityStorageType($this->entityTypeId, $this->storageClassName);
}
}

View File

@@ -0,0 +1,57 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Drupal;
use Drupal\Core\Config\Entity\ConfigEntityStorageInterface;
use Drupal\Core\Entity\ContentEntityStorageInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use PHPStan\Type\ObjectType;
final class EntityDataRepository
{
/**
* @var array<string, EntityData>
*/
private $entityData;
public function __construct(array $entityMapping)
{
foreach ($entityMapping as $entityTypeId => $entityData) {
$this->entityData[$entityTypeId] = new EntityData(
$entityTypeId,
$entityData
);
}
}
public function get(string $entityTypeId): EntityData
{
if (!isset($this->entityData[$entityTypeId])) {
$this->entityData[$entityTypeId] = new EntityData(
$entityTypeId,
[]
);
}
return $this->entityData[$entityTypeId];
}
public function resolveFromStorage(ObjectType $callerType): ?EntityData
{
if ($callerType->equals(new ObjectType(EntityStorageInterface::class))) {
return null;
}
if ($callerType->equals(new ObjectType(ConfigEntityStorageInterface::class))) {
return null;
}
if ($callerType->equals(new ObjectType(ContentEntityStorageInterface::class))) {
return null;
}
foreach ($this->entityData as $entityData) {
$storageType = $entityData->getStorageType();
if ($storageType !== null && $callerType->isSuperTypeOf($storageType)->yes()) {
return $entityData;
}
}
return null;
}
}

View File

@@ -0,0 +1,239 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Drupal;
use RuntimeException;
use Symfony\Component\Yaml\Yaml;
use function explode;
use function file_get_contents;
use function is_array;
use function sprintf;
use function strpos;
use function trim;
/**
* Defines an extension (file) object.
*
* Bundled version of \Drupal\Core\Extension\Extension.
*/
class Extension
{
/**
* The type of the extension (e.g., 'module').
*
* @var string
*/
protected $type;
/**
* The relative pathname of the extension (e.g.,
* 'core/modules/node/node.info.yml').
*
* @var string
*/
protected $pathname;
/**
* The filename of the main extension file (e.g., 'node.module').
*
* @var string|null
*/
protected $filename;
/**
* An SplFileInfo instance for the extension's info file.
*
* Note that SplFileInfo is a PHP resource and resources cannot be serialized.
*
* @var ?\SplFileInfo
*/
protected $splFileInfo;
/**
* The app root.
*
* @var string
*/
protected $root;
/**
* @var string
*/
public $subpath = '';
/**
* @var string
*/
public $origin = '';
/**
* @var array|null
*/
private $info;
/**
* @var string[]|null
*/
private $dependencies;
/**
* Constructs a new Extension object.
*
* @param string $root
* The app root.
* @param string $type
* The type of the extension; e.g., 'module'.
* @param string $pathname
* The relative path and filename of the extension's info file; e.g.,
* 'core/modules/node/node.info.yml'.
* @param string $filename
* (optional) The filename of the main extension file; e.g., 'node.module'.
*/
public function __construct($root, $type, $pathname, $filename = null)
{
$this->root = $root;
$this->type = $type;
$this->pathname = $pathname;
$this->filename = $filename;
}
/**
* Returns the type of the extension.
*
* @return string
*/
public function getType(): string
{
return $this->type;
}
/**
* Returns the internal name of the extension.
*
* @return string
*/
public function getName(): string
{
return basename($this->pathname, '.info.yml');
}
/**
* Returns the relative path of the extension.
*
* @return string
*/
public function getPath(): string
{
return dirname($this->pathname);
}
public function getAbsolutePath(): string
{
return $this->root . DIRECTORY_SEPARATOR . $this->getPath();
}
/**
* Returns the relative path and filename of the extension's info file.
*
* @return string
*/
public function getPathname(): string
{
return $this->pathname;
}
/**
* Returns the filename of the extension's info file.
*
* @return string
*/
public function getFilename(): string
{
return basename($this->pathname);
}
/**
* Returns the relative path of the main extension file, if any.
*
* @return string|null
*/
public function getExtensionPathname(): ?string
{
if ($this->filename !== null) {
return $this->getPath() . '/' . $this->filename;
}
return null;
}
/**
* Returns the name of the main extension file, if any.
*
* @return string|null
*/
public function getExtensionFilename(): ?string
{
return $this->filename;
}
/**
* Loads the main extension file, if any.
*
* @return bool
* TRUE if this extension has a main extension file, FALSE otherwise.
*/
public function load(): bool
{
if ($this->filename !== null) {
include_once $this->root . '/' . $this->getPath() . '/' . $this->filename;
return true;
}
return false;
}
/**
* @return string[]
*/
public function getDependencies(): array
{
if (is_array($this->dependencies)) {
return $this->dependencies;
}
$info = $this->parseInfo();
$dependencies = $info['dependencies'] ?? [];
if ($dependencies === []) {
return $this->dependencies = $dependencies;
}
$this->dependencies = [];
// @see \Drupal\Core\Extension\Dependency::createFromString().
foreach ($dependencies as $dependency) {
if (strpos($dependency, ':') !== false) {
[, $dependency] = explode(':', $dependency);
}
$parts = explode('(', $dependency, 2);
$this->dependencies[] = trim($parts[0]);
}
return $this->dependencies;
}
private function parseInfo(): array
{
if (is_array($this->info)) {
return $this->info;
}
$infoContent = file_get_contents(sprintf('%s/%s', $this->root, $this->getPathname()));
if (false === $infoContent) {
throw new RuntimeException(sprintf('Cannot read "%s', $this->getPathname()));
}
return $this->info = Yaml::parse($infoContent);
}
}

View File

@@ -0,0 +1,427 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Drupal;
use FilesystemIterator;
use mglaman\PHPStanDrupal\Drupal\Extension;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use function array_filter;
use function array_flip;
use function array_multisort;
use function dirname;
use function file_exists;
use function is_dir;
use function preg_match;
use function strpos;
class ExtensionDiscovery
{
/**
* Origin directory weight: Core.
*/
private const ORIGIN_CORE = 0;
/**
* Origin directory weight: Installation profile.
*/
private const ORIGIN_PROFILE = 1;
/**
* Origin directory weight: sites/all.
*/
private const ORIGIN_SITES_ALL = 2;
/**
* Origin directory weight: Site-wide directory.
*/
private const ORIGIN_ROOT = 3;
/**
* Origin directory weight: Site-specific directory.
*/
private const ORIGIN_SITE = 5;
/**
* Regular expression to match PHP function names.
*
* @see http://php.net/manual/functions.user-defined.php
*/
private const PHP_FUNCTION_PATTERN = '/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/';
/**
* Previously discovered files keyed by origin directory and extension type.
*
* @var array
*/
protected static $files = [];
/**
* List of installation profile directories to additionally scan.
*
* @var array
*/
protected $profileDirectories;
/**
* The app root for the current operation.
*
* @var string
*/
protected $root;
/**
* The site path.
*
* @var string
*/
protected $sitePath;
/**
* Constructs a new ExtensionDiscovery object.
*
* @param string $root
* The app root.
*/
public function __construct($root)
{
$this->root = $root;
$this->profileDirectories = [
$root . '/core/profiles/standard'
];
$this->sitePath = 'sites/default';
}
/**
* Discovers available extensions of a given type.
*
* Finds all extensions (modules, themes, etc) that exist on the site. It
* searches in several locations. For instance, to discover all available
* modules:
* @code
* $listing = new ExtensionDiscovery(\Drupal::root());
* $modules = $listing->scan('module');
* @endcode
*
* The following directories will be searched (in the order stated):
* - the core directory; i.e., /core
* - the installation profile directory; e.g., /core/profiles/standard
* - the legacy site-wide directory; i.e., /sites/all
* - the site-wide directory; i.e., /
* - the site-specific directory; e.g., /sites/example.com
*
* To also find test modules, add
* @code
* $settings['extension_discovery_scan_tests'] = TRUE;
* @endcode
* to your settings.php.
*
* The information is returned in an associative array, keyed by the extension
* name (without .info.yml extension). Extensions found later in the search
* will take precedence over extensions found earlier - unless they are not
* compatible with the current version of Drupal core.
*
* @param string $type
* The extension type to search for. One of 'profile', 'module', 'theme', or
* 'theme_engine'.
*
* @return \mglaman\PHPStanDrupal\Drupal\Extension[]
* An associative array of Extension objects, keyed by extension name.
*/
public function scan($type)
{
static $scanresult;
if (!$scanresult) {
$scanresult = [];
}
if (isset($scanresult[$type])) {
return $scanresult[$type];
}
$searchdirs = [];
// Search the core directory.
$searchdirs[self::ORIGIN_CORE] = 'core';
// Search the legacy sites/all directory.
$searchdirs[self::ORIGIN_SITES_ALL] = 'sites/all';
// Search for contributed and custom extensions in top-level directories.
// The scan uses a whitelist to limit recursion to the expected extension
// type specific directory names only.
$searchdirs[self::ORIGIN_ROOT] = '';
$searchdirs[self::ORIGIN_SITE] = $this->sitePath;
$files = [];
foreach ($searchdirs as $dir) {
// Discover all extensions in the directory, unless we did already.
if (!isset(static::$files[$this->root][$dir])) {
static::$files[$this->root][$dir] = $this->scanDirectory($dir);
}
// Only return extensions of the requested type.
if (isset(static::$files[$this->root][$dir][$type])) {
$files += static::$files[$this->root][$dir][$type];
}
}
// If applicable, filter out extensions that do not belong to the current
// installation profiles.
$files = $this->filterByProfileDirectories($files);
// Sort the discovered extensions by their originating directories.
$origin_weights = array_flip($searchdirs);
$files = $this->sort($files, $origin_weights);
// Process and return the list of extensions keyed by extension name.
$scanresult[$type] = $this->process($files);
return $scanresult[$type];
}
/**
* Gets the installation profile directories to be scanned.
*
* @return array
* A list of installation profile directory paths relative to the system
* root directory.
*/
public function getProfileDirectories()
{
return $this->profileDirectories;
}
/**
* Sets explicit profile directories to scan.
*
* @param array $paths
* A list of installation profile directory paths relative to the system
* root directory (without trailing slash) to search for extensions.
*
* @return $this
*/
public function setProfileDirectories(array $paths = [])
{
$this->profileDirectories = $paths;
return $this;
}
/**
* Filters out extensions not belonging to the scanned installation profiles.
*
* @param \mglaman\PHPStanDrupal\Drupal\Extension[] $all_files
* The list of all extensions.
*
* @return \mglaman\PHPStanDrupal\Drupal\Extension[]
* The filtered list of extensions.
*/
protected function filterByProfileDirectories(array $all_files)
{
if ($this->profileDirectories === []) {
return $all_files;
}
return array_filter($all_files, function (Extension $file) : bool {
if (strpos($file->subpath, 'profiles') !== 0) {
// This extension doesn't belong to a profile, ignore it.
return true;
}
foreach ($this->profileDirectories as $weight => $profile_path) {
if (strpos($file->getPath(), $profile_path) === 0) {
// Parent profile found.
return true;
}
}
return false;
});
}
/**
* Sorts the discovered extensions.
*
* @param \mglaman\PHPStanDrupal\Drupal\Extension[] $all_files
* The list of all extensions.
* @param array $weights
* An array of weights, keyed by originating directory.
*
* @return \mglaman\PHPStanDrupal\Drupal\Extension[]
* The sorted list of extensions.
*/
protected function sort(array $all_files, array $weights)
{
$origins = [];
$profiles = [];
foreach ($all_files as $key => $file) {
// If the extension does not belong to a profile, just apply the weight
// of the originating directory.
if (strpos($file->subpath, 'profiles') !== 0) {
$origins[$key] = $weights[$file->origin];
$profiles[$key] = null;
} elseif ($this->profileDirectories === []) {
// If the extension belongs to a profile but no profile directories are
// defined, then we are scanning for installation profiles themselves.
// In this case, profiles are sorted by origin only.
$origins[$key] = self::ORIGIN_PROFILE;
$profiles[$key] = null;
} else {
// Apply the weight of the originating profile directory.
foreach ($this->profileDirectories as $weight => $profile_path) {
if (strpos($file->getPath(), $profile_path) === 0) {
$origins[$key] = self::ORIGIN_PROFILE;
$profiles[$key] = $weight;
continue 2;
}
}
}
}
// Now sort the extensions by origin and installation profile(s).
// The result of this multisort can be depicted like the following matrix,
// whereas the first integer is the weight of the originating directory and
// the second is the weight of the originating installation profile:
// 0 core/modules/node/node.module
// 1 0 profiles/parent_profile/modules/parent_module/parent_module.module
// 1 1 core/profiles/testing/modules/compatible_test/compatible_test.module
// 2 sites/all/modules/common/common.module
// 3 modules/devel/devel.module
// 4 sites/default/modules/custom/custom.module
array_multisort($origins, SORT_ASC, $profiles, SORT_ASC, $all_files);
return $all_files;
}
/**
* Processes the filtered and sorted list of extensions.
*
* Extensions discovered in later search paths override earlier, unless they
* are not compatible with the current version of Drupal core.
*
* @param \mglaman\PHPStanDrupal\Drupal\Extension[] $all_files
* The sorted list of all extensions that were found.
*
* @return \mglaman\PHPStanDrupal\Drupal\Extension[]
* The filtered list of extensions, keyed by extension name.
*/
protected function process(array $all_files)
{
$files = [];
// Duplicate files found in later search directories take precedence over
// earlier ones; they replace the extension in the existing $files array.
foreach ($all_files as $file) {
$files[$file->getName()] = $file;
}
return $files;
}
/**
* Recursively scans a base directory for the extensions it contains.
*
* @param string $dir
* A relative base directory path to scan, without trailing slash.
*
* @return array
* An associative array whose keys are extension type names and whose values
* are associative arrays of \Drupal\Core\Extension\Extension objects, keyed
* by absolute path name.
*
* @see \mglaman\PHPStanDrupal\Drupal\RecursiveExtensionFilterIterator
*/
protected function scanDirectory($dir): array
{
$files = [];
// In order to scan top-level directories, absolute directory paths have to
// be used (which also improves performance, since any configured PHP
// include_paths will not be consulted). Retain the relative originating
// directory being scanned, so relative paths can be reconstructed below
// (all paths are expected to be relative to $this->root).
$dir_prefix = ($dir === '' ? '' : "$dir/");
$absolute_dir = ($dir === '' ? $this->root : $this->root . "/$dir");
if (!is_dir($absolute_dir)) {
return $files;
}
// Use Unix paths regardless of platform, skip dot directories, follow
// symlinks (to allow extensions to be linked from elsewhere), and return
// the RecursiveDirectoryIterator instance to have access to getSubPath(),
// since SplFileInfo does not support relative paths.
$flags = FilesystemIterator::UNIX_PATHS;
$flags |= FilesystemIterator::SKIP_DOTS;
$flags |= FilesystemIterator::FOLLOW_SYMLINKS;
$flags |= FilesystemIterator::CURRENT_AS_SELF;
$directory_iterator = new RecursiveDirectoryIterator($absolute_dir, $flags);
// Allow directories specified in settings.php to be ignored. You can use
// this to not check for files in common special-purpose directories. For
// example, node_modules and bower_components. Ignoring irrelevant
// directories is a performance boost.
$ignore_directories = ['node_modules', 'bower_components'];
// Filter the recursive scan to discover extensions only.
// Important: Without a RecursiveFilterIterator, RecursiveDirectoryIterator
// would recurse into the entire filesystem directory tree without any kind
// of limitations.
$filter = new RecursiveExtensionFilterIterator($directory_iterator, $ignore_directories);
// The actual recursive filesystem scan is only invoked by instantiating the
// RecursiveIteratorIterator.
$iterator = new RecursiveIteratorIterator(
$filter,
RecursiveIteratorIterator::LEAVES_ONLY,
// Suppress filesystem errors in case a directory cannot be accessed.
RecursiveIteratorIterator::CATCH_GET_CHILD
);
foreach ($iterator as $key => $fileinfo) {
// All extension names in Drupal have to be valid PHP function names due
// to the module hook architecture.
if (preg_match(self::PHP_FUNCTION_PATTERN, $fileinfo->getBasename('.info.yml')) !== 1) {
continue;
}
// This test module has a function declaration that conflicts with another module. Explicitly skip it.
// @see https://www.drupal.org/project/drupal/issues/3020142
// @todo remove when Drupal core fixed.
if ($fileinfo->getBasename('.info.yml') === 'no_transitions_css') {
continue;
}
// Determine extension type from info file.
$type = false;
$file = $fileinfo->openFile('r');
while ($type === false && !$file->eof()) {
if ($line = $file->fgets()) {
preg_match('@^type:\s*(\'|")?(\w+)\1?\s*$@', $line, $matches);
if (isset($matches[2])) {
$type = $matches[2];
}
}
}
if ($type === false) {
continue;
}
$name = $fileinfo->getBasename('.info.yml');
$pathname = $dir_prefix . $fileinfo->getSubPathname();
// Determine whether the extension has a main extension file.
// For theme engines, the file extension is .engine.
if ($type === 'theme_engine') {
$filename = $name . '.engine';
} else {
$filename = $name . '.' . $type;
}
if (!file_exists($this->root . '/' . dirname($pathname) . '/' . $filename)) {
$filename = null;
}
$extension = new Extension($this->root, $type, $pathname, $filename);
// Track the originating directory for sorting purposes.
$extension->subpath = $fileinfo->getSubPath();
$extension->origin = $dir;
$files[$type][$key] = $extension;
}
return $files;
}
}

View File

@@ -0,0 +1,87 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Drupal;
use function array_combine;
use function array_map;
use function is_array;
final class ExtensionMap
{
/** @var array<string, Extension> */
private static $modules = [];
/** @var array<string, Extension> */
private static $themes = [];
/** @var array<string, Extension> */
private static $profiles = [];
/**
* @return Extension[]
*/
public function getModules(): array
{
return self::$modules;
}
public function getModule(string $name): ?Extension
{
return self::$modules[$name] ?? null;
}
/**
* @return Extension[]
*/
public function getThemes(): array
{
return self::$themes;
}
public function getTheme(string $name): ?Extension
{
return self::$themes[$name] ?? null;
}
/**
* @return Extension[]
*/
public function getProfiles(): array
{
return self::$profiles;
}
public function getProfile(string $name): ?Extension
{
return self::$profiles[$name] ?? null;
}
/**
* @param array<int, Extension> $modules
* @param array<int, Extension> $themes
* @param array<int, Extension> $profiles
*/
public function setExtensions(array $modules, array $themes, array $profiles): void
{
self::$modules = self::keyByExtensionName($modules);
self::$themes = self::keyByExtensionName($themes);
self::$profiles = self::keyByExtensionName($profiles);
}
/**
* @param array<int, Extension> $extensions
* @return array<string, Extension>
*/
private static function keyByExtensionName(array $extensions): array
{
// PHP 7.4 returns array|false, PHP 8.0 only returns an array.
// Make PHPStan happy. When PHP 7.4 is dropped, reduce to a single
// return.
$combined = array_combine(array_map(static function (Extension $extension) {
return $extension->getName();
}, $extensions), $extensions);
// @phpstan-ignore-next-line
assert(is_array($combined));
return $combined;
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace mglaman\PHPStanDrupal\Drupal;
use RecursiveFilterIterator;
use RecursiveIterator;
use function array_merge;
use function in_array;
use function substr;
/**
* Filters a RecursiveDirectoryIterator to discover extensions.
*
* Locally bundled version of \Drupal\Core\Extension\Discovery\RecursiveExtensionFilterIterator.
*
* @method bool isDir()
*/
class RecursiveExtensionFilterIterator extends RecursiveFilterIterator
{
/**
* List of base extension type directory names to scan.
*
* Only these directory names are considered when starting a filesystem
* recursion in a search path.
*
* @var array
*/
protected $whitelist = [
'profiles',
'modules',
'themes',
];
/**
* List of directory names to skip when recursing.
*
* These directories are globally ignored in the recursive filesystem scan;
* i.e., extensions (of all types) are not able to use any of these names,
* because their directory names will be skipped.
*
* @var array
*/
protected $blacklist = [
// Object-oriented code subdirectories.
'src',
'lib',
'vendor',
// Front-end.
'assets',
'css',
'files',
'images',
'js',
'misc',
'templates',
// Legacy subdirectories.
'includes',
// Test subdirectories.
'fixtures',
// @todo ./tests/Drupal should be ./tests/src/Drupal
'Drupal',
];
/**
* Construct a RecursiveExtensionFilterIterator.
*
* @param \RecursiveIterator $iterator
* The iterator to filter.
* @param array $blacklist
* (optional) Add to the blacklist of directories that should be filtered
* out during the iteration.
*/
public function __construct(RecursiveIterator $iterator, array $blacklist = [])
{
parent::__construct($iterator);
$this->blacklist = array_merge($this->blacklist, $blacklist);
}
/**
* {@inheritdoc}
*/
public function getChildren(): RecursiveFilterIterator
{
$filter = parent::getChildren();
if ($filter instanceof self) {
// Pass on the blacklist.
$filter->blacklist = $this->blacklist;
}
return $filter;
}
/**
* {@inheritdoc}
*/
public function accept(): bool
{
$name = $this->current()->getFilename();
// FilesystemIterator::SKIP_DOTS only skips '.' and '..', but not hidden
// directories (like '.git').
if ($name[0] === '.') {
return false;
}
if ($this->isDir()) {
// If this is a subdirectory of a base search path, only recurse into the
// fixed list of expected extension type directory names. Required for
// scanning the top-level/root directory; without this condition, we would
// recurse into the whole filesystem tree that possibly contains other
// files aside from Drupal.
if ($this->current()->getSubPath() === '') {
return in_array($name, $this->whitelist, true);
}
// 'config' directories are special-cased here, because every extension
// contains one. However, those default configuration directories cannot
// contain extensions. The directory name cannot be globally skipped,
// because core happens to have a directory of an actual module that is
// named 'config'. By explicitly testing for that case, we can skip all
// other config directories, and at the same time, still allow the core
// config module to be overridden/replaced in a profile/site directory
// (whereas it must be located directly in a modules directory).
if ($name === 'config') {
return substr($this->current()->getPathname(), -14) === 'modules/config';
}
// Accept the directory unless the name is blacklisted.
return !in_array($name, $this->blacklist, true);
}
// Only accept extension info files.
return substr($name, -9) === '.info.yml';
}
}

View File

@@ -0,0 +1,104 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Drupal;
use function class_exists;
class ServiceMap
{
/** @var DrupalServiceDefinition[] */
private static $services = [];
public function getService(string $id): ?DrupalServiceDefinition
{
return self::$services[$id] ?? null;
}
/**
* @return DrupalServiceDefinition[]
*/
public function getServices(): array
{
return self::$services;
}
public function setDrupalServices(array $drupalServices): void
{
self::$services = [];
$decorators = [];
foreach ($drupalServices as $serviceId => $serviceDefinition) {
if (isset($serviceDefinition['alias'], $drupalServices[$serviceDefinition['alias']])) {
$serviceDefinition = $drupalServices[$serviceDefinition['alias']];
}
if (isset($serviceDefinition['parent'], $drupalServices[$serviceDefinition['parent']])) {
$serviceDefinition = $this->resolveParentDefinition($serviceDefinition['parent'], $serviceDefinition, $drupalServices);
}
if (isset($serviceDefinition['decorates'])) {
$decorators[$serviceDefinition['decorates']][] = $serviceId;
}
// @todo support factories
if (!isset($serviceDefinition['class'])) {
if (class_exists($serviceId)) {
$serviceDefinition['class'] = $serviceId;
} else {
continue;
}
}
self::$services[$serviceId] = new DrupalServiceDefinition(
(string) $serviceId,
$serviceDefinition['class'],
$serviceDefinition['public'] ?? true,
$serviceDefinition['alias'] ?? null
);
$deprecated = $serviceDefinition['deprecated'] ?? null;
if ($deprecated) {
if (is_array($deprecated) && isset($deprecated['message'])) {
$deprecated = $deprecated['message'];
}
$deprecated = str_replace('%service_id%', $serviceId, $deprecated);
if (isset($serviceDefinition['alias'])) {
$deprecated = str_replace('%alias_id%', $serviceDefinition['alias'], $deprecated);
}
self::$services[$serviceId]->setDeprecated(true, $deprecated);
}
}
foreach ($decorators as $decorated_service_id => $services) {
foreach ($services as $dcorating_service_id) {
if (!isset(self::$services[$decorated_service_id])) {
continue;
}
self::$services[$decorated_service_id]->addDecorator(self::$services[$dcorating_service_id]);
}
}
}
private function resolveParentDefinition(string $parentId, array $serviceDefinition, array $drupalServices): array
{
$parentDefinition = $drupalServices[$parentId] ?? [];
if ([] === $parentDefinition) {
return $serviceDefinition;
}
if (isset($parentDefinition['parent'])) {
if (!isset($drupalServices[$parentDefinition['parent']])) {
return $serviceDefinition;
}
$parentDefinition = $this->resolveParentDefinition($parentDefinition['parent'], $drupalServices[$parentDefinition['parent']], $drupalServices);
}
if (isset($parentDefinition['class']) && !isset($serviceDefinition['class'])) {
$serviceDefinition['class'] = $parentDefinition['class'];
}
if (isset($parentDefinition['public']) && !isset($serviceDefinition['public'])) {
$serviceDefinition['public'] = $parentDefinition['public'];
}
return $serviceDefinition;
}
}

View File

@@ -0,0 +1,22 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Internal;
use PHPStan\Analyser\Scope;
final class DeprecatedScopeCheck
{
public static function inDeprecatedScope(Scope $scope): bool
{
$class = $scope->getClassReflection();
if ($class !== null && $class->isDeprecated()) {
return true;
}
$trait = $scope->getTraitReflection();
if ($trait !== null && $trait->isDeprecated()) {
return true;
}
$function = $scope->getFunction();
return $function !== null && $function->isDeprecated()->yes();
}
}

View File

@@ -0,0 +1,37 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Internal;
use PhpParser\Node\Stmt\Class_;
/**
* @internal
*/
final class NamespaceCheck
{
public static function isDrupalNamespace(Class_ $class): bool
{
if (!isset($class->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::isDrupalNamespace($class)) {
return false;
}
return (string) $class->namespacedName->slice(0, 2) === (string) $class->extends->slice(0, 2);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace mglaman\PHPStanDrupal\Reflection;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\MethodsClassReflectionExtension;
use PHPStan\Type\ObjectType;
use function array_key_exists;
/**
* Allows some common methods on fields.
*/
class EntityFieldMethodsViaMagicReflectionExtension implements MethodsClassReflectionExtension
{
public function hasMethod(ClassReflection $classReflection, string $methodName): bool
{
if ($classReflection->hasNativeMethod($methodName) || array_key_exists($methodName, $classReflection->getMethodTags())) {
// Let other parts of PHPStan handle this.
return false;
}
$interfaceObject = new ObjectType('Drupal\Core\Field\FieldItemListInterface');
$objectType = new ObjectType($classReflection->getName());
if (!$interfaceObject->isSuperTypeOf($objectType)->yes()) {
return false;
}
if ($methodName === 'referencedEntities') {
return true;
}
return false;
}
public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection
{
if ($methodName === 'referencedEntities') {
$entityReferenceFieldItemListInterfaceType = new ObjectType('Drupal\Core\Field\EntityReferenceFieldItemListInterface');
$classReflection = $entityReferenceFieldItemListInterfaceType->getClassReflection();
assert($classReflection !== null);
}
return new FieldItemListMethodReflection(
$classReflection,
$methodName
);
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace mglaman\PHPStanDrupal\Reflection;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\PropertyReflection;
use PHPStan\TrinaryLogic;
use PHPStan\Type\MixedType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
/**
* Allows field access via magic methods
*
* See \Drupal\Core\Entity\ContentEntityBase::__get and ::__set.
*/
class EntityFieldReflection implements PropertyReflection
{
/** @var ClassReflection */
private $declaringClass;
/** @var string */
private $propertyName;
public function __construct(ClassReflection $declaringClass, string $propertyName)
{
$this->declaringClass = $declaringClass;
$this->propertyName = $propertyName;
}
public function getReadableType(): Type
{
if ($this->propertyName === 'original') {
if ($this->declaringClass->isSubclassOf('Drupal\Core\Entity\ContentEntityInterface')) {
$objectType = 'Drupal\Core\Entity\ContentEntityInterface';
} elseif ($this->declaringClass->isSubclassOf('Drupal\Core\Config\Entity\ConfigEntityInterface')) {
$objectType = 'Drupal\Core\Config\Entity\ConfigEntityInterface';
} else {
$objectType = 'Drupal\Core\Entity\EntityInterface';
}
return new ObjectType($objectType);
}
if ($this->declaringClass->isSubclassOf('Drupal\Core\Entity\ContentEntityInterface')) {
// Assume the property is a field.
return new ObjectType('Drupal\Core\Field\FieldItemListInterface');
}
return new MixedType();
}
public function getWritableType(): Type
{
if ($this->propertyName === 'original') {
if ($this->declaringClass->isSubclassOf('Drupal\Core\Entity\ContentEntityInterface')) {
$objectType = 'Drupal\Core\Entity\ContentEntityInterface';
} elseif ($this->declaringClass->isSubclassOf('Drupal\Core\Config\Entity\ConfigEntityInterface')) {
$objectType = 'Drupal\Core\Config\Entity\ConfigEntityInterface';
} else {
$objectType = 'Drupal\Core\Entity\EntityInterface';
}
return new ObjectType($objectType);
}
// @todo Drupal allows $entity->field_myfield = 'string'; does this break that?
if ($this->declaringClass->isSubclassOf('Drupal\Core\Entity\ContentEntityInterface')) {
// Assume the property is a field.
return new ObjectType('Drupal\Core\Field\FieldItemListInterface');
}
return new MixedType();
}
public function canChangeTypeAfterAssignment(): bool
{
return true;
}
public function getDeclaringClass(): ClassReflection
{
return $this->declaringClass;
}
public function isStatic(): bool
{
return false;
}
public function isPrivate(): bool
{
return false;
}
public function isPublic(): bool
{
return true;
}
public function isReadable(): bool
{
return true;
}
public function isWritable(): bool
{
return true;
}
public function getDeprecatedDescription(): ?string
{
return null;
}
public function getDocComment(): ?string
{
return null;
}
public function isDeprecated(): TrinaryLogic
{
return TrinaryLogic::createNo();
}
public function isInternal(): TrinaryLogic
{
return TrinaryLogic::createNo();
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace mglaman\PHPStanDrupal\Reflection;
use LogicException;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\PropertiesClassReflectionExtension;
use PHPStan\Reflection\PropertyReflection;
use PHPStan\TrinaryLogic;
use PHPStan\Type\ObjectType;
use function array_key_exists;
/**
* Allows field access via magic methods
*
* See \Drupal\Core\Entity\ContentEntityBase::__get and ::__set.
*
* @todo split into Entity and FieldItem specifics.
*/
class EntityFieldsViaMagicReflectionExtension implements PropertiesClassReflectionExtension
{
public function hasProperty(ClassReflection $classReflection, string $propertyName): bool
{
// @todo Have this run after PHPStan\Reflection\Annotations\AnnotationsPropertiesClassReflectionExtension
// We should not have to check for the property tags if we could get this to run after PHPStan's
// existing annotation property reflection.
if ($classReflection->hasNativeProperty($propertyName) || array_key_exists($propertyName, $classReflection->getPropertyTags())) {
// Let other parts of PHPStan handle this.
return false;
}
foreach ($classReflection->getAncestors() as $ancestor) {
if (array_key_exists($propertyName, $ancestor->getPropertyTags())) {
return false;
}
}
// We need to find a way to parse the entity annotation so that at the minimum the `entity_keys` are
// supported. The real fix is Drupal developers _really_ need to start writing @property definitions in the
// class doc if they don't get `get` methods.
if ($classReflection->implementsInterface('Drupal\Core\Entity\ContentEntityInterface')) {
// @todo revisit if it's a good idea to be true.
// Content entities have magical __get... so it is kind of true.
return true;
}
if (self::classObjectIsSuperOfInterface($classReflection->getName(), self::getFieldItemListInterfaceObject())->yes()) {
return FieldItemListPropertyReflection::canHandleProperty($classReflection, $propertyName);
}
return false;
}
public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection
{
if ($classReflection->implementsInterface('Drupal\Core\Entity\EntityInterface')) {
return new EntityFieldReflection($classReflection, $propertyName);
}
if (self::classObjectIsSuperOfInterface($classReflection->getName(), self::getFieldItemListInterfaceObject())->yes()) {
return new FieldItemListPropertyReflection($classReflection, $propertyName);
}
throw new LogicException($classReflection->getName() . "::$propertyName should be handled earlier.");
}
public static function classObjectIsSuperOfInterface(string $name, ObjectType $interfaceObject) : TrinaryLogic
{
return $interfaceObject->isSuperTypeOf(new ObjectType($name));
}
protected static function getFieldItemListInterfaceObject() : ObjectType
{
return new ObjectType('Drupal\Core\Field\FieldItemListInterface');
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace mglaman\PHPStanDrupal\Reflection;
use PHPStan\Reflection\ClassMemberReflection;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\TrivialParametersAcceptor;
use PHPStan\TrinaryLogic;
use PHPStan\Type\Type;
/**
* Allows field access to common methods.
*/
class FieldItemListMethodReflection implements MethodReflection
{
private ClassReflection $declaringClass;
private string $methodName;
public function __construct(ClassReflection $declaringClass, string $methodName)
{
$this->declaringClass = $declaringClass;
$this->methodName = $methodName;
}
public function getDeclaringClass(): ClassReflection
{
return $this->declaringClass;
}
public function isStatic(): bool
{
return false;
}
public function isPrivate(): bool
{
return false;
}
public function isPublic(): bool
{
return true;
}
public function getDocComment(): ?string
{
return null;
}
public function getName(): string
{
return $this->methodName;
}
public function getPrototype(): ClassMemberReflection
{
return $this;
}
/**
* @return \PHPStan\Reflection\ParametersAcceptor[]
*/
public function getVariants(): array
{
return [
new TrivialParametersAcceptor(),
];
}
public function isDeprecated(): TrinaryLogic
{
return TrinaryLogic::createNo();
}
public function getDeprecatedDescription(): ?string
{
return '';
}
public function isFinal(): TrinaryLogic
{
return TrinaryLogic::createYes();
}
public function isInternal(): TrinaryLogic
{
return TrinaryLogic::createNo();
}
public function getThrowType(): ?Type
{
return null;
}
public function hasSideEffects(): TrinaryLogic
{
return TrinaryLogic::createNo();
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace mglaman\PHPStanDrupal\Reflection;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\PropertyReflection;
use PHPStan\TrinaryLogic;
use PHPStan\Type\NullType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use function in_array;
/**
* Allows field access via magic methods
*
* See \Drupal\Core\Field\FieldItemListInterface::__get and ::__set.
*/
class FieldItemListPropertyReflection implements PropertyReflection
{
/** @var ClassReflection */
private $declaringClass;
/** @var string */
private $propertyName;
public function __construct(ClassReflection $declaringClass, string $propertyName)
{
$this->declaringClass = $declaringClass;
$this->propertyName = $propertyName;
}
public static function canHandleProperty(ClassReflection $classReflection, string $propertyName): bool
{
// @todo use the class reflection and be more specific about handled properties.
// Currently \PHPStan\Reflection\EntityFieldReflection::getType always passes FieldItemListInterface.
$names = ['entity', 'value', 'target_id'];
return in_array($propertyName, $names, true);
}
public function getReadableType(): Type
{
if ($this->propertyName === 'entity') {
return new ObjectType('Drupal\Core\Entity\EntityInterface');
}
if ($this->propertyName === 'target_id') {
// @todo needs to be union type.
return new StringType();
}
// @todo this is wrong, integer/bool/decimal/etc all use single value property.
if ($this->propertyName === 'value') {
return new StringType();
}
// Fallback.
return new NullType();
}
public function getWritableType(): Type
{
if ($this->propertyName === 'entity') {
return new ObjectType('Drupal\Core\Entity\EntityInterface');
}
if ($this->propertyName === 'target_id') {
return new StringType();
}
if ($this->propertyName === 'value') {
return new StringType();
}
// Fallback.
return new NullType();
}
public function canChangeTypeAfterAssignment(): bool
{
return true;
}
public function getDeclaringClass(): ClassReflection
{
return $this->declaringClass;
}
public function isStatic(): bool
{
return false;
}
public function isPrivate(): bool
{
return false;
}
public function isPublic(): bool
{
return true;
}
public function isReadable(): bool
{
return true;
}
public function isWritable(): bool
{
return true;
}
public function getDocComment(): ?string
{
return null;
}
public function isDeprecated(): TrinaryLogic
{
return TrinaryLogic::createNo();
}
public function getDeprecatedDescription(): ?string
{
return null;
}
public function isInternal(): TrinaryLogic
{
return TrinaryLogic::createNo();
}
}

View File

@@ -0,0 +1,81 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Classes;
use mglaman\PHPStanDrupal\Internal\NamespaceCheck;
use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use function sprintf;
/**
* @implements Rule<Class_>
*/
class ClassExtendsInternalClassRule implements Rule
{
/**
* @var ReflectionProvider
*/
private $reflectionProvider;
public function __construct(ReflectionProvider $reflectionProvider)
{
$this->reflectionProvider = $reflectionProvider;
}
public function getNodeType(): string
{
return Class_::class;
}
public function processNode(Node $node, Scope $scope): array
{
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)->build()];
}
$currentClassName = $node->namespacedName->toString();
if (!NamespaceCheck::isDrupalNamespace($node)) {
return [$this->buildError($currentClassName, $extendedClassName)->build()];
}
if (NamespaceCheck::isSharedNamespace($node)) {
return [];
}
$errorBuilder = $this->buildError($currentClassName, $extendedClassName);
if ($extendedClassName === 'Drupal\Core\Entity\ContentEntityDeleteForm') {
$errorBuilder->tip('Extend \Drupal\Core\Entity\ContentEntityConfirmFormBase. See https://www.drupal.org/node/2491057');
} elseif ((string) $node->extends->slice(0, 2) === 'Drupal\Core') {
$errorBuilder->tip('Read the Drupal core backwards compatibility and internal API policy: https://www.drupal.org/about/core/policies/core-change-policies/drupal-8-and-9-backwards-compatibility-and-internal-api#internal');
}
return [$errorBuilder->build()];
}
private function buildError(?string $currentClassName, string $extendedClassName): RuleErrorBuilder
{
return RuleErrorBuilder::message(sprintf(
'%s extends @internal class %s.',
$currentClassName !== null ? sprintf('Class %s', $currentClassName) : 'Anonymous class',
$extendedClassName
));
}
}

View File

@@ -0,0 +1,141 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Classes;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Type\ObjectType;
use function sprintf;
/**
* @implements \PHPStan\Rules\Rule<\PhpParser\Node\Stmt\Class_>
*/
class PluginManagerInspectionRule implements Rule
{
/** @var ReflectionProvider */
private $reflectionProvider;
public function __construct(ReflectionProvider $reflectionProvider)
{
$this->reflectionProvider = $reflectionProvider;
}
public function getNodeType(): string
{
return Node\Stmt\Class_::class;
}
public function processNode(Node $node, Scope $scope): array
{
if ($node->namespacedName === null) {
// anonymous class
return [];
}
if ($node->extends === null) {
return [];
}
$className = (string) $node->namespacedName;
$pluginManagerType = new ObjectType($className);
$pluginManagerInterfaceType = new ObjectType('\Drupal\Component\Plugin\PluginManagerInterface');
if (!$pluginManagerInterfaceType->isSuperTypeOf($pluginManagerType)->yes()) {
return [];
}
$errors = [];
if ($this->isYamlDiscovery($node)) {
$errors = $this->inspectYamlPluginManager($node);
} else {
// @todo inspect annotated plugin managers.
}
$hasAlterInfoSet = false;
foreach ($node->stmts as $stmt) {
if ($stmt instanceof Node\Stmt\ClassMethod && $stmt->name->toString() === '__construct') {
foreach ($stmt->stmts ?? [] as $statement) {
if ($statement instanceof Node\Stmt\Expression) {
$statement = $statement->expr;
}
if ($statement instanceof Node\Expr\MethodCall
&& $statement->name instanceof Node\Identifier
&& $statement->name->name === 'alterInfo') {
$hasAlterInfoSet = true;
}
}
}
}
if (!$hasAlterInfoSet) {
$errors[] = 'Plugin definitions cannot be altered.';
}
return $errors;
}
private function isYamlDiscovery(Node\Stmt\Class_ $class): bool
{
foreach ($class->stmts as $stmt) {
// YAML discovery plugin managers must override getDiscovery.
if ($stmt instanceof Node\Stmt\ClassMethod && $stmt->name->toString() === 'getDiscovery') {
foreach ($stmt->stmts ?? [] as $methodStmt) {
if ($methodStmt instanceof Node\Stmt\If_) {
foreach ($methodStmt->stmts as $ifStmt) {
if ($ifStmt instanceof Node\Stmt\Expression) {
$ifStmtExpr = $ifStmt->expr;
if ($ifStmtExpr instanceof Node\Expr\Assign) {
$ifStmtExprVar = $ifStmtExpr->var;
if ($ifStmtExprVar instanceof Node\Expr\PropertyFetch
&& $ifStmtExprVar->var instanceof Node\Expr\Variable
&& $ifStmtExprVar->name instanceof Node\Identifier
&& $ifStmtExprVar->name->name === 'discovery'
) {
$ifStmtExprExpr = $ifStmtExpr->expr;
if ($ifStmtExprExpr instanceof Node\Expr\New_
&& ($ifStmtExprExpr->class instanceof Node\Name)
&& $ifStmtExprExpr->class->toString() === 'Drupal\Core\Plugin\Discovery\YamlDiscovery') {
return true;
}
}
}
}
}
}
}
}
}
return false;
}
private function inspectYamlPluginManager(Node\Stmt\Class_ $class): array
{
$errors = [];
$fqn = (string) $class->namespacedName;
$reflection = $this->reflectionProvider->getClass($fqn);
$constructor = $reflection->getConstructor();
if ($constructor->getDeclaringClass()->getName() !== $fqn) {
$errors[] = sprintf('%s must override __construct if using YAML plugins.', $fqn);
} else {
foreach ($class->stmts as $stmt) {
if ($stmt instanceof Node\Stmt\ClassMethod && $stmt->name->toString() === '__construct') {
foreach ($stmt->stmts ?? [] as $constructorStmt) {
if ($constructorStmt instanceof Node\Stmt\Expression) {
$constructorStmt = $constructorStmt->expr;
}
if ($constructorStmt instanceof Node\Expr\StaticCall
&& $constructorStmt->class instanceof Node\Name
&& ((string)$constructorStmt->class === 'parent')
&& $constructorStmt->name instanceof Node\Identifier
&& $constructorStmt->name->name === '__construct') {
$errors[] = sprintf('YAML plugin managers should not invoke its parent constructor.');
}
}
}
}
}
return $errors;
}
}

View File

@@ -0,0 +1,132 @@
<?php declare(strict_types = 1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use Drupal;
use mglaman\PHPStanDrupal\Internal\DeprecatedScopeCheck;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use function array_merge;
use function explode;
use function sprintf;
/**
* @implements Rule<Node\Expr\ConstFetch>
*/
class AccessDeprecatedConstant implements Rule
{
/** @var ReflectionProvider */
private $reflectionProvider;
public function __construct(ReflectionProvider $reflectionProvider)
{
$this->reflectionProvider = $reflectionProvider;
}
public function getNodeType(): string
{
return Node\Expr\ConstFetch::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (DeprecatedScopeCheck::inDeprecatedScope($scope)) {
return [];
}
// nikic/php-parser does not let us access phpdoc comments from deprecated constants, so
// here goes a list of hardcoded core constants. List is available at
// https://api.drupal.org/api/drupal/deprecated/8.9.x?order=object_type&sort=asc&page=5
$deprecatedConstants = [
'DATETIME_STORAGE_TIMEZONE' => 'Deprecated in drupal:8.5.0 and is removed from drupal:9.0.0. Use \Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface::STORAGE_TIMEZONE instead.',
'DATETIME_DATETIME_STORAGE_FORMAT' => 'Deprecated in drupal:8.5.0 and is removed from drupal:9.0.0. Use \Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface::DATETIME_STORAGE_FORMAT instead.',
'DATETIME_DATE_STORAGE_FORMAT' => 'Deprecated in drupal:8.5.0 and is removed from drupal:9.0.0. Use \Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface::DATE_STORAGE_FORMAT instead.',
'DRUPAL_ANONYMOUS_RID' => 'Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use Drupal\Core\Session\AccountInterface::ANONYMOUS_ROLE or \Drupal\user\RoleInterface::ANONYMOUS_ID instead.',
'DRUPAL_AUTHENTICATED_RID' => 'Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use Drupal\Core\Session\AccountInterface::AUTHENTICATED_ROLE or \Drupal\user\RoleInterface::AUTHENTICATED_ID instead.',
'REQUEST_TIME' => 'Deprecated in drupal:8.3.0 and is removed from drupal:11.0.0. Use \Drupal::time()->getRequestTime(); ',
'DRUPAL_PHP_FUNCTION_PATTERN' => 'Deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use \Drupal\Core\Extension\ExtensionDiscovery::PHP_FUNCTION_PATTERN instead.',
'CONFIG_ACTIVE_DIRECTORY' => 'Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Drupal core no longer creates an active directory.',
'CONFIG_SYNC_DIRECTORY' => 'Deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use \Drupal\Core\Site\Settings::get(\'config_sync_directory\') instead.',
'CONFIG_STAGING_DIRECTORY' => 'Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. The staging directory was renamed to sync.',
'LOCALE_PLURAL_DELIMITER' => 'Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use Drupal\Component\Gettext\PoItem::DELIMITER instead.',
'FILE_CHMOD_DIRECTORY' => 'Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\FileSystem::CHMOD_DIRECTORY.',
'FILE_CHMOD_FILE' => 'Deprecated in drupal:8.0.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\FileSystem::CHMOD_FILE.',
'FILE_CREATE_DIRECTORY' => 'Deprecated in drupal:8.7.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\FileSystemInterface::CREATE_DIRECTORY.',
'FILE_MODIFY_PERMISSIONS' => 'Deprecated in drupal:8.7.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\FileSystemInterface::MODIFY_PERMISSIONS.',
'FILE_EXISTS_RENAME' => 'Deprecated in drupal:8.7.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\FileSystemInterface::EXISTS_RENAME.',
'FILE_EXISTS_REPLACE' => 'Deprecated in drupal:8.7.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\FileSystemInterface::EXISTS_REPLACE.',
'FILE_EXISTS_ERROR' => 'Deprecated in drupal:8.7.0 and is removed from drupal:9.0.0. Use \Drupal\Core\File\FileSystemInterface::EXISTS_ERROR.',
'AGGREGATOR_CLEAR_NEVER' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\aggregator\FeedStorageInterface::CLEAR_NEVER instead.',
'COMMENT_ANONYMOUS_MAYNOT_CONTACT' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\comment\CommentInterface::ANONYMOUS_MAYNOT_CONTACT instead.',
'COMMENT_ANONYMOUS_MAY_CONTACT' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\comment\CommentInterface::ANONYMOUS_MAY_CONTACT instead.',
'COMMENT_ANONYMOUS_MUST_CONTACT' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\comment\CommentInterface::ANONYMOUS_MUST_CONTACT instead.',
'IMAGE_STORAGE_NORMAL' => 'Deprecated in drupal:8.1.0 and is removed from drupal:9.0.0.',
'IMAGE_STORAGE_OVERRIDE' => 'Deprecated in drupal:8.1.0 and is removed from drupal:9.0.0.',
'IMAGE_STORAGE_DEFAULT' => 'Deprecated in drupal:8.1.0 and is removed from drupal:9.0.0.',
'IMAGE_STORAGE_EDITABLE' => 'Deprecated in drupal:8.1.0 and is removed from drupal:9.0.0.',
'IMAGE_STORAGE_MODULE' => 'Deprecated in drupal:8.1.0 and is removed from drupal:9.0.0.',
'MENU_MAX_MENU_NAME_LENGTH_UI' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\system\MenuStorage::MAX_ID_LENGTH instead.',
'NODE_NOT_PUBLISHED' => 'Deprecated in drupal:8.?.? and is removed from drupal:9.0.0. Use \Drupal\node\NodeInterface::NOT_PUBLISHED instead.',
'NODE_PUBLISHED' => 'Deprecated in drupal:8.?.? and is removed from drupal:9.0.0. Use \Drupal\node\NodeInterface::PUBLISHED instead.',
'NODE_NOT_PROMOTED' => 'Deprecated in drupal:8.?.? and is removed from drupal:9.0.0. Use \Drupal\node\NodeInterface::NOT_PROMOTED instead.',
'NODE_PROMOTED' => 'Deprecated in drupal:8.?.? and is removed from drupal:9.0.0. Use \Drupal\node\NodeInterface::PROMOTED instead.',
'NODE_NOT_STICKY' => 'Deprecated in drupal:8.?.? and is removed from drupal:9.0.0. Use \Drupal\node\NodeInterface::NOT_STICKY instead.',
'NODE_STICKY' => 'Deprecated in drupal:8.?.? and is removed from drupal:9.0.0. Use \Drupal\node\NodeInterface::STICKY instead.',
'RESPONSIVE_IMAGE_EMPTY_IMAGE' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use Drupal\responsive_image\ResponsiveImageStyleInterface::EMPTY_IMAGE instead.',
'RESPONSIVE_IMAGE_ORIGINAL_IMAGE' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\responsive_image\ResponsiveImageStyleInterface::ORIGINAL_IMAGE instead.',
'DRUPAL_USER_TIMEZONE_DEFAULT' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\user\UserInterface::TIMEZONE_DEFAULT instead.',
'DRUPAL_USER_TIMEZONE_EMPTY' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\user\UserInterface::TIMEZONE_EMPTY instead.',
'DRUPAL_USER_TIMEZONE_SELECT' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\user\UserInterface::TIMEZONE_SELECT instead.',
'TAXONOMY_HIERARCHY_DISABLED' => 'Deprecated in drupal:8.2.0 and is removed from drupal:9.0.0. Use \Drupal\taxonomy\VocabularyInterface::HIERARCHY_DISABLED instead.',
'TAXONOMY_HIERARCHY_SINGLE' => 'Deprecated in drupal:8.2.0 and is removed from drupal:9.0.0. Use \Drupal\taxonomy\VocabularyInterface::HIERARCHY_SINGLE instead.',
'TAXONOMY_HIERARCHY_MULTIPLE' => 'Deprecated in drupal:8.2.0 and is removed from drupal:9.0.0. Use \Drupal\taxonomy\VocabularyInterface::HIERARCHY_MULTIPLE instead.',
'UPDATE_NOT_SECURE' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateManagerInterface::NOT_SECURE instead.',
'UPDATE_REVOKED' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateManagerInterface::REVOKED instead.',
'UPDATE_NOT_SUPPORTED' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateManagerInterface::NOT_SUPPORTED instead.',
'UPDATE_NOT_CURRENT' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateManagerInterface::NOT_CURRENT instead.',
'UPDATE_CURRENT' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateManagerInterface::CURRENT instead.',
'UPDATE_NOT_CHECKED' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateFetcherInterface::NOT_CHECKED instead.',
'UPDATE_UNKNOWN' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateFetcherInterface::UNKNOWN instead.',
'UPDATE_NOT_FETCHED' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateFetcherInterface::NOT_FETCHED instead.',
'UPDATE_FETCH_PENDING' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\update\UpdateFetcherInterface::FETCH_PENDING instead.',
'USERNAME_MAX_LENGTH' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\user\UserInterface::USERNAME_MAX_LENGTH instead.',
'USER_REGISTER_ADMINISTRATORS_ONLY' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\user\UserInterface::REGISTER_ADMINISTRATORS_ONLY instead.',
'USER_REGISTER_VISITORS' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\user\UserInterface::REGISTER_VISITORS instead.',
'USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL' => 'Deprecated in drupal:8.3.0 and is removed from drupal:9.0.0. Use \Drupal\user\UserInterface::REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL instead.',
];
[$major, $minor] = explode('.', Drupal::VERSION, 3);
if ($major === '9') {
if ((int) $minor >= 1) {
$deprecatedConstants = array_merge($deprecatedConstants, [
'DRUPAL_MINIMUM_PHP' => 'Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use \Drupal::MINIMUM_PHP instead.',
'DRUPAL_MINIMUM_PHP_MEMORY_LIMIT' => 'Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use \Drupal::MINIMUM_PHP_MEMORY_LIMIT instead.',
'DRUPAL_MINIMUM_SUPPORTED_PHP' => 'Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use \Drupal::MINIMUM_SUPPORTED_PHP instead.',
'DRUPAL_RECOMMENDED_PHP' => 'Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use \Drupal::RECOMMENDED_PHP instead.',
'PREG_CLASS_CJK' => 'Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use \Drupal\search\SearchTextProcessorInterface::PREG_CLASS_CJK instead.',
'PREG_CLASS_NUMBERS' => 'Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use \Drupal\search\SearchTextProcessorInterface::PREG_CLASS_NUMBERS',
'PREG_CLASS_PUNCTUATION' => 'Deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. Use \Drupal\search\SearchTextProcessorInterface::PREG_CLASS_PUNCTUATION',
]);
}
if ((int) $minor >= 2) {
$deprecatedConstants = array_merge($deprecatedConstants, [
'FILE_INSECURE_EXTENSION_REGEX' => 'Deprecated in drupal:9.2.0 and is removed from drupal:10.0.0. Use \Drupal\Core\File\FileSystemInterface::INSECURE_EXTENSION_REGEX.',
]);
}
if ((int) $minor >= 3) {
$deprecatedConstants = array_merge($deprecatedConstants, [
'FILE_STATUS_PERMANENT' => 'Deprecated in drupal:9.3.0 and is removed from drupal:10.0.0. Use \Drupal\file\FileInterface::STATUS_PERMANENT or \Drupal\file\FileInterface::setPermanent().',
'SCHEMA_UNINSTALLED' => 'Deprecated in drupal:9.3.0 and is removed from drupal:10.0.0. Use \Drupal\Core\Update\UpdateHookRegistry::SCHEMA_UNINSTALLED',
]);
}
}
$constantName = $this->reflectionProvider->resolveConstantName($node->name, $scope);
if (isset($deprecatedConstants[$constantName])) {
return [
sprintf('Call to deprecated constant %s: %s', $constantName, $deprecatedConstants[$constantName])
];
}
return [];
}
}

View File

@@ -0,0 +1,61 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use Drupal\Core\Condition\ConditionManager;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\ObjectType;
use function count;
/**
* @implements Rule<Node\Expr\MethodCall>
*/
final class ConditionManagerCreateInstanceContextConfigurationRule implements Rule
{
public function getNodeType(): string
{
return Node\Expr\MethodCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Node\Identifier) {
return [];
}
if ($node->name->toString() !== 'createInstance') {
return [];
}
$args = $node->getArgs();
if (count($args) !== 2) {
return [];
}
$conditionManagerType = new ObjectType(ConditionManager::class);
$type = $scope->getType($node->var);
if (!$conditionManagerType->isSuperTypeOf($type)->yes()) {
return [];
}
$configuration = $args[1];
$configurationType = $scope->getType($configuration->value);
// Must be an array, return [] and allow parameter inspection rule to report error.
if (!$configurationType instanceof ConstantArrayType) {
return [];
}
foreach ($configurationType->getKeyTypes() as $keyType) {
if ($keyType instanceof ConstantStringType && $keyType->getValue() === 'context') {
return [
RuleErrorBuilder::message('Passing context values to plugins via configuration is deprecated in drupal:9.1.0 and will be removed before drupal:10.0.0. Instead, call ::setContextValue() on the plugin itself. See https://www.drupal.org/node/3120980')
->line($node->getStartLine())
->build()
];
}
}
return [];
}
}

View File

@@ -0,0 +1,52 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\PhpDoc\ResolvedPhpDocBlock;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
use PHPStan\Reflection\ClassReflection;
use PHPStan\ShouldNotHappenException;
use function preg_match;
final class ConfigEntityConfigExportRule extends DeprecatedAnnotationsRuleBase
{
protected function getExpectedInterface(): string
{
return 'Drupal\Core\Config\Entity\ConfigEntityInterface';
}
protected function doProcessNode(ClassReflection $reflection, Node\Stmt\Class_ $node, Scope $scope): array
{
$phpDoc = $reflection->getResolvedPhpDoc();
// Plugins should always be annotated, but maybe this class is missing its
// annotation since it swaps an existing one.
if ($phpDoc === null || !$this->isAnnotated($phpDoc)) {
return [];
}
$hasMatch = preg_match('/config_export\s?=\s?{/', $phpDoc->getPhpDocString());
if ($hasMatch === false) {
throw new ShouldNotHappenException('Unexpected error when trying to run match on phpDoc string.');
}
if ($hasMatch === 0) {
return [
'Configuration entity must define a `config_export` key. See https://www.drupal.org/node/2481909',
];
}
return [];
}
private function isAnnotated(ResolvedPhpDocBlock $phpDoc): bool
{
foreach ($phpDoc->getPhpDocNodes() as $docNode) {
foreach ($docNode->children as $childNode) {
if (($childNode instanceof PhpDocTagNode) && $childNode->name === '@ConfigEntityType') {
return true;
}
}
}
return false;
}
}

View File

@@ -0,0 +1,67 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
/**
* @implements Rule<Node\Stmt\Class_>
*/
abstract class DeprecatedAnnotationsRuleBase implements Rule
{
/**
* @var \PHPStan\Reflection\ReflectionProvider
*/
protected $reflectionProvider;
public function __construct(ReflectionProvider $reflectionProvider)
{
$this->reflectionProvider = $reflectionProvider;
}
public function getNodeType(): string
{
return Node\Stmt\Class_::class;
}
abstract protected function getExpectedInterface(): string;
abstract protected function doProcessNode(
ClassReflection $reflection,
Node\Stmt\Class_ $node,
Scope $scope
): array;
public function processNode(Node $node, Scope $scope): array
{
if ($node->extends === null) {
return [];
}
if ($node->name === null) {
return [];
}
if ($node->isAbstract()) {
return [];
}
// PHPStan gives anonymous classes a name, so we cannot determine if
// a class is truly anonymous using the normal methods from php-parser.
// @see \PHPStan\Reflection\BetterReflection\BetterReflectionProvider::getAnonymousClassReflection
if ($node->hasAttribute('anonymousClass') && $node->getAttribute('anonymousClass') === true) {
return [];
}
$className = $node->name->name;
$namespace = $scope->getNamespace();
$reflection = $this->reflectionProvider->getClass($namespace . '\\' . $className);
$implementsExpectedInterface = $reflection->implementsInterface($this->getExpectedInterface());
if (!$implementsExpectedInterface) {
return [];
}
return $this->doProcessNode($reflection, $node, $scope);
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use PhpParser\Node;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt\Function_;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use function basename;
use function explode;
use function strlen;
use function substr_replace;
/**
* @implements Rule<Function_>
*/
class DeprecatedHookImplementation implements Rule
{
protected ReflectionProvider $reflectionProvider;
public function __construct(ReflectionProvider $reflectionProvider)
{
$this->reflectionProvider = $reflectionProvider;
}
public function getNodeType(): string
{
return Function_::class;
}
public function processNode(Node $node, Scope $scope) : array
{
if (!str_ends_with($scope->getFile(), ".module") && !str_ends_with($scope->getFile(), ".inc")) {
return [];
}
// We want both name.module and name.views.inc, to resolve to name.
$module_name = explode(".", basename($scope->getFile()))[0];
// Hooks start with their own module's name.
if (!str_starts_with($node->name->toString(), "{$module_name}_")) {
return [];
}
$function_name = $node->name->toString();
$hook_name = substr_replace($function_name, "hook", 0, strlen($module_name));
$hook_name_node = new Name($hook_name);
if (!$this->reflectionProvider->hasFunction($hook_name_node, $scope)) {
// @todo replace this hardcoded logic with something more intelligent and extensible.
if ($hook_name === 'hook_field_widget_form_alter') {
return $this->buildError(
$function_name,
$hook_name,
'in drupal:9.2.0 and is removed from drupal:10.0.0. Use hook_field_widget_single_element_form_alter instead.'
);
}
if (str_starts_with($hook_name, 'hook_field_widget_') && str_ends_with($hook_name, '_form_alter')) {
return $this->buildError(
$function_name,
'hook_field_widget_WIDGET_TYPE_form_alter',
'in drupal:9.2.0 and is removed from drupal:10.0.0. Use hook_field_widget_single_element_WIDGET_TYPE_form_alter instead.'
);
}
if ($hook_name === 'hook_field_widget_multivalue_form_alter') {
return $this->buildError(
$function_name,
$hook_name,
'in drupal:9.2.0 and is removed from drupal:10.0.0. Use hook_field_widget_complete_form_alter instead.'
);
}
if (str_starts_with($hook_name, 'hook_field_widget_multivalue_') && str_ends_with($hook_name, '_form_alter')) {
return $this->buildError(
$function_name,
'hook_field_widget_multivalue_WIDGET_TYPE_form_alter',
'in drupal:9.2.0 and is removed from drupal:10.0.0. Use hook_field_widget_complete_WIDGET_TYPE_form_alter instead.'
);
}
return [];
}
$reflection = $this->reflectionProvider->getFunction($hook_name_node, $scope);
if (!$reflection->isDeprecated()->yes()) {
return [];
}
return $this->buildError($function_name, $hook_name, $reflection->getDeprecatedDescription());
}
private function buildError(string $function_name, string $hook_name, ?string $deprecated_description): array
{
$deprecated_description = $deprecated_description !== null ? " $deprecated_description" : ".";
return [
RuleErrorBuilder::message(
"Function $function_name implements $hook_name which is deprecated$deprecated_description",
)->build()
];
}
}

View File

@@ -0,0 +1,65 @@
<?php declare(strict_types = 1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use mglaman\PHPStanDrupal\Drupal\DrupalServiceDefinition;
use mglaman\PHPStanDrupal\Drupal\ServiceMap;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
/**
* @implements Rule<Node\Expr\MethodCall>
*/
final class GetDeprecatedServiceRule implements Rule
{
/**
* @var ServiceMap
*/
private $serviceMap;
public function __construct(ServiceMap $serviceMap)
{
$this->serviceMap = $serviceMap;
}
public function getNodeType(): string
{
return Node\Expr\MethodCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Node\Identifier) {
return [];
}
$method_name = $node->name->toString();
if ($method_name !== 'get') {
return [];
}
$methodReflection = $scope->getMethodReflection($scope->getType($node->var), $node->name->toString());
if ($methodReflection === null) {
return [];
}
$declaringClass = $methodReflection->getDeclaringClass();
if ($declaringClass->getName() !== 'Symfony\Component\DependencyInjection\ContainerInterface') {
return [];
}
$serviceNameArg = $node->args[0];
assert($serviceNameArg instanceof Node\Arg);
$serviceName = $serviceNameArg->value;
// @todo check if var, otherwise throw.
// ACTUALLY what if it was a constant? can we use a resolver.
if (!$serviceName instanceof Node\Scalar\String_) {
return [];
}
$service = $this->serviceMap->getService($serviceName->value);
if (($service instanceof DrupalServiceDefinition) && $service->isDeprecated()) {
return [$service->getDeprecatedDescription()];
}
return [];
}
}

View File

@@ -0,0 +1,38 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\ShouldNotHappenException;
use function preg_match;
final class PluginAnnotationContextDefinitionsRule extends DeprecatedAnnotationsRuleBase
{
protected function getExpectedInterface(): string
{
return 'Drupal\Component\Plugin\ContextAwarePluginInterface';
}
protected function doProcessNode(ClassReflection $reflection, Node\Stmt\Class_ $node, Scope $scope): array
{
$annotation = $reflection->getResolvedPhpDoc();
// Plugins should always be annotated, but maybe this class is missing its
// annotation since it swaps an existing one.
if ($annotation === null) {
return [];
}
$hasMatch = preg_match('/context\s?=\s?{/', $annotation->getPhpDocString());
if ($hasMatch === false) {
throw new ShouldNotHappenException('Unexpected error when trying to run match on phpDoc string.');
}
if ($hasMatch === 1) {
return [
'Providing context definitions via the "context" key is deprecated in Drupal 8.7.x and will be removed before Drupal 9.0.0. Use the "context_definitions" key instead.',
];
}
return [];
}
}

View File

@@ -0,0 +1,74 @@
<?php declare(strict_types = 1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use mglaman\PHPStanDrupal\Drupal\DrupalServiceDefinition;
use mglaman\PHPStanDrupal\Drupal\ServiceMap;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
/**
* @implements Rule<Node\Expr\StaticCall>
*/
final class StaticServiceDeprecatedServiceRule implements Rule
{
/**
* @var ServiceMap
*/
private $serviceMap;
public function __construct(ServiceMap $serviceMap)
{
$this->serviceMap = $serviceMap;
}
public function getNodeType(): string
{
return Node\Expr\StaticCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Node\Identifier) {
return [];
}
$method_name = $node->name->toString();
if ($method_name !== 'service') {
return [];
}
$class = $node->class;
if ($class instanceof Node\Name) {
$calledOnType = $scope->resolveTypeByName($class);
} else {
$calledOnType = $scope->getType($class);
}
$methodReflection = $scope->getMethodReflection($calledOnType, $node->name->toString());
if ($methodReflection === null) {
return [];
}
$declaringClass = $methodReflection->getDeclaringClass();
if ($declaringClass->getName() !== 'Drupal') {
return [];
}
$serviceNameArg = $node->args[0];
assert($serviceNameArg instanceof Node\Arg);
$serviceName = $serviceNameArg->value;
// @todo check if var, otherwise throw.
// ACTUALLY what if it was a constant? can we use a resolver.
if (!$serviceName instanceof Node\Scalar\String_) {
return [];
}
$service = $this->serviceMap->getService($serviceName->value);
if (($service instanceof DrupalServiceDefinition) && $service->isDeprecated()) {
return [$service->getDeprecatedDescription()];
}
return [];
}
}

View File

@@ -0,0 +1,73 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use Drupal;
use Drupal\Core\Routing\RouteObjectInterface;
use mglaman\PHPStanDrupal\Internal\DeprecatedScopeCheck;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use Symfony\Cmf\Component\Routing\RouteObjectInterface as SymfonyRouteObjectInterface;
use function sprintf;
/**
* @implements Rule<Node\Expr\ClassConstFetch>
*/
final class SymfonyCmfRouteObjectInterfaceConstantsRule implements Rule
{
public function getNodeType(): string
{
return Node\Expr\ClassConstFetch::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Node\Identifier) {
return [];
}
if (!$node->class instanceof Node\Name) {
return [];
}
$constantName = $node->name->name;
$className = $node->class;
$classType = $scope->resolveTypeByName($className);
if (!$classType->hasConstant($constantName)->yes()) {
return [];
}
if (DeprecatedScopeCheck::inDeprecatedScope($scope)) {
return [];
}
[$major, $minor] = explode('.', Drupal::VERSION, 3);
if ($major !== '9') {
return [];
}
if ((int) $minor < 1) {
return [];
}
// @phpstan-ignore-next-line
$cmfRouteObjectInterfaceType = new ObjectType(SymfonyRouteObjectInterface::class);
if (!$classType->isSuperTypeOf($cmfRouteObjectInterfaceType)->yes()) {
return [];
}
$coreRouteObjectInterfaceType = new ObjectType(RouteObjectInterface::class);
if (!$coreRouteObjectInterfaceType->hasConstant($constantName)->yes()) {
return [
RuleErrorBuilder::message(
sprintf('The core dependency symfony-cmf/routing is deprecated and %s::%s is not supported.', $className, $constantName)
)->tip('Change record: https://www.drupal.org/node/3151009')->build(),
];
}
return [
RuleErrorBuilder::message(
sprintf('%s::%s is deprecated and removed in Drupal 10. Use \Drupal\Core\Routing\RouteObjectInterface::%2$s instead.', $className, $constantName)
)->tip('Change record: https://www.drupal.org/node/3151009')->build(),
];
}
}

View File

@@ -0,0 +1,132 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Deprecations;
use Drupal;
use mglaman\PHPStanDrupal\Internal\DeprecatedScopeCheck;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\InClassMethodNode;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use Symfony\Cmf\Component\Routing\LazyRouteCollection;
use Symfony\Cmf\Component\Routing\RouteObjectInterface;
use Symfony\Cmf\Component\Routing\RouteProviderInterface;
use function explode;
use function sprintf;
/**
* @implements Rule<InClassMethodNode>
*/
final class SymfonyCmfRoutingInClassMethodSignatureRule implements Rule
{
public function getNodeType(): string
{
return InClassMethodNode::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (DeprecatedScopeCheck::inDeprecatedScope($scope)) {
return [];
}
[$major, $minor] = explode('.', Drupal::VERSION, 3);
if ($major !== '9' || (int) $minor < 1) {
return [];
}
$method = $node->getMethodReflection();
// @phpstan-ignore-next-line
$cmfRouteObjectInterfaceType = new ObjectType(RouteObjectInterface::class);
// @phpstan-ignore-next-line
$cmfRouteProviderInterfaceType = new ObjectType(RouteProviderInterface::class);
// @phpstan-ignore-next-line
$cmfLazyRouteCollectionType = new ObjectType(LazyRouteCollection::class);
$methodSignature = ParametersAcceptorSelector::selectFromArgs(
$scope,
[],
$method->getVariants()
);
$errors = [];
$errorMessage = 'Parameter $%s of method %s() uses deprecated %s and removed in Drupal 10. Use %s instead.';
foreach ($methodSignature->getParameters() as $parameter) {
foreach ($parameter->getType()->getReferencedClasses() as $referencedClass) {
$referencedClassType = new ObjectType($referencedClass);
if ($cmfRouteObjectInterfaceType->equals($referencedClassType)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
$errorMessage,
$parameter->getName(),
$method->getName(),
$referencedClass,
'\Drupal\Core\Routing\RouteObjectInterface'
)
)->tip('Change record: https://www.drupal.org/node/3151009')->build();
} elseif ($cmfRouteProviderInterfaceType->equals($referencedClassType)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
$errorMessage,
$parameter->getName(),
$method->getName(),
$referencedClass,
'\Drupal\Core\Routing\RouteProviderInterface'
)
)->tip('Change record: https://www.drupal.org/node/3151009')->build();
} elseif ($cmfLazyRouteCollectionType->equals($referencedClassType)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
$errorMessage,
$parameter->getName(),
$method->getName(),
$referencedClass,
'\Drupal\Core\Routing\LazyRouteCollection'
)
)->tip('Change record: https://www.drupal.org/node/3151009')->build();
}
}
}
$errorMessage = 'Return type of method %s::%s() has typehint with deprecated %s and is removed in Drupal 10. Use %s instead.';
$returnClasses = $methodSignature->getReturnType()->getReferencedClasses();
foreach ($returnClasses as $returnClass) {
$returnType = new ObjectType($returnClass);
if ($cmfRouteObjectInterfaceType->equals($returnType)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
$errorMessage,
$method->getDeclaringClass()->getName(),
$method->getName(),
$returnClass,
'\Drupal\Core\Routing\RouteObjectInterface'
)
)->tip('Change record: https://www.drupal.org/node/3151009')->build();
} elseif ($cmfRouteProviderInterfaceType->equals($returnType)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
$errorMessage,
$method->getDeclaringClass()->getName(),
$method->getName(),
$returnClass,
'\Drupal\Core\Routing\RouteProviderInterface'
)
)->tip('Change record: https://www.drupal.org/node/3151009')->build();
} elseif ($cmfLazyRouteCollectionType->equals($returnType)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
$errorMessage,
$method->getDeclaringClass()->getName(),
$method->getName(),
$returnClass,
'\Drupal\Core\Routing\LazyRouteCollection'
)
)->tip('Change record: https://www.drupal.org/node/3151009')->build();
}
}
return $errors;
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use Drupal\Core\Access\AccessResult;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\VerbosityLevel;
/**
* @implements Rule<Node\Expr\StaticCall>
*/
final class AccessResultConditionRule implements Rule
{
/** @var bool */
private $treatPhpDocTypesAsCertain;
/**
* @param bool $treatPhpDocTypesAsCertain
*/
public function __construct($treatPhpDocTypesAsCertain)
{
$this->treatPhpDocTypesAsCertain = $treatPhpDocTypesAsCertain;
}
public function getNodeType(): string
{
return Node\Expr\StaticCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Node\Identifier) {
return [];
}
$methodName = $node->name->toString();
if (!in_array($methodName, ['allowedIf', 'forbiddenIf'], true)) {
return [];
}
if (!$node->class instanceof Node\Name) {
return [];
}
$className = $scope->resolveName($node->class);
if ($className !== AccessResult::class) {
return [];
}
$args = $node->getArgs();
if (count($args) === 0) {
return [];
}
$condition = $args[0]->value;
if (!$condition instanceof Node\Expr\BinaryOp\Identical && !$condition instanceof Node\Expr\BinaryOp\NotIdentical) {
return [];
}
$conditionType = $this->treatPhpDocTypesAsCertain ? $scope->getType($condition) : $scope->getNativeType($condition);
$bool = $conditionType->toBoolean();
if ($bool->isTrue()->or($bool->isFalse())->yes()) {
$leftType = $this->treatPhpDocTypesAsCertain ? $scope->getType($condition->left) : $scope->getNativeType($condition->left);
$rightType = $this->treatPhpDocTypesAsCertain ? $scope->getType($condition->right) : $scope->getNativeType($condition->right);
return [
RuleErrorBuilder::message(sprintf(
'Strict comparison using %s between %s and %s will always evaluate to %s.',
$condition->getOperatorSigil(),
$leftType->describe(VerbosityLevel::value()),
$rightType->describe(VerbosityLevel::value()),
$bool->describe(VerbosityLevel::value()),
))->identifier(sprintf('%s.alwaysFalse', $condition instanceof Node\Expr\BinaryOp\Identical ? 'identical' : 'notIdentical'))->build(),
];
}
return [];
}
}

View File

@@ -0,0 +1,63 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal\Coder;
use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use function in_array;
use function sprintf;
use function strtolower;
/**
* Based on Drupal_Sniffs_Functions_DiscouragedFunctionsSniff.
*
* @implements Rule<FuncCall>
*/
class DiscouragedFunctionsRule implements Rule
{
public function getNodeType(): string
{
return FuncCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!($node->name instanceof Node\Name)) {
return [];
}
$name = strtolower((string)$node->name);
$discouragedFunctions = [
// Devel module debugging functions.
'dargs',
'dcp',
'dd',
'dfb',
'dfbt',
'dpm',
'dpq',
'dpr',
'dprint_r',
'drupal_debug',
'dsm',
'dvm',
'dvr',
'kdevel_print_object',
'kpr',
'kprint_r',
'sdpm',
// Functions which are not available on all
// PHP builds.
'fnmatch',
// Functions which are a security risk.
'eval',
];
if (in_array($name, $discouragedFunctions, true)) {
return [sprintf('Calls to function %s should not exist.', $name)];
}
return [];
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use Drupal\Core\DependencyInjection\DependencySerializationTrait;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\ClassPropertyNode;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* @implements Rule<ClassPropertyNode>
*/
final class DependencySerializationTraitPropertyRule implements Rule
{
public function getNodeType(): string
{
return ClassPropertyNode::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->getClassReflection()->hasTraitUse(DependencySerializationTrait::class)) {
return [];
}
$errors = [];
if ($node->isPrivate()) {
$errors[] = RuleErrorBuilder::message(
sprintf(
'%s does not support private properties.',
DependencySerializationTrait::class
)
)->tip('See https://www.drupal.org/node/3110266')->build();
}
if ($node->isReadOnly()) {
$errors[] = RuleErrorBuilder::message(
sprintf(
'Read-only properties are incompatible with %s.',
DependencySerializationTrait::class
)
)->tip('See https://www.drupal.org/node/3110266')->build();
}
return $errors;
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal\EntityQuery;
use mglaman\PHPStanDrupal\Type\EntityQuery\ConfigEntityQueryType;
use mglaman\PHPStanDrupal\Type\EntityQuery\EntityQueryExecuteWithoutAccessCheckCountType;
use mglaman\PHPStanDrupal\Type\EntityQuery\EntityQueryExecuteWithoutAccessCheckType;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* @implements Rule<Node\Expr\MethodCall>
*/
final class EntityQueryHasAccessCheckRule implements Rule
{
public function getNodeType(): string
{
return Node\Expr\MethodCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
$name = $node->name;
if (!$name instanceof Node\Identifier) {
return [];
}
if ($name->toString() !== 'execute') {
return [];
}
$type = $scope->getType($node);
if (!$type instanceof EntityQueryExecuteWithoutAccessCheckCountType && !$type instanceof EntityQueryExecuteWithoutAccessCheckType) {
return [];
}
$parent = $scope->getType($node->var);
if ($parent instanceof ConfigEntityQueryType) {
return [];
}
return [
RuleErrorBuilder::message(
'Relying on entity queries to check access by default is deprecated in drupal:9.2.0 and an error will be thrown from drupal:10.0.0. Call \Drupal\Core\Entity\Query\QueryInterface::accessCheck() with TRUE or FALSE to specify whether access should be checked.'
)->tip('See https://www.drupal.org/node/3201242')->build(),
];
}
}

View File

@@ -0,0 +1,78 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ExtendedMethodReflection;
use PHPStan\Rules\Rule;
/**
* @implements Rule<Node\Expr\StaticCall>
*/
class GlobalDrupalDependencyInjectionRule implements Rule
{
public function getNodeType(): string
{
return Node\Expr\StaticCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
// Only check static calls to \Drupal
if (!($node->class instanceof Node\Name\FullyQualified) || (string) $node->class !== 'Drupal') {
return [];
}
// Do not raise if called inside a trait.
if (!$scope->isInClass() || $scope->isInTrait()) {
return [];
}
$scopeClassReflection = $scope->getClassReflection();
// Enums cannot have dependency injection.
if ($scopeClassReflection->isEnum()) {
return [];
}
$allowed_list = [
// Ignore tests.
'PHPUnit\Framework\Test',
// Typed data objects cannot use dependency injection.
'Drupal\Core\TypedData\TypedDataInterface',
// Render elements cannot use dependency injection.
'Drupal\Core\Render\Element\ElementInterface',
'Drupal\Core\Render\Element\FormElementInterface',
'Drupal\config_translation\FormElement\ElementInterface',
// Entities don't use services for now
// @see https://www.drupal.org/project/drupal/issues/2913224
'Drupal\Core\Entity\EntityInterface',
// Stream wrappers are only registered as a service for their tags
// and cannot use dependency injection. Function calls like
// file_exists, stat, etc. will construct the class directly.
'Drupal\Core\StreamWrapper\StreamWrapperInterface',
// Ignore Nightwatch test setup classes.
'Drupal\TestSite\TestSetupInterface',
];
foreach ($allowed_list as $item) {
if ($scopeClassReflection->implementsInterface($item)) {
return [];
}
}
$scopeFunction = $scope->getFunction();
if ($scopeFunction === null) {
return [];
}
if (!$scopeFunction instanceof ExtendedMethodReflection) {
return [];
}
if ($scopeFunction->isStatic()) {
return [];
}
return [
'\Drupal calls should be avoided in classes, use dependency injection instead'
];
}
}

View File

@@ -0,0 +1,61 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use mglaman\PHPStanDrupal\Drupal\ExtensionMap;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use function count;
/**
* @template TNodeType of Node
* @implements Rule<TNodeType>
*/
abstract class LoadIncludeBase implements Rule
{
/**
* @var \mglaman\PHPStanDrupal\Drupal\ExtensionMap
*/
protected $extensionMap;
public function __construct(ExtensionMap $extensionMap)
{
$this->extensionMap = $extensionMap;
}
private function getStringArgValue(Node\Expr $expr, Scope $scope): ?string
{
$type = $scope->getType($expr);
$stringTypes = $type->getConstantStrings();
if (count($stringTypes) > 0) {
return $stringTypes[0]->getValue();
}
return null;
}
protected function parseLoadIncludeArgs(Node\Arg $module, Node\Arg $type, ?Node\Arg $name, Scope $scope): array
{
$moduleName = $this->getStringArgValue($module->value, $scope);
if ($moduleName === null) {
return [false, false];
}
$fileType = $this->getStringArgValue($type->value, $scope);
if ($fileType === null) {
return [false, false];
}
$baseName = null;
if ($name !== null) {
$baseName = $this->getStringArgValue($name->value, $scope);
if ($baseName === null) {
return [false, false];
}
}
if ($baseName === null) {
$baseName = $moduleName;
}
return [$moduleName, "$baseName.$fileType"];
}
}

View File

@@ -0,0 +1,92 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use Drupal\Core\Extension\ModuleHandlerInterface;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use Throwable;
use function count;
use function is_file;
use function sprintf;
/**
* @extends LoadIncludeBase<Node\Expr\MethodCall>
*/
class LoadIncludes extends LoadIncludeBase
{
public function getNodeType(): string
{
return Node\Expr\MethodCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Node\Identifier) {
return [];
}
$method_name = $node->name->toString();
if ($method_name !== 'loadInclude') {
return [];
}
$args = $node->getArgs();
if (count($args) < 2) {
return [];
}
$type = $scope->getType($node->var);
$moduleHandlerInterfaceType = new ObjectType(ModuleHandlerInterface::class);
if (!$type->isSuperTypeOf($moduleHandlerInterfaceType)->yes()) {
return [];
}
try {
// Try to invoke it similarly as the module handler itself.
[$moduleName, $filename] = $this->parseLoadIncludeArgs($args[0], $args[1], $args[2] ?? null, $scope);
if (!$moduleName && !$filename) {
// Couldn't determine module- nor file-name, most probably
// because it's a variable. Nothing to load, bail now.
return [];
}
$module = $this->extensionMap->getModule($moduleName);
if ($module === null) {
return [
RuleErrorBuilder::message(sprintf(
'File %s could not be loaded from %s::loadInclude because %s module is not found.',
$filename,
ModuleHandlerInterface::class,
$moduleName
))
->line($node->getStartLine())
->build()
];
}
$file = $module->getAbsolutePath() . DIRECTORY_SEPARATOR . $filename;
if (is_file($file)) {
require_once $file;
return [];
}
return [
RuleErrorBuilder::message(sprintf(
'File %s could not be loaded from %s::loadInclude',
$module->getPath() . '/' . $filename,
ModuleHandlerInterface::class
))
->line($node->getStartLine())
->build()
];
} catch (Throwable $e) {
return [
RuleErrorBuilder::message(sprintf(
'A file could not be loaded from %s::loadInclude',
ModuleHandlerInterface::class
))
->line($node->getStartLine())
->build()
];
}
}
}

View File

@@ -0,0 +1,80 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use PhpParser\Node;
use PhpParser\Node\Name;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\RuleErrorBuilder;
use Throwable;
use function count;
use function is_file;
use function sprintf;
/**
* Handles module_load_include dynamic file loading.
*
* @note may become deprecated and removed in D10
* @see https://www.drupal.org/project/drupal/issues/697946
*
* @extends LoadIncludeBase<Node\Expr\FuncCall>
*/
class ModuleLoadInclude extends LoadIncludeBase
{
public function getNodeType(): string
{
return Node\Expr\FuncCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->name instanceof Name) {
return [];
}
$name = (string) $node->name;
if ($name !== 'module_load_include') {
return [];
}
$args = $node->getArgs();
if (count($args) < 2) {
return [];
}
try {
// Try to invoke it similarly as the module handler itself.
[$moduleName, $filename] = $this->parseLoadIncludeArgs($args[1], $args[0], $args[2] ?? null, $scope);
$module = $this->extensionMap->getModule($moduleName);
if ($module === null) {
return [
RuleErrorBuilder::message(sprintf(
'File %s could not be loaded from module_load_include because %s module is not found.',
$filename,
$moduleName
))
->line($node->getStartLine())
->build()
];
}
$file = $module->getAbsolutePath() . DIRECTORY_SEPARATOR . $filename;
if (is_file($file)) {
require_once $file;
return [];
}
return [
RuleErrorBuilder::message(sprintf(
'File %s could not be loaded from module_load_include.',
$module->getPath() . '/' . $filename
))
->line($node->getStartLine())
->build()
];
} catch (Throwable $e) {
return [
RuleErrorBuilder::message('A file could not be loaded from module_load_include')
->line($node->getStartLine())
->build()
];
}
}
}

View File

@@ -0,0 +1,22 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal\PluginManager;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Rules\Rule;
/**
* @template TNodeType of \PhpParser\Node
* @implements Rule<TNodeType>
*/
abstract class AbstractPluginManagerRule implements Rule
{
protected function isPluginManager(ClassReflection $classReflection): bool
{
return
!$classReflection->isInterface() &&
!$classReflection->isAnonymous() &&
$classReflection->implementsInterface('Drupal\Component\Plugin\PluginManagerInterface');
}
}

View File

@@ -0,0 +1,99 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal\PluginManager;
use PhpParser\Node;
use PhpParser\Node\Stmt\ClassMethod;
use PHPStan\Analyser\Scope;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\Type;
use function array_map;
use function count;
use function sprintf;
use function strpos;
/**
* @extends AbstractPluginManagerRule<ClassMethod>
*/
class PluginManagerSetsCacheBackendRule extends AbstractPluginManagerRule
{
public function getNodeType(): string
{
return ClassMethod::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$scope->isInClass()) {
throw new ShouldNotHappenException();
}
if ($scope->isInTrait()) {
return [];
}
if ($node->name->name !== '__construct') {
return [];
}
$scopeClassReflection = $scope->getClassReflection();
if (!$this->isPluginManager($scopeClassReflection)) {
return [];
}
$hasCacheBackendSet = false;
$misnamedCacheTagWarnings = [];
foreach ($node->stmts ?? [] as $statement) {
if ($statement instanceof Node\Stmt\Expression) {
$statement = $statement->expr;
}
if (($statement instanceof Node\Expr\MethodCall) &&
($statement->name instanceof Node\Identifier) &&
$statement->name->name === 'setCacheBackend') {
// setCacheBackend accepts a cache backend, the cache key, and optional (but suggested) cache tags.
$setCacheBackendArgs = $statement->getArgs();
if (count($setCacheBackendArgs) < 2) {
continue;
}
$hasCacheBackendSet = true;
$cacheKey = array_map(
static fn (Type $type) => $type->getValue(),
$scope->getType($setCacheBackendArgs[1]->value)->getConstantStrings()
);
if (count($cacheKey) === 0) {
continue;
}
if (isset($setCacheBackendArgs[2])) {
$cacheTagsType = $scope->getType($setCacheBackendArgs[2]->value);
foreach ($cacheTagsType->getConstantArrays() as $constantArray) {
foreach ($constantArray->getValueTypes() as $valueType) {
foreach ($valueType->getConstantStrings() as $cacheTagConstantString) {
foreach ($cacheKey as $cacheKeyValue) {
if (strpos($cacheTagConstantString->getValue(), $cacheKeyValue) === false) {
$misnamedCacheTagWarnings[] = $cacheTagConstantString->getValue();
}
}
}
}
}
}
break;
}
}
$errors = [];
if (!$hasCacheBackendSet) {
$errors[] = 'Missing cache backend declaration for performance.';
}
foreach ($misnamedCacheTagWarnings as $cacheTagWarning) {
$errors[] = sprintf('%s cache tag might be unclear and does not contain the cache key in it.', $cacheTagWarning);
}
return $errors;
}
}

View File

@@ -0,0 +1,317 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use Drupal\Core\Render\Element\RenderCallbackInterface;
use Drupal\Core\Render\PlaceholderGenerator;
use Drupal\Core\Render\Renderer;
use Drupal\Core\Security\Attribute\TrustedCallback;
use Drupal\Core\Security\TrustedCallbackInterface;
use mglaman\PHPStanDrupal\Drupal\ServiceMap;
use PhpParser\Node;
use PhpParser\Node\Name;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\TrinaryLogic;
use PHPStan\Type\ClosureType;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\Generic\GenericClassStringType;
use PHPStan\Type\IntersectionType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\StaticType;
use PHPStan\Type\Type;
use PHPStan\Type\UnionType;
use PHPStan\Type\VerbosityLevel;
use function array_map;
use function array_merge;
use function class_exists;
use function count;
use function explode;
use function preg_match;
use function sprintf;
use function substr_count;
/**
* @implements Rule<Node\Expr\ArrayItem>
*/
final class RenderCallbackRule implements Rule
{
private ReflectionProvider $reflectionProvider;
private ServiceMap $serviceMap;
private array $supportedKeys = [
'#pre_render',
'#post_render',
'#access_callback',
'#lazy_builder',
'#date_time_callbacks',
'#date_date_callbacks',
];
public function __construct(ReflectionProvider $reflectionProvider, ServiceMap $serviceMap)
{
$this->reflectionProvider = $reflectionProvider;
$this->serviceMap = $serviceMap;
}
public function getNodeType(): string
{
return Node\Expr\ArrayItem::class;
}
public function processNode(Node $node, Scope $scope): array
{
$key = $node->key;
if (!$key instanceof Node\Scalar\String_) {
return [];
}
// @see https://www.drupal.org/node/2966725
$keySearch = array_search($key->value, $this->supportedKeys, true);
if ($keySearch === false) {
return [];
}
$keyChecked = $this->supportedKeys[$keySearch];
$value = $node->value;
if ($keyChecked === '#access_callback') {
return $this->doProcessNode($node->value, $scope, $keyChecked, 0);
}
if ($keyChecked === '#lazy_builder') {
if ($scope->isInClass()) {
$classReflection = $scope->getClassReflection();
$classType = new ObjectType($classReflection->getName());
// These classes use #lazy_builder in array_intersect_key. With
// PHPStan 1.6, nodes do not track their parent/next/prev which
// saves a lot of memory. But makes it harder to detect if we're
// in a call to array_intersect_key. This is an easier workaround.
$allowedTypes = new UnionType([
new ObjectType(PlaceholderGenerator::class),
new ObjectType(Renderer::class),
new ObjectType('Drupal\Tests\Core\Render\RendererPlaceholdersTest'),
]);
if ($allowedTypes->isSuperTypeOf($classType)->yes()) {
return [];
}
}
if (!$value instanceof Node\Expr\Array_) {
return [
RuleErrorBuilder::message(sprintf('The "%s" expects a callable array with arguments.', $keyChecked))
->line($node->getStartLine())->build()
];
}
if (count($value->items) === 0) {
return [];
}
// @todo take $value->items[1] and validate parameters against the callback.
return $this->doProcessNode($value->items[0]->value, $scope, $keyChecked, 0);
}
if (!$value instanceof Node\Expr\Array_) {
return [
RuleErrorBuilder::message(sprintf('The "%s" render array value expects an array of callbacks.', $keyChecked))
->line($node->getStartLine())->build()
];
}
if (count($value->items) === 0) {
return [];
}
$errors = [];
foreach ($value->items as $pos => $item) {
$errors[] = $this->doProcessNode($item->value, $scope, $keyChecked, $pos);
}
return array_merge(...$errors);
}
/**
@return (string|\PHPStan\Rules\RuleError)[] errors
*/
private function doProcessNode(Node\Expr $node, Scope $scope, string $keyChecked, int $pos): array
{
$checkIsCallable = true;
$trustedCallbackType = new UnionType([
new ObjectType(TrustedCallbackInterface::class),
new ObjectType(RenderCallbackInterface::class),
]);
$errors = [];
$errorLine = $node->getStartLine();
$type = $this->getType($node, $scope);
foreach ($type->getConstantStrings() as $constantStringType) {
if (!$constantStringType->isCallable()->yes()) {
$errors[] = RuleErrorBuilder::message(
sprintf("%s callback %s at key '%s' is not callable.", $keyChecked, $constantStringType->describe(VerbosityLevel::value()), $pos)
)->line($errorLine)->build();
} elseif ($this->reflectionProvider->hasFunction(new Name($constantStringType->getValue()), null)) {
// We can determine if the callback is callable through the type system. However, we cannot determine
// if it is just a function or a static class call (MyClass::staticFunc).
$errors[] = RuleErrorBuilder::message(
sprintf("%s callback %s at key '%s' is not trusted.", $keyChecked, $constantStringType->describe(VerbosityLevel::value()), $pos)
)->line($errorLine)
->tip('Change record: https://www.drupal.org/node/2966725.')
->build();
} else {
// @see \PHPStan\Type\Constant\ConstantStringType::isCallable
preg_match('#^([a-zA-Z_\\x7f-\\xff\\\\][a-zA-Z0-9_\\x7f-\\xff\\\\]*)::([a-zA-Z_\\x7f-\\xff][a-zA-Z0-9_\\x7f-\\xff]*)\\z#', $constantStringType->getValue(), $matches);
if (count($matches) === 0) {
$errors[] = RuleErrorBuilder::message(
sprintf("%s callback %s at key '%s' is not callable.", $keyChecked, $constantStringType->describe(VerbosityLevel::value()), $pos)
)->line($errorLine)->build();
} elseif (!$trustedCallbackType->isSuperTypeOf(new ObjectType($matches[1]))->yes()) {
$errors[] = RuleErrorBuilder::message(
sprintf("%s callback class %s at key '%s' does not implement Drupal\Core\Security\TrustedCallbackInterface.", $keyChecked, $constantStringType->describe(VerbosityLevel::value()), $pos)
)->line($errorLine)->tip('Change record: https://www.drupal.org/node/2966725.')->build();
}
}
}
foreach ($type->getConstantArrays() as $constantArrayType) {
if (!$constantArrayType->isCallable()->yes()) {
// If the right-hand side of the array is a variable, we cannot
// determine if it is callable. Bail now.
$itemType = $constantArrayType->getItemType();
if ($itemType instanceof UnionType) {
$unionConstantStrings = array_merge(...array_map(static function (Type $type) {
return $type->getConstantStrings();
}, $itemType->getTypes()));
if (count($unionConstantStrings) === 0) {
// Right-hand side of UnionType is not a constant string. We cannot determine if the dynamic
// value is callable or not.
$checkIsCallable = false;
break;
}
}
$errors[] = RuleErrorBuilder::message(
sprintf("%s callback %s at key '%s' is not callable.", $keyChecked, $constantArrayType->describe(VerbosityLevel::value()), $pos)
)->line($errorLine)->build();
continue;
}
$typeAndMethodNames = $constantArrayType->findTypeAndMethodNames();
if ($typeAndMethodNames === []) {
continue;
}
foreach ($typeAndMethodNames as $typeAndMethodName) {
$isTrustedCallbackAttribute = TrinaryLogic::createNo()->lazyOr(
$typeAndMethodName->getType()->getObjectClassReflections(),
function (ClassReflection $reflection) use ($typeAndMethodName) {
if (!class_exists(TrustedCallback::class)) {
return TrinaryLogic::createNo();
}
$hasAttribute = $reflection->getNativeReflection()
->getMethod($typeAndMethodName->getMethod())
->getAttributes(TrustedCallback::class);
return TrinaryLogic::createFromBoolean(count($hasAttribute) > 0);
}
);
$isTrustedCallbackInterfaceType = $trustedCallbackType->isSuperTypeOf($typeAndMethodName->getType())->yes();
if (!$isTrustedCallbackInterfaceType && !$isTrustedCallbackAttribute->yes()) {
if (class_exists(TrustedCallback::class)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
"%s callback method '%s' at key '%s' does not implement attribute \Drupal\Core\Security\Attribute\TrustedCallback.",
$keyChecked,
$constantArrayType->describe(VerbosityLevel::value()),
$pos
)
)->line($errorLine)->tip('Change record: https://www.drupal.org/node/3349470')->build();
} else {
$errors[] = RuleErrorBuilder::message(
sprintf(
"%s callback class '%s' at key '%s' does not implement Drupal\Core\Security\TrustedCallbackInterface.",
$keyChecked,
$typeAndMethodName->getType()->describe(VerbosityLevel::value()),
$pos
)
)->line($errorLine)->tip('Change record: https://www.drupal.org/node/2966725.')->build();
}
}
}
}
// @todo move to its own rule for 1.2.0, FormClosureSerializationRule.
if (($type instanceof ClosureType) && $scope->isInClass()) {
$classReflection = $scope->getClassReflection();
$classType = new ObjectType($classReflection->getName());
$formType = new ObjectType('\Drupal\Core\Form\FormInterface');
if ($formType->isSuperTypeOf($classType)->yes()) {
$errors[] = RuleErrorBuilder::message(
sprintf("%s may not contain a closure at key '%s' as forms may be serialized and serialization of closures is not allowed.", $keyChecked, $pos)
)->line($errorLine)->build();
}
}
if (count($errors) === 0 && ($checkIsCallable && !$type->isCallable()->yes())) {
$errors[] = RuleErrorBuilder::message(
sprintf("%s value '%s' at key '%s' is invalid.", $keyChecked, $type->describe(VerbosityLevel::value()), $pos)
)->line($errorLine)->build();
}
return $errors;
}
// @todo move to a helper, as Drupal uses `service:method` references a lot.
private function getType(Node\Expr $node, Scope $scope): Type
{
$type = $scope->getType($node);
if ($type instanceof IntersectionType) {
// Covers concatenation of static::class . '::methodName'.
if ($node instanceof Node\Expr\BinaryOp\Concat) {
$leftType = $scope->getType($node->left);
$rightType = $scope->getType($node->right);
if ($rightType instanceof ConstantStringType && $leftType instanceof GenericClassStringType && $leftType->getGenericType() instanceof StaticType) {
return new ConstantArrayType(
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
[
$leftType->getGenericType(),
new ConstantStringType(ltrim($rightType->getValue(), ':'))
]
);
}
}
} elseif ($type instanceof ConstantStringType) {
if ($type->isClassStringType()->yes()) {
return $type;
}
// Covers \Drupal\Core\Controller\ControllerResolver::createController.
if (substr_count($type->getValue(), ':') === 1) {
[$class_or_service, $method] = explode(':', $type->getValue(), 2);
$serviceDefinition = $this->serviceMap->getService($class_or_service);
if ($serviceDefinition === null || $serviceDefinition->getClass() === null) {
return $type;
}
return new ConstantArrayType(
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
[
new ObjectType($serviceDefinition->getClass()),
new ConstantStringType($method)
]
);
}
// @see \PHPStan\Type\Constant\ConstantStringType::isCallable
preg_match('#^([a-zA-Z_\\x7f-\\xff\\\\][a-zA-Z0-9_\\x7f-\\xff\\\\]*)::([a-zA-Z_\\x7f-\\xff][a-zA-Z0-9_\\x7f-\\xff]*)\\z#', $type->getValue(), $matches);
if (count($matches) > 0) {
return new ConstantArrayType(
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
[
new StaticType($this->reflectionProvider->getClass($matches[1])),
new ConstantStringType($matches[2])
]
);
}
}
return $type;
}
}

View File

@@ -0,0 +1,60 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use Drupal;
use mglaman\PHPStanDrupal\Internal\DeprecatedScopeCheck;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use Symfony\Component\HttpFoundation\RequestStack as SymfonyRequestStack;
use function explode;
use function sprintf;
/**
* @implements Rule<Node\Expr\MethodCall>
*/
final class RequestStackGetMainRequestRule implements Rule
{
public function getNodeType(): string
{
return Node\Expr\MethodCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (DeprecatedScopeCheck::inDeprecatedScope($scope)) {
return [];
}
[$major, $minor] = explode('.', Drupal::VERSION, 3);
// Only valid for 9.3 -> 9.5. Deprecated in Drupal 10.
if (($major !== '9' || (int) $minor < 3)) {
return [];
}
if (!$node->name instanceof Node\Identifier) {
return [];
}
$method_name = $node->name->toString();
if ($method_name !== 'getMasterRequest') {
return [];
}
$type = $scope->getType($node->var);
$symfonyRequestStackType = new ObjectType(SymfonyRequestStack::class);
if ($symfonyRequestStackType->isSuperTypeOf($type)->yes()) {
$message = sprintf(
'%s::getMasterRequest() is deprecated in drupal:9.3.0 and is removed from drupal:10.0.0 for Symfony 6 compatibility. Use the forward compatibility shim class %s and its getMainRequest() method instead.',
SymfonyRequestStack::class,
'Drupal\Core\Http\RequestStack'
);
return [
RuleErrorBuilder::message($message)
->tip('Change record: https://www.drupal.org/node/3253744')
->build(),
];
}
return [];
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\ClassPropertyNode;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPUnit\Framework\TestCase;
use function in_array;
use function sprintf;
/**
* @implements Rule<ClassPropertyNode>
*/
class TestClassesProtectedPropertyModulesRule implements Rule
{
public function getNodeType(): string
{
return ClassPropertyNode::class;
}
/**
* @throws \PHPStan\ShouldNotHappenException
*/
public function processNode(Node $node, Scope $scope): array
{
if ($node->getName() !== 'modules') {
return [];
}
$scopeClassReflection = $node->getClassReflection();
if ($scopeClassReflection->isAnonymous()) {
return [];
}
if (!in_array(TestCase::class, $scopeClassReflection->getParentClassesNames(), true)) {
return [];
}
if ($node->isPublic()) {
return [
RuleErrorBuilder::message(
sprintf('Property %s::$modules property must be protected.', $scopeClassReflection->getDisplayName())
)->tip('Change record: https://www.drupal.org/node/2909426')->build(),
];
}
return [];
}
}

View File

@@ -0,0 +1,112 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal\Tests;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use PHPStan\Type\TypeCombinator;
use PHPUnit\Framework\Test;
use function count;
use function in_array;
use function interface_exists;
use function method_exists;
use function substr_compare;
/**
* @implements Rule<Node\Stmt\Class_>
*/
final class BrowserTestBaseDefaultThemeRule implements Rule
{
public function getNodeType(): string
{
return Node\Stmt\Class_::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!interface_exists(Test::class)) {
return [];
}
if ($node->extends === null) {
return [];
}
if ($node->namespacedName === null) {
return [];
}
// Only inspect tests.
// @todo replace this str_ends_with() when php 8 is required.
if (0 !== substr_compare($node->namespacedName->getLast(), 'Test', -4)) {
return [];
}
// Do some cheap preflight tests to make sure the class is in a
// namespace that makes sense to inspect.
// @phpstan-ignore-next-line
$parts = method_exists($node->namespacedName, 'getParts') ? $node->namespacedName->getParts() : $node->namespacedName->parts;
// The namespace is too short to be a test so skip inspection.
if (count($parts) < 3) {
return [];
}
// If the 4th component matches it's a module test. If the 2nd, core.
if ($parts[3] !== 'Functional'
&& $parts [3] !== 'FunctionalJavascript'
&& $parts[1] !== 'FunctionalTests'
&& $parts[1] !== 'FunctionalJavascriptTests') {
return [];
}
$classType = $scope->resolveTypeByName($node->namespacedName);
assert($classType instanceof ObjectType);
$browserTestBaseType = new ObjectType('Drupal\\Tests\\BrowserTestBase');
if (!$browserTestBaseType->isSuperTypeOf($classType)->yes()) {
return [];
}
$excludedTestTypes = TypeCombinator::union(
new ObjectType('Drupal\\FunctionalTests\\Update\\UpdatePathTestBase'),
new ObjectType('Drupal\\FunctionalTests\\Installer\\InstallerConfigDirectoryTestBase'),
new ObjectType('Drupal\\FunctionalTests\\Installer\\InstallerExistingConfigTestBase')
);
if ($excludedTestTypes->isSuperTypeOf($classType)->yes()) {
return [];
}
$reflection = $classType->getClassReflection();
assert($reflection !== null);
if ($reflection->isAbstract()) {
return [];
}
$defaultProperties = $reflection->getNativeReflection()->getDefaultProperties();
$profile = $defaultProperties['profile'] ?? null;
$testingProfilesWithoutThemes = [
'testing',
'nightwatch_testing',
'testing_config_overrides',
'testing_missing_dependencies',
'testing_multilingual',
'testing_multilingual_with_english',
'testing_requirements',
];
if ($profile !== null && !in_array($profile, $testingProfilesWithoutThemes, true)) {
return [];
}
$defaultTheme = $defaultProperties['defaultTheme'] ?? null;
if ($defaultTheme === null || $defaultTheme === '') {
return [
RuleErrorBuilder::message('Drupal\Tests\BrowserTestBase::$defaultTheme is required. See https://www.drupal.org/node/3083055, which includes recommendations on which theme to use.')
->line($node->getStartLine())->build(),
];
}
return [];
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Rules\Drupal\Tests;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use PHPUnit\Framework\TestCase;
/**
* Implements rule that all non-abstract test classes name should end with "Test".
*
* @implements Rule<Node\Stmt\Class_>
*/
final class TestClassSuffixNameRule implements Rule
{
public function getNodeType(): string
{
return Node\Stmt\Class_::class;
}
public function processNode(Node $node, Scope $scope): array
{
// We're not interested in non-extending classes.
if ($node->extends === null) {
return [];
}
// We're not interested in abstract classes.
if ($node->isAbstract()) {
return [];
}
// We need a namespaced class name.
if ($node->namespacedName === null) {
return [];
}
// We're only interested in \PHPUnit\Framework\TestCase subtype classes.
$classType = $scope->resolveTypeByName($node->namespacedName);
$phpUnitFrameworkTestCaseType = new ObjectType(TestCase::class);
if (!$phpUnitFrameworkTestCaseType->isSuperTypeOf($classType)->yes()) {
return [];
}
// Check class name has suffix "Test".
// @todo replace this str_ends_with() when php 8 is required.
if (substr_compare($node->namespacedName->getLast(), 'Test', -4) === 0) {
return [];
}
return [
RuleErrorBuilder::message(
sprintf(
'Non-abstract test classes names should always have the suffix "Test", found incorrect class name "%s".',
$node->name,
)
)
->line($node->getStartLine())
->tip('See https://www.drupal.org/docs/develop/standards/php/object-oriented-code#naming')
->build()
];
}
}

View File

@@ -0,0 +1,101 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type;
use mglaman\PHPStanDrupal\Drupal\ServiceMap;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\Constant\ConstantBooleanType;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\NullType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use Symfony\Component\DependencyInjection\ContainerInterface;
use function count;
use function in_array;
class ContainerDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
/**
* @var ServiceMap
*/
private ServiceMap $serviceMap;
public function __construct(ServiceMap $serviceMap)
{
$this->serviceMap = $serviceMap;
}
public function getClass(): string
{
return ContainerInterface::class;
}
public function isMethodSupported(MethodReflection $methodReflection): bool
{
return in_array($methodReflection->getName(), ['get', 'has'], true);
}
public function getTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope
): Type {
$returnType = ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
$methodName = $methodReflection->getName();
if ($methodName === 'has') {
$args = $methodCall->getArgs();
if (count($args) !== 1) {
return $returnType;
}
$types = [];
$argType = $scope->getType($args[0]->value);
foreach ($argType->getConstantStrings() as $constantStringType) {
$serviceId = $constantStringType->getValue();
$service = $this->serviceMap->getService($serviceId);
$types[] = new ConstantBooleanType($service !== null);
}
return TypeCombinator::union(...$types);
} elseif ($methodName === 'get') {
$args = $methodCall->getArgs();
if (count($args) === 0) {
return $returnType;
}
$types = [];
if (isset($args[1])) {
$invalidBehaviour = $scope->getType($args[1]->value);
foreach ($invalidBehaviour->getConstantScalarValues() as $value) {
if ($value === ContainerInterface::NULL_ON_INVALID_REFERENCE) {
$types[] = new NullType();
break;
}
}
}
$argType = $scope->getType($args[0]->value);
foreach ($argType->getConstantStrings() as $constantStringType) {
$serviceId = $constantStringType->getValue();
$service = $this->serviceMap->getService($serviceId);
$types[] = $service !== null ? $service->getType() : $returnType;
}
return TypeCombinator::union(...$types);
}
return $returnType;
}
}

View File

@@ -0,0 +1,52 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type;
use Drupal\Core\DependencyInjection\ClassResolverInterface;
use mglaman\PHPStanDrupal\Drupal\ServiceMap;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\Type;
use function count;
class DrupalClassResolverDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
/**
* @var ServiceMap
*/
private $serviceMap;
public function __construct(ServiceMap $serviceMap)
{
$this->serviceMap = $serviceMap;
}
public function getClass(): string
{
return ClassResolverInterface::class;
}
public function isMethodSupported(MethodReflection $methodReflection): bool
{
return $methodReflection->getName() === 'getInstanceFromDefinition';
}
public function getTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope
): Type {
if (0 === count($methodCall->getArgs())) {
return ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
}
return DrupalClassResolverReturnType::getType($methodReflection, $methodCall, $scope, $this->serviceMap);
}
}

View File

@@ -0,0 +1,49 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type;
use Drupal;
use Drupal\Core\DependencyInjection\ClassResolverInterface;
use mglaman\PHPStanDrupal\Drupal\ServiceMap;
use PhpParser\Node\Expr\StaticCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\DynamicStaticMethodReturnTypeExtension;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
use function count;
class DrupalClassResolverDynamicStaticReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension
{
/**
* @var ServiceMap
*/
private $serviceMap;
public function __construct(ServiceMap $serviceMap)
{
$this->serviceMap = $serviceMap;
}
public function getClass(): string
{
return Drupal::class;
}
public function isStaticMethodSupported(MethodReflection $methodReflection): bool
{
return $methodReflection->getName() === 'classResolver';
}
public function getTypeFromStaticMethodCall(
MethodReflection $methodReflection,
StaticCall $methodCall,
Scope $scope
): Type {
if (0 === count($methodCall->getArgs())) {
return new ObjectType(ClassResolverInterface::class);
}
return DrupalClassResolverReturnType::getType($methodReflection, $methodCall, $scope, $this->serviceMap);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type;
use mglaman\PHPStanDrupal\Drupal\DrupalServiceDefinition;
use mglaman\PHPStanDrupal\Drupal\ServiceMap;
use PhpParser\Node\Expr\CallLike;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
use function count;
final class DrupalClassResolverReturnType
{
public static function getType(
MethodReflection $methodReflection,
CallLike $methodCall,
Scope $scope,
ServiceMap $serviceMap
): Type {
$arg1 = $scope->getType($methodCall->getArgs()[0]->value);
if (count($arg1->getConstantStrings()) === 0) {
return ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
}
$serviceName = $arg1->getConstantStrings()[0];
$serviceDefinition = $serviceMap->getService($serviceName->getValue());
if ($serviceDefinition instanceof DrupalServiceDefinition) {
return $serviceDefinition->getType();
}
return new ObjectType($serviceName->getValue());
}
}

View File

@@ -0,0 +1,85 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type;
use Drupal;
use mglaman\PHPStanDrupal\Drupal\DrupalServiceDefinition;
use mglaman\PHPStanDrupal\Drupal\ServiceMap;
use PhpParser\Node\Expr\ClassConstFetch;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Name\FullyQualified;
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\VariadicPlaceholder;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\DynamicStaticMethodReturnTypeExtension;
use PHPStan\Type\Type;
class DrupalServiceDynamicReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension
{
/**
* @var ServiceMap
*/
private $serviceMap;
public function __construct(ServiceMap $serviceMap)
{
$this->serviceMap = $serviceMap;
}
public function getClass(): string
{
return Drupal::class;
}
public function isStaticMethodSupported(MethodReflection $methodReflection): bool
{
return $methodReflection->getName() === 'service';
}
public function getTypeFromStaticMethodCall(
MethodReflection $methodReflection,
StaticCall $methodCall,
Scope $scope
): Type {
$returnType = ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
if (!isset($methodCall->args[0])) {
return $returnType;
}
$arg1 = $methodCall->args[0];
if ($arg1 instanceof VariadicPlaceholder) {
throw new ShouldNotHappenException();
}
$arg1 = $arg1->value;
if ($arg1 instanceof String_) {
$serviceId = $arg1->value;
return $this->getServiceType($serviceId) ?? $returnType;
}
if ($arg1 instanceof ClassConstFetch && $arg1->class instanceof FullyQualified) {
$serviceId = (string) $arg1->class;
return $this->getServiceType($serviceId) ?? $returnType;
}
return $returnType;
}
protected function getServiceType(string $serviceId): ?Type
{
$service = $this->serviceMap->getService($serviceId);
if ($service instanceof DrupalServiceDefinition) {
return $service->getType();
}
return null;
}
}

View File

@@ -0,0 +1,99 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type;
use Drupal;
use Drupal\Core\Config\Entity\ConfigEntityStorageInterface;
use Drupal\Core\Entity\ContentEntityStorageInterface;
use mglaman\PHPStanDrupal\Drupal\EntityDataRepository;
use mglaman\PHPStanDrupal\Type\EntityQuery\ConfigEntityQueryType;
use mglaman\PHPStanDrupal\Type\EntityQuery\ContentEntityQueryType;
use mglaman\PHPStanDrupal\Type\EntityQuery\EntityQueryType;
use PhpParser\Node\Expr\StaticCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\DynamicStaticMethodReturnTypeExtension;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
class DrupalStaticEntityQueryDynamicReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension
{
/**
* @var EntityDataRepository
*/
private $entityDataRepository;
public function __construct(EntityDataRepository $entityDataRepository)
{
$this->entityDataRepository = $entityDataRepository;
}
public function getClass(): string
{
return Drupal::class;
}
public function isStaticMethodSupported(MethodReflection $methodReflection): bool
{
return $methodReflection->getName() === 'entityQuery';
}
public function getTypeFromStaticMethodCall(
MethodReflection $methodReflection,
StaticCall $methodCall,
Scope $scope
): Type {
$returnType = ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
if (!$returnType instanceof ObjectType) {
return $returnType;
}
$args = $methodCall->getArgs();
if (count($args) !== 1) {
return $returnType;
}
$type = $scope->getType($args[0]->value);
if (count($type->getConstantStrings()) === 0) {
// We're unsure what specific EntityQueryType it is, so let's stick
// with the general class itself to ensure it gets access checked.
return new EntityQueryType(
$returnType->getClassName(),
$returnType->getSubtractedType(),
$returnType->getClassReflection()
);
}
$entityTypeId = $type->getConstantStrings()[0]->getValue();
$entityType = $this->entityDataRepository->get($entityTypeId);
$entityStorageType = $entityType->getStorageType();
if ($entityStorageType === null) {
return $returnType;
}
if ((new ObjectType(ContentEntityStorageInterface::class))->isSuperTypeOf($entityStorageType)->yes()) {
return new ContentEntityQueryType(
$returnType->getClassName(),
$returnType->getSubtractedType(),
$returnType->getClassReflection()
);
}
if ((new ObjectType(ConfigEntityStorageInterface::class))->isSuperTypeOf($entityStorageType)->yes()) {
return new ConfigEntityQueryType(
$returnType->getClassName(),
$returnType->getSubtractedType(),
$returnType->getClassReflection()
);
}
return new EntityQueryType(
$returnType->getClassName(),
$returnType->getSubtractedType(),
$returnType->getClassReflection()
);
}
}

View File

@@ -0,0 +1,56 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Entity\EntityAccessControlHandlerInterface;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\BooleanType;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
use function count;
use function in_array;
final class EntityAccessControlHandlerReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
public function getClass(): string
{
return EntityAccessControlHandlerInterface::class;
}
public function isMethodSupported(MethodReflection $methodReflection): bool
{
return in_array($methodReflection->getName(), ['access', 'createAccess', 'fieldAccess'], true);
}
public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type
{
$returnType = new BooleanType();
$args = $methodCall->getArgs();
$arg = null;
if ($methodReflection->getName() === 'access' && count($args) === 4) {
$arg = $args[3];
}
if ($methodReflection->getName() === 'createAccess' && count($args) === 4) {
$arg = $args[3];
}
if ($methodReflection->getName() === 'fieldAccess' && count($args) === 5) {
$arg = $args[4];
}
if ($arg === null) {
return $returnType;
}
$returnAsObjectArg = $scope->getType($arg->value);
if (!$returnAsObjectArg->isBoolean()->yes()) {
return $returnType;
}
return $returnAsObjectArg->isTrue()->yes() ? new ObjectType(AccessResultInterface::class) : new BooleanType();
}
}

View File

@@ -0,0 +1,59 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityQuery;
use Drupal\Core\Entity\Query\QueryInterface;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Analyser\SpecifiedTypes;
use PHPStan\Analyser\TypeSpecifier;
use PHPStan\Analyser\TypeSpecifierAwareExtension;
use PHPStan\Analyser\TypeSpecifierContext;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\MethodTypeSpecifyingExtension;
final class AccessCheckTypeSpecifyingExtension implements MethodTypeSpecifyingExtension, TypeSpecifierAwareExtension
{
private TypeSpecifier $typeSpecifier;
public function setTypeSpecifier(TypeSpecifier $typeSpecifier) : void
{
$this->typeSpecifier = $typeSpecifier;
}
public function getClass(): string
{
return QueryInterface::class;
}
public function isMethodSupported(
MethodReflection $methodReflection,
MethodCall $node,
TypeSpecifierContext $context
): bool {
return $methodReflection->getName() === 'accessCheck';
}
public function specifyTypes(
MethodReflection $methodReflection,
MethodCall $node,
Scope $scope,
TypeSpecifierContext $context
): SpecifiedTypes {
$returnType = ParametersAcceptorSelector::selectFromArgs(
$scope,
[],
$methodReflection->getVariants()
)->getReturnType();
$expr = $node->var;
if (!$returnType instanceof EntityQueryType) {
return new SpecifiedTypes([]);
}
return $this->typeSpecifier->create(
$expr,
$returnType->withAccessCheck(),
TypeSpecifierContext::createTruthy(),
true
);
}
}

View File

@@ -0,0 +1,11 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityQuery;
/**
* Type used to represent an entity query instance for config entity query.
*/
final class ConfigEntityQueryType extends EntityQueryType
{
}

View File

@@ -0,0 +1,11 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityQuery;
/**
* Type used to represent an entity query instance for content entity query.
*/
final class ContentEntityQueryType extends EntityQueryType
{
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityQuery;
use Drupal\Core\Entity\Query\QueryInterface;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\Type;
class EntityQueryAccessCheckDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
public function getClass(): string
{
return QueryInterface::class;
}
public function isMethodSupported(MethodReflection $methodReflection): bool
{
return 'accessCheck' === $methodReflection->getName();
}
public function getTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope
): Type {
$varType = $scope->getType($methodCall->var);
if (!$varType instanceof EntityQueryType) {
return ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
}
return $varType->withAccessCheck();
}
}

View File

@@ -0,0 +1,11 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityQuery;
/**
* Type used to represent an entity query instance as count query.
*/
final class EntityQueryCountType extends EntityQueryType
{
}

View File

@@ -0,0 +1,86 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityQuery;
use Drupal\Core\Entity\Query\QueryInterface;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\ArrayType;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\IntegerType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use function in_array;
class EntityQueryDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
public function getClass(): string
{
return QueryInterface::class;
}
public function isMethodSupported(MethodReflection $methodReflection): bool
{
return in_array($methodReflection->getName(), [
'count',
'execute',
], true);
}
public function getTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope
): Type {
$defaultReturnType = ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
$varType = $scope->getType($methodCall->var);
$methodName = $methodReflection->getName();
if (!$varType instanceof ObjectType) {
return $defaultReturnType;
}
if ($methodName === 'count') {
if ($varType instanceof EntityQueryType) {
return $varType->asCount();
}
// By now we are sure we can't determine anything about what query
// the count method is on, so we ignore it.
return $defaultReturnType;
}
if ($methodName === 'execute') {
if (!$varType instanceof EntityQueryType) {
return $defaultReturnType;
}
if ($varType->isCount()) {
return $varType->hasAccessCheck()
? new IntegerType()
: new EntityQueryExecuteWithoutAccessCheckCountType();
}
if ($varType instanceof ConfigEntityQueryType) {
return $varType->hasAccessCheck()
? new ArrayType(new StringType(), new StringType())
: new EntityQueryExecuteWithoutAccessCheckType(new StringType(), new StringType());
}
if ($varType instanceof ContentEntityQueryType) {
return $varType->hasAccessCheck()
? new ArrayType(new IntegerType(), new StringType())
: new EntityQueryExecuteWithoutAccessCheckType(new IntegerType(), new StringType());
}
return $varType->hasAccessCheck()
? new ArrayType(new IntegerType(), new StringType())
: new EntityQueryExecuteWithoutAccessCheckType(new IntegerType(), new StringType());
}
return $defaultReturnType;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityQuery;
use PHPStan\Type\IntegerType;
final class EntityQueryExecuteWithoutAccessCheckCountType extends IntegerType
{
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityQuery;
use PHPStan\Type\ArrayType;
final class EntityQueryExecuteWithoutAccessCheckType extends ArrayType
{
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityQuery;
use PHPStan\Type\ObjectType;
use function implode;
class EntityQueryType extends ObjectType
{
private bool $hasAccessCheck = false;
private bool $isCount = false;
public function hasAccessCheck(): bool
{
return $this->hasAccessCheck;
}
public function isCount(): bool
{
return $this->isCount;
}
public function withAccessCheck(): self
{
// The constructor of ObjectType is under backward compatibility promise.
// @see https://phpstan.org/developing-extensions/backward-compatibility-promise
// @phpstan-ignore-next-line
$type = new static(
$this->getClassName(),
$this->getSubtractedType(),
$this->getClassReflection()
);
$type->hasAccessCheck = true;
$type->isCount = $this->isCount;
return $type;
}
public function asCount(): self
{
// @phpstan-ignore-next-line
$type = new static(
$this->getClassName(),
$this->getSubtractedType(),
$this->getClassReflection()
);
$type->hasAccessCheck = $this->hasAccessCheck;
$type->isCount = true;
return $type;
}
protected function describeAdditionalCacheKey(): string
{
$parts = [
$this->hasAccessCheck ? 'with-access-check' : 'without-access-check',
$this->isCount ? '' : 'count'
];
return implode('-', $parts);
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Entity\EntityRepositoryInterface;
use mglaman\PHPStanDrupal\Drupal\EntityDataRepository;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\ArrayType;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\IntegerType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
final class EntityRepositoryReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
/**
* @var EntityDataRepository
*/
private $entityDataRepository;
public function __construct(EntityDataRepository $entityDataRepository)
{
$this->entityDataRepository = $entityDataRepository;
}
public function getClass(): string
{
return EntityRepositoryInterface::class;
}
public function isMethodSupported(MethodReflection $methodReflection): bool
{
return in_array(
$methodReflection->getName(),
[
'getTranslationFromContext',
'loadEntityByUuid',
'loadEntityByConfigTarget',
'getActive',
'getActiveMultiple',
'getCanonical',
'getCanonicalMultiple',
],
true
);
}
public function getTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope
): ?Type {
$methodName = $methodReflection->getName();
$methodArgs = $methodCall->getArgs();
$returnType = ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
if (count($methodArgs) === 0) {
return $returnType;
}
if ($methodName === 'getTranslationFromContext') {
return $scope->getType($methodArgs[0]->value);
}
$entityObjectTypes = [];
$entityIdArg = $scope->getType($methodArgs[0]->value);
foreach ($entityIdArg->getConstantStrings() as $constantStringType) {
$entityObjectTypes[] = $this->entityDataRepository->get($constantStringType->getValue())->getClassType() ?? $returnType;
}
$entityTypes = TypeCombinator::union(...$entityObjectTypes);
if ($returnType->isArray()->no()) {
if ($returnType->isNull()->maybe()) {
$entityTypes = TypeCombinator::addNull($entityTypes);
}
return $entityTypes;
}
if ((new ObjectType(ConfigEntityInterface::class))->isSuperTypeOf($entityTypes)->yes()) {
$keyType = new StringType();
} else {
$keyType = new IntegerType();
}
return new ArrayType($keyType, $entityTypes);
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityStorage;
final class ConfigEntityStorageType extends EntityStorageType
{
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityStorage;
final class ContentEntityStorageType extends EntityStorageType
{
}

View File

@@ -0,0 +1,111 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityStorage;
use Drupal\Core\Config\Entity\ConfigEntityStorageInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use mglaman\PHPStanDrupal\Drupal\EntityDataRepository;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\ArrayType;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\IntegerType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use function in_array;
class EntityStorageDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
/**
* @var EntityDataRepository
*/
private $entityDataRepository;
public function __construct(EntityDataRepository $entityDataRepository)
{
$this->entityDataRepository = $entityDataRepository;
}
public function getClass(): string
{
return EntityStorageInterface::class;
}
public function isMethodSupported(MethodReflection $methodReflection): bool
{
return in_array(
$methodReflection->getName(),
[
'create',
'load',
'loadMultiple',
'loadByProperties',
'loadUnchanged',
],
true
);
}
public function getTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope
): Type {
$callerType = $scope->getType($methodCall->var);
if (!$callerType instanceof ObjectType) {
return ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
}
if (!$callerType instanceof EntityStorageType) {
$resolvedEntityType = $this->entityDataRepository->resolveFromStorage($callerType);
if ($resolvedEntityType === null) {
return ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
}
$type = $resolvedEntityType->getClassType();
} else {
$type = $this->entityDataRepository->get($callerType->getEntityTypeId())->getClassType();
}
if ($type === null) {
return ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
}
if (in_array($methodReflection->getName(), ['load', 'loadUnchanged'], true)) {
return TypeCombinator::addNull($type);
}
if (in_array($methodReflection->getName(), ['loadMultiple', 'loadByProperties'], true)) {
if ((new ObjectType(ConfigEntityStorageInterface::class))->isSuperTypeOf($callerType)->yes()) {
return new ArrayType(new StringType(), $type);
}
return new ArrayType(new IntegerType(), $type);
}
if ($methodReflection->getName() === 'create') {
return $type;
}
return ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityStorage;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
class EntityStorageType extends ObjectType
{
/**
* @var string
*/
private $entityTypeId;
public function __construct(
string $entityTypeId,
string $className,
?Type $subtractedType = null,
?ClassReflection $classReflection = null
) {
parent::__construct($className, $subtractedType, $classReflection);
$this->entityTypeId = $entityTypeId;
}
public function getEntityTypeId(): string
{
return $this->entityTypeId;
}
}

View File

@@ -0,0 +1,75 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type\EntityStorage;
use Drupal\Core\Config\Entity\ConfigEntityStorageInterface;
use Drupal\Core\Entity\ContentEntityStorageInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use mglaman\PHPStanDrupal\Type\EntityQuery\ConfigEntityQueryType;
use mglaman\PHPStanDrupal\Type\EntityQuery\ContentEntityQueryType;
use mglaman\PHPStanDrupal\Type\EntityQuery\EntityQueryType;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
use function in_array;
final class GetQueryReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
public function getClass(): string
{
return EntityStorageInterface::class;
}
public function isMethodSupported(MethodReflection $methodReflection): bool
{
return in_array($methodReflection->getName(), [
'getQuery',
'getAggregateQuery',
], true);
}
public function getTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope
): Type {
$returnType = ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
if (!$returnType instanceof ObjectType) {
return $returnType;
}
$callerType = $scope->getType($methodCall->var);
if (!$callerType->isObject()->yes()) {
return $returnType;
}
if ((new ObjectType(ContentEntityStorageInterface::class))->isSuperTypeOf($callerType)->yes()) {
return new ContentEntityQueryType(
$returnType->getClassName(),
$returnType->getSubtractedType(),
$returnType->getClassReflection()
);
}
if ((new ObjectType(ConfigEntityStorageInterface::class))->isSuperTypeOf($callerType)->yes()) {
return new ConfigEntityQueryType(
$returnType->getClassName(),
$returnType->getSubtractedType(),
$returnType->getClassReflection()
);
}
return new EntityQueryType(
$returnType->getClassName(),
$returnType->getSubtractedType(),
$returnType->getClassReflection()
);
}
}

View File

@@ -0,0 +1,93 @@
<?php declare(strict_types=1);
namespace mglaman\PHPStanDrupal\Type;
use mglaman\PHPStanDrupal\Drupal\EntityDataRepository;
use mglaman\PHPStanDrupal\Type\EntityStorage\EntityStorageType;
use PhpParser\Node\Expr\BinaryOp\Concat;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\VariadicPlaceholder;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
class EntityTypeManagerGetStorageDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
/**
* @var EntityDataRepository
*/
private $entityDataRepository;
/**
* EntityTypeManagerGetStorageDynamicReturnTypeExtension constructor.
*
* @param EntityDataRepository $entityDataRepository
*/
public function __construct(EntityDataRepository $entityDataRepository)
{
$this->entityDataRepository = $entityDataRepository;
}
public function getClass(): string
{
return 'Drupal\Core\Entity\EntityTypeManagerInterface';
}
public function isMethodSupported(MethodReflection $methodReflection): bool
{
return $methodReflection->getName() === 'getStorage';
}
public function getTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope
): Type {
$returnType = ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$methodReflection->getVariants()
)->getReturnType();
if (!isset($methodCall->args[0])) {
// Parameter is required.
throw new ShouldNotHappenException();
}
$arg1 = $methodCall->args[0];
if ($arg1 instanceof VariadicPlaceholder) {
throw new ShouldNotHappenException();
}
$arg1 = $arg1->value;
// @todo handle where the first param is EntityTypeInterface::id()
if ($arg1 instanceof MethodCall) {
// There may not be much that can be done, since it's a generic EntityTypeInterface.
return $returnType;
}
// @todo handle concat ie: entity_{$display_context}_display for entity_form_display or entity_view_display
if ($arg1 instanceof Concat) {
return $returnType;
}
$type = $scope->getType($arg1);
if (count($type->getConstantStrings()) === 0) {
return $returnType;
}
$entityTypeId = $type->getConstantStrings()[0]->getValue();
$storageType = $this->entityDataRepository->get($entityTypeId)->getStorageType();
if ($storageType !== null) {
return $storageType;
}
if ($returnType instanceof ObjectType) {
return new EntityStorageType($entityTypeId, $returnType->getClassName());
}
return $returnType;
}
}