container parameter types (#184)

fixes https://github.com/psalm/psalm-plugin-symfony/issues/177
This commit is contained in:
Farhad Safarov 2021-06-18 10:59:41 +03:00 committed by GitHub
parent ce6bb2995b
commit 4fd391d445
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 285 additions and 16 deletions

View File

@ -1,21 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/7.5/phpunit.xsd" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
bootstrap="vendor/autoload.php" bootstrap="vendor/autoload.php"
forceCoversAnnotation="true" forceCoversAnnotation="false"
beStrictAboutCoversAnnotation="true" beStrictAboutCoversAnnotation="true"
beStrictAboutOutputDuringTests="true" beStrictAboutOutputDuringTests="true"
beStrictAboutTodoAnnotatedTests="true" beStrictAboutTodoAnnotatedTests="true"
verbose="true"> verbose="true"
>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">src</directory>
</include>
</coverage>
<testsuites> <testsuites>
<testsuite name="default"> <testsuite name="default">
<directory suffix="Test.php">tests/unit</directory> <directory suffix="Test.php">tests/unit</directory>
</testsuite> </testsuite>
</testsuites> </testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">src</directory>
</whitelist>
</filter>
</phpunit> </phpunit>

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<files psalm-version="3.17.1@8f211792d813e4dc89f04ed372785ce93b902fd1"> <files psalm-version="4.7.3@38c452ae584467e939d55377aaf83b5a26f19dd1">
<file src="src/Twig/Context.php"> <file src="src/Twig/Context.php">
<UnnecessaryVarAnnotation occurrences="2"> <UnnecessaryVarAnnotation occurrences="2">
<code>int</code> <code>int</code>
@ -11,4 +11,9 @@
<code>NonInvariantDocblockPropertyType</code> <code>NonInvariantDocblockPropertyType</code>
</UnusedPsalmSuppress> </UnusedPsalmSuppress>
</file> </file>
<file src="src/Symfony/ContainerMeta.php">
<PossiblyNullReference occurrences="1">
<code>attributes</code>
</PossiblyNullReference>
</file>
</files> </files>

View File

@ -0,0 +1,67 @@
<?php
namespace Psalm\SymfonyPsalmPlugin\Handler;
use PhpParser\Node\Expr;
use PhpParser\Node\Scalar\String_;
use Psalm\Codebase;
use Psalm\Context;
use Psalm\Plugin\Hook\AfterMethodCallAnalysisInterface;
use Psalm\StatementsSource;
use Psalm\SymfonyPsalmPlugin\Symfony\ContainerMeta;
use Psalm\Type\Atomic;
use Psalm\Type\Union;
class ParameterBagHandler implements AfterMethodCallAnalysisInterface
{
/**
* @var ContainerMeta|null
*/
private static $containerMeta;
public static function init(ContainerMeta $containerMeta): void
{
self::$containerMeta = $containerMeta;
}
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
): void {
if (!self::$containerMeta || 'Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface::get' !== $declaring_method_id) {
return;
}
if (!isset($expr->args[0]->value) || !($expr->args[0]->value instanceof String_)) {
return;
}
$argument = $expr->args[0]->value->value;
// @todo find a better way to calculate return type
switch (gettype(self::$containerMeta->getParameter($argument))) {
case 'string':
$return_type_candidate = new Union([Atomic::create('string')]);
break;
case 'boolean':
$return_type_candidate = new Union([Atomic::create('bool')]);
break;
case 'integer':
$return_type_candidate = new Union([Atomic::create('integer')]);
break;
case 'double':
$return_type_candidate = new Union([Atomic::create('float')]);
break;
case 'array':
$return_type_candidate = new Union([Atomic::create('array')]);
break;
}
}
}

View File

