diff --git a/extension.neon b/extension.neon index 94b60a20..398bc7e1 100644 --- a/extension.neon +++ b/extension.neon @@ -43,3 +43,8 @@ services: - class: PHPStan\Reflection\EntityFieldsViaMagicReflectionExtension tags: [phpstan.broker.propertiesClassReflectionExtension] + - + class: PHPStan\Rules\Drupal\LoadIncludes + tags: [phpstan.rules.rule] + arguments: + - %drupal.drupal_root% diff --git a/src/Drupal/ExtensionDiscovery.php b/src/Drupal/ExtensionDiscovery.php index 104393e5..c58677c1 100644 --- a/src/Drupal/ExtensionDiscovery.php +++ b/src/Drupal/ExtensionDiscovery.php @@ -118,6 +118,15 @@ public function __construct($root) */ public function scan($type) { + static $scanresult; + if (!$scanresult) { + $scanresult = []; + } + + if (isset($scanresult[$type])) { + return $scanresult[$type]; + } + $searchdirs = []; // Search the core directory. $searchdirs[static::ORIGIN_CORE] = 'core'; @@ -152,7 +161,8 @@ public function scan($type) $files = $this->sort($files, $origin_weights); // Process and return the list of extensions keyed by extension name. - return $this->process($files); + $scanresult[$type] = $this->process($files); + return $scanresult[$type]; } /** diff --git a/src/Rules/Drupal/LoadIncludes.php b/src/Rules/Drupal/LoadIncludes.php new file mode 100644 index 00000000..ffd13bca --- /dev/null +++ b/src/Rules/Drupal/LoadIncludes.php @@ -0,0 +1,103 @@ +projectRoot = $project_root; + } + + public function getNodeType(): string + { + return Node\Expr\MethodCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + assert($node instanceof Node\Expr\MethodCall); + if (!$node->name instanceof Node\Identifier) { + return []; + } + $method_name = $node->name->toString(); + if ($method_name !== 'loadInclude') { + return []; + } + $variable = $node->var; + if (!$variable instanceof Node\Expr\Variable) { + return []; + } + $var_name = $variable->name; + if (!is_string($var_name)) { + throw new ShouldNotHappenException(sprintf('Expected string for variable in %s, please open an issue on GitHub https://github.com/mglaman/phpstan-drupal/issues', get_called_class())); + } + $type = $scope->getVariableType($var_name); + assert($type instanceof ObjectType); + if (!class_exists($type->getClassName()) && !interface_exists($type->getClassName())) { + throw new ShouldNotHappenException(sprintf('Could not find class for %s from reflection.', get_called_class())); + } + + try { + $reflected = new \ReflectionClass($type->getClassName()); + if (!$reflected->implementsInterface(ModuleHandlerInterface::class)) { + return []; + } + // Try to invoke it similarily as the module handler itself. + $finder = new DrupalFinder(); + $finder->locateRoot($this->projectRoot); + $drupal_root = $finder->getDrupalRoot(); + $extensionDiscovery = new ExtensionDiscovery($drupal_root); + $modules = $extensionDiscovery->scan('module'); + $module_arg = $node->args[0]; + assert($module_arg->value instanceof Node\Scalar\String_); + $type_arg = $node->args[1]; + assert($type_arg->value instanceof Node\Scalar\String_); + $name_arg = $node->args[2] ?? null; + + if ($name_arg === null) { + $name_arg = $module_arg; + } + assert($name_arg->value instanceof Node\Scalar\String_); + + $module_name = $module_arg->value->value; + if (!isset($modules[$module_name])) { + return []; + } + $type_prefix = $name_arg->value->value; + $type_filename = $type_arg->value->value; + $module = $modules[$module_name]; + $file = $drupal_root . '/' . $module->getPath() . "/$type_prefix.$type_filename"; + if (is_file($file)) { + require_once $file; + return []; + } + return [sprintf('File %s could not be loaded from %s::loadInclude', $file, $type->getClassName())]; + } catch (\Throwable $e) { + return [sprintf('A file could not be loaded from %s::loadInclude', $type->getClassName())]; + } + } +} diff --git a/tests/fixtures/drupal/modules/phpstan_fixtures/phpstan_fixtures.module b/tests/fixtures/drupal/modules/phpstan_fixtures/phpstan_fixtures.module index 274a0697..95a03641 100644 --- a/tests/fixtures/drupal/modules/phpstan_fixtures/phpstan_fixtures.module +++ b/tests/fixtures/drupal/modules/phpstan_fixtures/phpstan_fixtures.module @@ -16,3 +16,14 @@ function phpstan_fixtures_get_app_root(): string { $app_root = \Drupal::getContainer()->get('app.root'); return $app_root . '/core/includes/install.inc'; } + +function phpstan_fixtures_module_load_includes_test(): array { + $module_handler = \Drupal::moduleHandler(); + $module_handler->loadInclude('locale', 'fetch.inc'); + return _locale_translation_default_update_options(); +} + +function phpstan_fixtures_module_load_includes_negative_test(): void { + $module_handler = \Drupal::moduleHandler(); + $module_handler->loadInclude('phpstan_fixtures', 'fetch.inc'); +} diff --git a/tests/src/DrupalIntegrationTest.php b/tests/src/DrupalIntegrationTest.php index 9f51408b..d58afe52 100644 --- a/tests/src/DrupalIntegrationTest.php +++ b/tests/src/DrupalIntegrationTest.php @@ -38,7 +38,7 @@ public function testDrupalTestInChildSiteContant() { public function testExtensionReportsError() { $errors = $this->runAnalyze(__DIR__ . '/../fixtures/drupal/modules/phpstan_fixtures/phpstan_fixtures.module'); - $this->assertCount(2, $errors->getErrors(), var_export($errors, true)); + $this->assertCount(3, $errors->getErrors(), var_export($errors, true)); $this->assertCount(0, $errors->getInternalErrors(), var_export($errors, true)); $errors = $errors->getErrors(); @@ -46,6 +46,8 @@ public function testExtensionReportsError() { $this->assertEquals('If condition is always false.', $error->getMessage()); $error = array_shift($errors); $this->assertEquals('Function phpstan_fixtures_MissingReturnRule() should return string but return statement is missing.', $error->getMessage()); + $error = array_shift($errors); + $this->assertStringContainsString('phpstan_fixtures/phpstan_fixtures.fetch.inc could not be loaded from Drupal\\Core\\Extension\\ModuleHandlerInterface::loadInclude', $error->getMessage()); } public function testExtensionTestSuiteAutoloading() {