Skip to content

Commit

Permalink
GH-55 Improve extension integration with PHPStan
Browse files Browse the repository at this point in the history
  • Loading branch information
mglaman committed May 10, 2019
1 parent 5188efc commit 9cd79ee
Show file tree
Hide file tree
Showing 9 changed files with 175 additions and 116 deletions.
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
],
"require": {
"php": "^7.1",
"phpstan/phpstan": "^0.11",
"phpstan/phpstan": "^0.11.6",
"symfony/yaml": "~3.4.5|^4.2",
"webflo/drupal-finder": "^1.1"
},
Expand Down
8 changes: 8 additions & 0 deletions drupal-autoloader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

use PHPStan\DependencyInjection\Container;
use PHPStan\Drupal\Bootstrap;

assert($container instanceof Container);
$drupalAutoloader = new Bootstrap($container);
$drupalAutoloader->register();
3 changes: 3 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
parameters:
# @todo this must persist in a DI extension due unexpanded relative path.
#autoload_files:
# - drupal-autoloader.php
excludes_analyse:
- *.api.php
- */tests/fixtures/*.php
Expand Down
3 changes: 0 additions & 3 deletions phpstan-bootstrap.php

This file was deleted.

57 changes: 10 additions & 47 deletions src/DependencyInjection/DrupalExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,32 +60,12 @@ public function loadConfiguration(): void
{
/** @var array */
$config = Helpers::merge($this->config, $this->defaultConfig);

$finder = new DrupalFinder();

if ($config['drupal_root'] !== '' && realpath($config['drupal_root']) !== false && is_dir($config['drupal_root'])) {
$start_path = $config['drupal_root'];
} else {
$start_path = dirname($GLOBALS['autoloaderInWorkingDirectory'], 2);
}

$finder->locateRoot($start_path);
$this->drupalRoot = $finder->getDrupalRoot();
$this->drupalVendorDir = $finder->getVendorDir();
if (! (bool) $this->drupalRoot || ! (bool) $this->drupalVendorDir) {
throw new \RuntimeException("Unable to detect Drupal at $start_path");
}

$builder = $this->getContainerBuilder();
$builder->parameters['bootstrap'] = dirname(__DIR__, 2) . '/phpstan-bootstrap.php';
$builder->parameters['drupalRoot'] = $this->drupalRoot;

$this->modules = $config['modules'] ?? [];
$this->themes = $config['themes'] ?? [];

$builder->parameters['autoload_files'][] = dirname(__DIR__, 2) . '/drupal-autoloader.php';
$builder->parameters['drupal_root'] = $config['drupal_root'];
$builder->parameters['drupal']['entityTypeStorageMapping'] = $config['entityTypeStorageMapping'] ?? [];
$builder->parameters['drupalServiceMap'] = [];

$builder = $this->getContainerBuilder();
foreach ($builder->getDefinitions() as $definition) {
$factory = $definition->getFactory();
if ($factory === null) {
Expand All @@ -95,6 +75,12 @@ public function loadConfiguration(): void
$definition->setFactory(EnhancedRequireParentConstructCallRule::class);
}
}
return;

$this->modules = $config['modules'] ?? [];
$this->themes = $config['themes'] ?? [];

$builder = $this->getContainerBuilder();

// Build the service definitions...
$extensionDiscovery = new ExtensionDiscovery($this->drupalRoot);
Expand Down Expand Up @@ -149,7 +135,6 @@ public function loadConfiguration(): void
}
});
}
unset($serviceDefinition['tags']);
// @todo sanitize "calls" and "configurator" and "factory"
/**
jsonapi.params.enhancer:
Expand All @@ -159,31 +144,9 @@ class: Drupal\jsonapi\Routing\JsonApiParamEnhancer
tags:
- { name: route_enhancer }
*/
unset($serviceDefinition['calls']);
unset($serviceDefinition['configurator']);
unset($serviceDefinition['factory']);
unset($serviceDefinition['tags'], $serviceDefinition['calls'], $serviceDefinition['configurator'], $serviceDefinition['factory']);
$builder->parameters['drupalServiceMap'][$serviceId] = $serviceDefinition;
}
}
}

protected function camelize(string $id): string
{
return strtr(ucwords(strtr($id, ['_' => ' ', '.' => '_ ', '\\' => '_ '])), [' ' => '']);
}