@ -14,6 +14,7 @@ use Psalm\SymfonyPsalmPlugin\Handler\ContainerHandler;
use Psalm\SymfonyPsalmPlugin\Handler\DoctrineQueryBuilderHandler; use Psalm\SymfonyPsalmPlugin\Handler\DoctrineQueryBuilderHandler;
use Psalm\SymfonyPsalmPlugin\Handler\DoctrineRepositoryHandler; use Psalm\SymfonyPsalmPlugin\Handler\DoctrineRepositoryHandler;
use Psalm\SymfonyPsalmPlugin\Handler\HeaderBagHandler; use Psalm\SymfonyPsalmPlugin\Handler\HeaderBagHandler;
use Psalm\SymfonyPsalmPlugin\Handler\ParameterBagHandler;
use Psalm\SymfonyPsalmPlugin\Handler\RequiredSetterHandler; use Psalm\SymfonyPsalmPlugin\Handler\RequiredSetterHandler;
use Psalm\SymfonyPsalmPlugin\Symfony\ContainerMeta; use Psalm\SymfonyPsalmPlugin\Symfony\ContainerMeta;
use Psalm\SymfonyPsalmPlugin\Twig\AnalyzedTemplatesTainter; use Psalm\SymfonyPsalmPlugin\Twig\AnalyzedTemplatesTainter;
@ -84,7 +85,12 @@ class Plugin implements PluginEntryPointInterface
} }
if (isset($config->containerXml)) { if (isset($config->containerXml)) {
ContainerHandler::init(new ContainerMeta((array) $config->containerXml)); $containerMeta = new ContainerMeta((array) $config->containerXml);
ContainerHandler::init($containerMeta);
require_once __DIR__.'/Handler/ParameterBagHandler.php';
ParameterBagHandler::init($containerMeta);
$api->registerHooksFromClass(ParameterBagHandler::class);
} }
$api->registerHooksFromClass(ContainerHandler::class); $api->registerHooksFromClass(ContainerHandler::class);

View File

@ -19,6 +19,11 @@ class ContainerMeta
*/ */
private $classNames = []; private $classNames = [];
/**
* @var array<string, mixed>
*/
private $parameters = [];
public function __construct(array $containerXmlPaths) public function __construct(array $containerXmlPaths)
{ {
$this->init($containerXmlPaths); $this->init($containerXmlPaths);
@ -43,6 +48,18 @@ class ContainerMeta
$this->services[$service->getId()] = $service; $this->services[$service->getId()] = $service;
} }
/**
* @return mixed|null
*/
public function getParameter(string $key)
{
if (isset($this->parameters[$key])) {
return $this->parameters[$key];
}
return null;
}
/** /**
* @return array<string> * @return array<string>
*/ */
@ -90,9 +107,66 @@ class ContainerMeta
$this->add($service); $this->add($service);
} }
/** @var \SimpleXMLElement $parameter */
foreach ($xml->parameters->parameter as $parameter) {
$value = $this->getXmlParameterValue($parameter);
$attributes = $parameter->attributes();
if (!isset($attributes->key)) {
continue;
}
$this->parameters[(string) $attributes->key] = $value;
}
return; return;
} }
throw new ConfigException('Container xml file(s) not found at '); throw new ConfigException('Container xml file(s) not found at ');
} }
/**
* @return mixed
*/
private function getXmlParameterValue(\SimpleXMLElement $parameter)
{
$value = null;
$attributes = $parameter->attributes();
if (isset($attributes->type)) {
switch ((string) $attributes->type) {
case 'binary':
$value = base64_decode((string) $parameter, true);
break;
case 'collection':
foreach ($parameter->children() as $child) {
$childAttributes = $child->attributes();
if (isset($childAttributes->key)) {
$value[(string) $childAttributes->key] = $this->getXmlParameterValue($child);
} else {
$value[] = $this->getXmlParameterValue($child);
}
}
break;
case 'string':
default:
$value = (string) $parameter;
break;
}
} else {
$value = (string) $parameter;
if ('true' === $value || 'false' === $value) {
$value = (bool) $value;
} elseif ('null' === $value) {
$value = null;
} elseif (is_numeric($value)) {
if (false === strpos($value, '.')) {
$value = (int) $value;
} else {
$value = (float) $value;
}
}
}
return $value;
}
} }

View File

