refactor container check (#8)

* refactor container check

* no message

* no message

* no message
This commit is contained in:
Farhad Safarov 2020-03-09 15:24:39 +03:00 committed by GitHub
parent 5e53558c97
commit 247647254d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 317 additions and 70 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
vendor vendor
composer.lock composer.lock
.php_cs.cache .php_cs.cache
tests/_run

20
.php_cs.dist Normal file
View File

@ -0,0 +1,20 @@
<?php
$finder = PhpCsFixer\Finder::create()
->in(__DIR__.'/src')
->ignoreDotFiles(true)
->ignoreVCS(true)
->files()
->name('*.php');
return PhpCsFixer\Config::create()
->setFinder($finder)
->setRules([
'@Symfony' => true,
'array_syntax' => ['syntax' => 'short'],
'binary_operator_spaces' => ['align_double_arrow' => false],
'no_useless_else' => true,
'no_useless_return' => false,
'ordered_imports' => true,
'phpdoc_to_comment' => false,
]);

View File

@ -7,6 +7,7 @@ php:
before_install: before_install:
- phpenv config-rm xdebug.ini || true - phpenv config-rm xdebug.ini || true
- composer validate
install: install:
- composer install - composer install

View File

@ -11,11 +11,45 @@ vendor/bin/psalm-plugin enable seferov/symfony-psalm-plugin
### Features ### Features
- Detect `ContainerInterface::get()` result type - Detect `ContainerInterface::get()` result type. Works better if you [configure](https://github.com/seferov/symfony-psalm-plugin/#configuration) compiled container XML file.
- Fixes `PossiblyInvalidArgument` for `Symfony\Component\HttpFoundation\Request::getContent`. - Fixes `PossiblyInvalidArgument` for `Symfony\Component\HttpFoundation\Request::getContent`.
The plugin calculates real return type by checking the given argument and marks return type as either string or resource. The plugin calculates real return type by checking the given argument and marks return type as either string or resource.
- Complains when `Container` is injected to a service. Use dependency-injection. - Complains when `Container` is injected to a service. Use dependency-injection.
### Configuration
If you followed installation instructions, psalm-plugin command would added plugin configuration to psalm.xml
```xml
<?xml version="1.0"?>
<psalm
totallyTyped="true"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
>
<!-- project configuration -->
<plugins>
<pluginClass class="Seferov\SymfonyPsalmPlugin\Plugin" />
</plugins>
</psalm>
```
To be able to detect return types of services using ID (generally starts with `@` in Symfony YAML config files. Ex: `logger` service)
`containerXml` must be provided. Example:
```xml
<pluginClass class="Seferov\SymfonyPsalmPlugin\Plugin">
<containerXml>var/cache/dev/App_KernelDevDebugContainer.xml</containerXml>
</pluginClass>
```
This file path may change based on your Symfony version, file structure and environment settings.
Default file for Symfony versions:
- Symfony 3: var/cache/dev/srcDevDebugProjectContainer.xml
- Symfony 4: var/cache/dev/srcApp_KernelDevDebugContainer.xml
- Symfony 5: var/cache/dev/App_KernelDevDebugContainer.xml
### Credits ### Credits
- [@weirdan](https://github.com/weirdan) for [codeception psalm module](https://github.com/weirdan/codeception-psalm-module) - [@weirdan](https://github.com/weirdan) for [codeception psalm module](https://github.com/weirdan/codeception-psalm-module)

View File

@ -1,11 +1,11 @@
namespace: Psalm\PhpUnitPlugin\Tests namespace: Psalm\PhpUnitPlugin\Tests
paths: paths:
tests: tests tests: tests/acceptance
output: tests/_output output: tests/acceptance/_output
data: tests/_data data: tests/acceptance/_data
support: tests/_support support: tests/acceptance/_support
envs: tests/_envs envs: tests/acceptance/_envs
actor_suffix: Tester actor_suffix: Tester

View File

@ -11,6 +11,7 @@
], ],
"require": { "require": {
"php": "^7.1", "php": "^7.1",
"ext-simplexml": "*",
"vimeo/psalm": "^3.7", "vimeo/psalm": "^3.7",
"symfony/framework-bundle": "^3.0 || ^4.0 || ^5.0" "symfony/framework-bundle": "^3.0 || ^4.0 || ^5.0"
}, },

View File

@ -13,7 +13,6 @@ use Psalm\Plugin\Hook\AfterClassLikeAnalysisInterface;
use Psalm\Plugin\Hook\AfterMethodCallAnalysisInterface; use Psalm\Plugin\Hook\AfterMethodCallAnalysisInterface;
use Psalm\StatementsSource; use Psalm\StatementsSource;
use Psalm\Storage\ClassLikeStorage; use Psalm\Storage\ClassLikeStorage;
use Psalm\Type\Atomic\TNamedObject;
use Psalm\Type\Union; use Psalm\Type\Union;
use Seferov\SymfonyPsalmPlugin\Issue\ContainerDependency; use Seferov\SymfonyPsalmPlugin\Issue\ContainerDependency;
use Seferov\SymfonyPsalmPlugin\Issue\RepositoryStringShortcut; use Seferov\SymfonyPsalmPlugin\Issue\RepositoryStringShortcut;
@ -21,11 +20,6 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
class ClassHandler implements AfterClassLikeAnalysisInterface, AfterMethodCallAnalysisInterface class ClassHandler implements AfterClassLikeAnalysisInterface, AfterMethodCallAnalysisInterface
{ {
/**
* @psalm-var array<string, string>
*/
private static $classServiceMap = [];
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
@ -62,22 +56,6 @@ class ClassHandler implements AfterClassLikeAnalysisInterface, AfterMethodCallAn
Union &$return_type_candidate = null Union &$return_type_candidate = null
) { ) {
switch ($declaring_method_id) { switch ($declaring_method_id) {
case 'Psr\Container\ContainerInterface::get':
case 'Symfony\Component\DependencyInjection\ContainerInterface::get':
if ($return_type_candidate && $expr->args[0]->value instanceof ClassConstFetch) {
$className = (string) $expr->args[0]->value->class->getAttribute('resolvedName');
$return_type_candidate = new Union([new TNamedObject($className)]);
}
if (!count(self::$classServiceMap)) {
self::$classServiceMap = self::loadServiceFile($codebase);
}
if ($return_type_candidate && count(self::$classServiceMap) && $expr->args[0]->value instanceof Node\Scalar\String_) {
$serviceName = (string) $expr->args[0]->value->value;
if (isset(self::$classServiceMap[$serviceName])) {
$return_type_candidate = new Union([new TNamedObject((string) self::$classServiceMap[$serviceName])]);
}
}
break;
case 'Symfony\Component\HttpFoundation\Request::getcontent': case 'Symfony\Component\HttpFoundation\Request::getcontent':
if ($return_type_candidate) { if ($return_type_candidate) {
$removeType = 'resource'; $removeType = 'resource';
@ -99,45 +77,4 @@ class ClassHandler implements AfterClassLikeAnalysisInterface, AfterMethodCallAn
break; break;
} }
} }
/**
* @todo don't check every time if containerXml config is not set.
* @psalm-return array<string, string>
*/
private static function loadServiceFile(Codebase $codebase): array
{
$classServiceMap = [];
if (count($codebase->config->getPluginClasses())) {
foreach ($codebase->config->getPluginClasses() as $pluginClass) {
if ($pluginClass['class'] === str_replace('Handler', 'Plugin', __NAMESPACE__)) {
$simpleXmlConfig = $pluginClass['config'];
}
}
}
if (isset($simpleXmlConfig) && $simpleXmlConfig instanceof \SimpleXMLElement) {
$serviceFilePath = (string) $simpleXmlConfig->containerXml;
if (!file_exists($serviceFilePath)) {
return [];
}
$xml = simplexml_load_file($serviceFilePath);
if (!$xml->services instanceof \SimpleXMLElement) {
return $classServiceMap;
}
$services = $xml->services;
/** @psalm-suppress MixedAssignment */
if (count($services)) {
foreach ($services->service as $serviceObj) {
if (isset($serviceObj) && $serviceObj instanceof \SimpleXMLElement) {
$serviceAttributes = $serviceObj->attributes();
if ($serviceAttributes && isset($serviceAttributes['id']) && isset($serviceAttributes['class'])) {
$classServiceMap[(string) $serviceAttributes['id']] = (string) $serviceAttributes['class'];
}
}
}
}
}
return $classServiceMap;
}
} }

View File

@ -0,0 +1,39 @@
<?php
namespace Seferov\SymfonyPsalmPlugin\Handler;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\ClassConstFetch;
use Psalm\Codebase;
use Psalm\Context;
use Psalm\Plugin\Hook\AfterMethodCallAnalysisInterface;
use Psalm\StatementsSource;
use Psalm\Type\Atomic\TNamedObject;
use Psalm\Type\Union;
class ContainerHandler implements AfterMethodCallAnalysisInterface
{
/**
* {@inheritdoc}
*/
public static function afterMethodCallAnalysis(
Expr $expr,
string $method_id,
string $appearing_method_id,
string $declaring_method_id,
Context $context,
StatementsSource $statements_source,
Codebase $codebase,
array &$file_replacements = [],
Union &$return_type_candidate = null
) {
if (!in_array($declaring_method_id, ['Psr\Container\ContainerInterface::get', 'Symfony\Component\DependencyInjection\ContainerInterface::get'])) {
return;
}
if ($return_type_candidate && $expr->args[0]->value instanceof ClassConstFetch) {
$className = (string) $expr->args[0]->value->class->getAttribute('resolvedName');
$return_type_candidate = new Union([new TNamedObject($className)]);
}
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace Seferov\SymfonyPsalmPlugin\Handler;
use PhpParser\Node\Expr;
use PhpParser\Node\Scalar\String_;
use Psalm\Codebase;
use Psalm\CodeLocation;
use Psalm\Context;
use Psalm\IssueBuffer;
use Psalm\Plugin\Hook\AfterMethodCallAnalysisInterface;
use Psalm\StatementsSource;
use Psalm\Type\Atomic\TNamedObject;
use Psalm\Type\Union;
use Seferov\SymfonyPsalmPlugin\Issue\ServiceNotFound;
use Seferov\SymfonyPsalmPlugin\SymfonyContainer;
class ContainerXmlHandler implements AfterMethodCallAnalysisInterface
{
/**
* {@inheritdoc}
*/
public static function afterMethodCallAnalysis(
Expr $expr,
string $method_id,
string $appearing_method_id,
string $declaring_method_id,
Context $context,
StatementsSource $statements_source,
Codebase $codebase,
array &$file_replacements = [],
Union &$return_type_candidate = null
) {
if (!in_array($declaring_method_id, ['Psr\Container\ContainerInterface::get', 'Symfony\Component\DependencyInjection\ContainerInterface::get'])) {
return;
}
if ($return_type_candidate && $expr->args[0]->value instanceof String_) {
$simpleXmlConfig = null;
if (count($codebase->config->getPluginClasses())) {
foreach ($codebase->config->getPluginClasses() as $pluginClass) {
if ($pluginClass['class'] === str_replace('Handler', 'Plugin', __NAMESPACE__)) {
$simpleXmlConfig = (string) $pluginClass['config'];
}
}
}
if (!is_string($simpleXmlConfig)) {
throw new \LogicException('This hook is registered when xml file is set');
}
$symfonyContainer = new SymfonyContainer($simpleXmlConfig);
$serviceId = (string) $expr->args[0]->value->value;
$serviceDefinition = $symfonyContainer->get($serviceId);
if ($serviceDefinition) {
if ($serviceDefinition->isPublic()) {
$class = $serviceDefinition->getClass();
if ($class) {
$return_type_candidate = new Union([new TNamedObject($class)]);
}
}
// @todo: else emit "get private service" issue
} else {
IssueBuffer::accepts(
new ServiceNotFound($serviceId, new CodeLocation($statements_source, $expr->args[0]->value)),
$statements_source->getSuppressedIssues()
);
}
}
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace Seferov\SymfonyPsalmPlugin\Issue;
use Psalm\CodeLocation;
use Psalm\Issue\CodeIssue;
class ServiceNotFound extends CodeIssue
{
public function __construct(string $id, CodeLocation $code_location)
{
parent::__construct(sprintf('Service "%s" not found', $id), $code_location);
}
}

View File

@ -2,18 +2,36 @@
namespace Seferov\SymfonyPsalmPlugin; namespace Seferov\SymfonyPsalmPlugin;
use Psalm\Exception\ConfigException;
use Psalm\Plugin\PluginEntryPointInterface; use Psalm\Plugin\PluginEntryPointInterface;
use Psalm\Plugin\RegistrationInterface; use Psalm\Plugin\RegistrationInterface;
use Seferov\SymfonyPsalmPlugin\Handler\ClassHandler; use Seferov\SymfonyPsalmPlugin\Handler\ClassHandler;
use Seferov\SymfonyPsalmPlugin\Handler\ContainerHandler;
use Seferov\SymfonyPsalmPlugin\Handler\ContainerXmlHandler;
use SimpleXMLElement; use SimpleXMLElement;
class Plugin implements PluginEntryPointInterface class Plugin implements PluginEntryPointInterface
{ {
/** @return void */ /**
* {@inheritdoc}
*/
public function __invoke(RegistrationInterface $api, SimpleXMLElement $config = null) public function __invoke(RegistrationInterface $api, SimpleXMLElement $config = null)
{ {
require_once __DIR__.'/Handler/ClassHandler.php'; require_once __DIR__.'/Handler/ClassHandler.php';
require_once __DIR__.'/Handler/ContainerHandler.php';
require_once __DIR__.'/Handler/ContainerXmlHandler.php';
$api->registerHooksFromClass(ClassHandler::class); $api->registerHooksFromClass(ClassHandler::class);
if (isset($config->containerXml)) {
$containerXmlPath = realpath((string) $config->containerXml);
if (!$containerXmlPath) {
throw new ConfigException(sprintf('Container XML file (%s) does not exits', $containerXmlPath));
}
$api->registerHooksFromClass(ContainerXmlHandler::class);
} else {
$api->registerHooksFromClass(ContainerHandler::class);
}
} }
} }

43
src/SymfonyContainer.php Normal file
View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Seferov\SymfonyPsalmPlugin;
use Psalm\Exception\ConfigException;
use Symfony\Component\DependencyInjection\Definition;
class SymfonyContainer
{
/**
* @psalm-var array<string, Definition>
*/
private $services = [];
public function __construct(string $containerXmlPath)
{
$xml = simplexml_load_file($containerXmlPath);
if (!$xml->services instanceof \SimpleXMLElement) {
throw new ConfigException('Not a valid container xml file');
}
/** @psalm-var \SimpleXMLElement $service */
foreach ($xml->services->service as $service) {
/** @psalm-var \SimpleXMLElement $serviceAttributes */
$serviceAttributes = $service->attributes();
$definition = new Definition((string) $serviceAttributes->class);
$definition->setPublic((bool) $serviceAttributes->public);
$this->services[(string) $serviceAttributes->id] = $definition;
}
}
public function get(string $id): ?Definition
{
if (isset($this->services[$id])) {
return $this->services[$id];
}
return null;
}
}

View File

@ -0,0 +1,59 @@
Feature: Container XML config
Detect ContainerInterface::get() result type
Background:
Given I have the following config
"""
<?xml version="1.0"?>
<psalm totallyTyped="true">
<projectFiles>
<directory name="."/>
<ignoreFiles> <directory name="../../vendor"/> </ignoreFiles>
</projectFiles>
<plugins>
<pluginClass class="Seferov\SymfonyPsalmPlugin\Plugin">
<containerXml>../../tests/acceptance/container.xml</containerXml>
</pluginClass>
</plugins>
</psalm>
"""
Scenario: Asserting psalm recognizes return type of service got via 'ContainerInterface::get() using service ID'
Given I have the following code
"""
<?php
class SomeController
{
use \Symfony\Component\DependencyInjection\ContainerAwareTrait;
public function index(): bool
{
return $this->container->get('service_container')->has('lorem');
}
}
"""
When I run Psalm
Then I see no errors
# todo: emit a service not found error instead of PossiblyNullReference
Scenario: Psalm emits when service ID not found in container'
Given I have the following code
"""
<?php
class SomeController
{
use \Symfony\Component\DependencyInjection\ContainerAwareTrait;
public function index(): void
{
$this->container->get('not_a_service')->has('lorem');
}
}
"""
When I run Psalm
Then I see these errors
| Type | Message |
| ServiceNotFound | Service "not_a_service" not found |

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<container xmlns="http://symfony.com/schema/dic/services" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd">
<parameters>
<parameter key="kernel.environment">dev</parameter>
</parameters>
<services>
<service id="service_container" class="Symfony\Component\DependencyInjection\ContainerInterface" public="true" synthetic="true"/>
</services>
</container>