public function afterCompile(Nette\PhpGenerator\ClassType $class)
{
// @todo find a non-hack way to pass the Drupal roots to the bootstrap file.
$class->getMethod('initialize')->addBody('$GLOBALS["drupalRoot"] = ?;', [$this->drupalRoot]);
$class->getMethod('initialize')->addBody('$GLOBALS["drupalVendorDir"] = ?;', [$this->drupalVendorDir]);

// DRUPAL_TEST_IN_CHILD_SITE is only defined in the \Drupal\Core\DrupalKernel::bootEnvironment method when
// Drupal is bootstrapped. Since we don't actually invoke the bootstrapping of Drupal, define the constant here
// as `false`. And we have to conditionally define it due to our own PHPUnit tests
$class->getMethod('initialize')->addBody('
if (!defined("DRUPAL_TEST_IN_CHILD_SITE")) {
define("DRUPAL_TEST_IN_CHILD_SITE", ?);
}', [false]);
}
}
154 changes: 125 additions & 29 deletions src/Drupal/Bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Drupal\Core\DependencyInjection\ContainerNotInitializedException;
use DrupalFinder\DrupalFinder;
use Nette\Utils\Finder;
use Symfony\Component\Yaml\Yaml;

class Bootstrap
{
Expand All @@ -26,6 +27,11 @@ class Bootstrap
*/
private $drupalRoot;

/**
* @var string
*/
private $drupalVendorDir;

/**
* List of available modules.
*
Expand All @@ -41,50 +47,90 @@ class Bootstrap
protected $themeData = [];

/**
* @var array
* @var ?\PHPStan\Drupal\ExtensionDiscovery
*/
private $modules = [];
private $extensionDiscovery;

/**
* @var array
*/
private $themes = [];
private $namespaces = [];

/**
* @var ?\PHPStan\Drupal\ExtensionDiscovery
* @var \PHPStan\DependencyInjection\Container
*/
private $extensionDiscovery;
private $container;

/**
* @var array
*/
private $namespaces = [];
public function __construct(\PHPStan\DependencyInjection\Container $container)
{
$this->container = $container;
}

public function register(): void
private function registerServiceMap()
{
$drupalRoot = realpath($GLOBALS['drupalRoot']);
if ($drupalRoot === false) {
throw new \RuntimeException('Cannot determine the Drupal root from ' . $drupalRoot);
$serviceYamls = [
'core' => $this->drupalRoot . '/core/core.services.yml',
];
$serviceClassProviders = [
'core' => 'Drupal\Core\CoreServiceProvider',
];
foreach ($this->moduleData as $extension) {
$module_dir = $this->drupalRoot . '/' . $extension->getPath();
$moduleName = $extension->getName();
$servicesFileName = $module_dir . '/' . $moduleName . '.services.yml';
if (file_exists($servicesFileName)) {
$serviceYamls[$moduleName] = $servicesFileName;
}

$camelized = $this->camelize($extension->getName());
$name = "{$camelized}ServiceProvider";
$class = "Drupal\\{$moduleName}\\{$name}";

if (class_exists($class)) {
$serviceClassProviders[$moduleName] = $class;
}
}
$this->drupalRoot = $drupalRoot;

$drupalVendorRoot = realpath($GLOBALS['drupalVendorDir']);
$this->autoloader = include $drupalVendorRoot . '/autoload.php';
foreach ($serviceYamls as $extension => $serviceYaml) {
$yaml = Yaml::parseFile($serviceYaml);
// Weed out service files which only provide parameters.
if (!isset($yaml['services']) || !is_array($yaml['services'])) {
continue;
}
foreach ($yaml['services'] as $serviceId => $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)) {
// @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']);
//$builder->parameters['drupalServiceMap'][$serviceId] = $serviceDefinition;
}
}
}

$this->extensionDiscovery = new ExtensionDiscovery($this->drupalRoot);
$this->extensionDiscovery->setProfileDirectories([]);
$profiles = $this->extensionDiscovery->scan('profile');
$profile_directories = array_map(static function (Extension $profile) : string {
return $profile->getPath();
}, $profiles);
$this->extensionDiscovery->setProfileDirectories($profile_directories);
public function register(): void
{
$this->registerDrupalPath();
$this->autoloader = include $this->drupalVendorDir . '/autoload.php';
$this->registerExtensionData();

$this->moduleData = array_merge($this->extensionDiscovery->scan('module'), $profiles);
usort($this->moduleData, static function (Extension $a, Extension $b) {
// blazy_test causes errors, ensure it is loaded last.
return $a->getName() === 'blazy_test' ? 10 : 0;
});
$this->themeData = $this->extensionDiscovery->scan('theme');
$this->addCoreNamespaces();
$this->addModuleNamespaces();
$this->addThemeNamespaces();
Expand Down Expand Up @@ -137,6 +183,50 @@ public function register(): void
}
}