@ -0,0 +1,75 @@
@symfony-common
Feature: ParameterBag
Background:
Given I have Symfony plugin enabled with the following config
"""
<containerXml>../../tests/acceptance/container.xml</containerXml>
"""
And I have the following code preamble
"""
<?php
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
"""
Scenario: Asserting psalm recognizes return type of Symfony parameters if container.xml is provided
Given I have the following code
"""
class Foo
{
public function __invoke(ParameterBagInterface $parameterBag)
{
/** @psalm-trace $kernelEnvironment */
$kernelEnvironment = $parameterBag->get('kernel.environment');
/** @psalm-trace $debugEnabled */
$debugEnabled = $parameterBag->get('debug_enabled');
/** @psalm-trace $debugDisabled */
$debugDisabled = $parameterBag->get('debug_disabled');
/** @psalm-trace $version */
$version = $parameterBag->get('version');
/** @psalm-trace $integerOne */
$integerOne = $parameterBag->get('integer_one');
/** @psalm-trace $pi */
$pi = $parameterBag->get('pi');
/** @psalm-trace $collection1 */
$collection1 = $parameterBag->get('collection1');
}
}
"""
When I run Psalm
Then I see these errors
| Type | Message |
| Trace | $kernelEnvironment: string |
| Trace | $debugEnabled: bool |
| Trace | $debugDisabled: bool |
| Trace | $version: string |
| Trace | $integerOne: int |
| Trace | $pi: float |
| Trace | $collection1: array |
And I see no other errors
Scenario: Get non-existent parameter
Given I have the following code
"""
class Foo
{
public function __invoke(ParameterBagInterface $parameterBag)
{
/** @psalm-trace $nonExistentParameter */
$nonExistentParameter = $parameterBag->get('non_existent_parameter');
}
}
"""
When I run Psalm
Then I see these errors
| Type | Message |
| MixedAssignment | Unable to determine the type that $nonExistentParameter is being assigned to |
| Trace | $nonExistentParameter: mixed |
And I see no other errors

View File

@ -2,6 +2,25 @@
<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"> <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> <parameters>
<parameter key="kernel.environment">dev</parameter> <parameter key="kernel.environment">dev</parameter>
<parameter key="debug_enabled">true</parameter>
<parameter key="debug_disabled">false</parameter>
<parameter key="version" type="string">1</parameter>
<parameter key="integer_one">1</parameter>
<parameter key="pi">3.14</parameter>
<parameter key="collection1" type="collection">
<parameter key="key1">val1</parameter>
<parameter key="key2">val2</parameter>
</parameter>
<parameter key="nested_collection" type="collection">
<parameter key="key">val</parameter>
<parameter key="child_collection" type="collection">
<parameter key="boolean">true</parameter>
<parameter key="float">2.18</parameter>
<parameter key="grandchild_collection" type="collection">
<parameter key="string">something</parameter>
</parameter>
</parameter>
</parameter>
</parameters> </parameters>
<services> <services>
<service id="foo" alias="no_such_service" public="true"/> <service id="foo" alias="no_such_service" public="true"/>

View File

@ -138,4 +138,27 @@ class ContainerMetaTest extends TestCase
$service = $containerMeta->get('service_container'); $service = $containerMeta->get('service_container');
$this->assertSame('Symfony\Component\DependencyInjection\ContainerInterface', $service->getClassName()); $this->assertSame('Symfony\Component\DependencyInjection\ContainerInterface', $service->getClassName());
} }
public function testGetParameter(): void
{
$this->assertSame('dev', $this->containerMeta->getParameter('kernel.environment'));
$this->assertSame(true, $this->containerMeta->getParameter('debug_enabled'));
$this->assertSame('1', $this->containerMeta->getParameter('version'));
$this->assertSame(1, $this->containerMeta->getParameter('integer_one'));
$this->assertSame(3.14, $this->containerMeta->getParameter('pi'));
$this->assertSame([
'key1' => 'val1',
'key2' => 'val2',
], $this->containerMeta->getParameter('collection1'));
$this->assertSame([
'key' => 'val',
'child_collection' => [
'boolean' => true,
'float' => 2.18,
'grandchild_collection' => [
'string' => 'something',
],
]
], $this->containerMeta->getParameter('nested_collection'));
}
} }