private function registerDrupalPath(): void
{
$drupalRoot = $this->container->getParameter('drupal_root');
if ($drupalRoot !== '' && realpath($drupalRoot) !== false && is_dir($drupalRoot)) {
$start_path = realpath($drupalRoot);
} else {
$start_path = $this->container->getParameter('currentWorkingDirectory');
}
if ($start_path === false) {
throw new \RuntimeException('Cannot determine the Drupal root from ' . $start_path);
}
$finder = new DrupalFinder();
$finder->locateRoot($start_path);
$this->drupalRoot = $finder->getDrupalRoot();
$this->drupalVendorDir = $finder->getVendorDir();
if (! (bool) $this->drupalRoot || ! (bool) $this->drupalVendorDir) {
throw new \RuntimeException("Unable to detect Drupal at $start_path");
}

// DRUPAL_TEST_IN_CHILD_SITE is only defined in the \Drupal\Core\DrupalKernel::bootEnvironment method when
// Drupal is bootstrapped. Since we don't actually invoke the bootstrapping of Drupal, define the constant here
// as `false`. And we have to conditionally define it due to our own PHPUnit tests
if (!defined('DRUPAL_TEST_IN_CHILD_SITE')) {
define('DRUPAL_TEST_IN_CHILD_SITE', false);
}
}
private function registerExtensionData(): void
{
$this->extensionDiscovery = new ExtensionDiscovery($this->drupalRoot);
$this->extensionDiscovery->setProfileDirectories([]);
$profiles = $this->extensionDiscovery->scan('profile');
$profile_directories = array_map(static function (Extension $profile) : string {
return $profile->getPath();
}, $profiles);
$this->extensionDiscovery->setProfileDirectories($profile_directories);

$this->moduleData = array_merge($this->extensionDiscovery->scan('module'), $profiles);
usort($this->moduleData, static function (Extension $a, Extension $b) {
// blazy_test causes errors, ensure it is loaded last.
return $a->getName() === 'blazy_test' ? 10 : 0;
});
$this->themeData = $this->extensionDiscovery->scan('theme');
}

protected function loadLegacyIncludes(): void
{
/** @var \SplFileInfo $file */
Expand Down Expand Up @@ -239,4 +329,10 @@ protected function loadAndCatchErrors(string $path): void
@trigger_error("$path failed loading due to {$e->getMessage()}", E_USER_WARNING);
}
}

protected function camelize(string $id): string
{
return strtr(ucwords(strtr($id, ['_' => ' ', '.' => '_ ', '\\' => '_ '])), [' ' => '']);
}

}
8 changes: 8 additions & 0 deletions tests/fixtures/drupal/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extra": [
"This is mocked so the vendor root is properly detected"
],
"config": {
"vendor-dir": "../../../vendor"
}
}
28 changes: 8 additions & 20 deletions tests/src/AnalyzerTestBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,28 +22,16 @@ protected function runAnalyze(string $path) {
$fileHelper = $container->getByType(FileHelper::class);
assert($fileHelper !== null);

$bootstrapFile = $container->parameters['bootstrap'];
$this->assertEquals(dirname(__DIR__, 2) . '/phpstan-bootstrap.php', $bootstrapFile);
$autoloadFiles = $container->parameters['autoload_files'];
self::assertContains(dirname(__DIR__, 2) . '/drupal-autoloader.php', $autoloadFiles);

// Mock the autoloader.
$GLOBALS['drupalVendorDir'] = dirname(__DIR__, 2) . '/vendor';
if ($bootstrapFile !== null) {
$bootstrapFile = $fileHelper->normalizePath($bootstrapFile);
if (!is_file($bootstrapFile)) {
$this->fail('Bootstrap file not found');
}
try {
(static function (string $file): void {
require_once $file;
})($bootstrapFile);
} catch (ContainerNotInitializedException $e) {
$trace = $e->getTrace();
$offending_file = $trace[1];
$this->fail(sprintf('%s called the Drupal container from unscoped code.', $offending_file['file']));
}
catch (\Throwable $e) {
$this->fail('Could not load the bootstrap file: ' . $e->getMessage());
}
}
foreach ($container->parameters['autoload_files'] as $parameterAutoloadFile) {
(static function (string $file) use ($container): void {
require_once $file;
})($fileHelper->normalizePath($parameterAutoloadFile));
}

$analyser = $container->getByType(Analyser::class);
assert($analyser !== null);
Expand Down
Loading

0 comments on commit 9cd79ee

Please sign in to comment.