diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..3a626c3a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly diff --git a/.github/workflows/integrate.yaml b/.github/workflows/integrate.yaml index a3eb6a67..620a5f93 100644 --- a/.github/workflows/integrate.yaml +++ b/.github/workflows/integrate.yaml @@ -18,23 +18,16 @@ jobs: fail-fast: false matrix: php-version: - - 7.3 - - 7.4 - 8.0 + - 8.1 dependencies: - highest - lowest - exclude: - - php-version: 7.3 - dependencies: lowest - - php-version: 7.4 - dependencies: lowest - steps: - name: "Checkout" - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: "Install PHP with extensions" uses: shivammathur/setup-php@v2 @@ -48,7 +41,7 @@ jobs: dependency-versions: ${{ matrix.dependencies }} - name: "Cache cache directory for vimeo/psalm" - uses: actions/cache@v1 + uses: actions/cache@v3 with: path: .build/psalm key: php-${{ matrix.php-version }}-psalm-${{ github.sha }} @@ -71,38 +64,26 @@ jobs: fail-fast: false matrix: php-version: - - 7.1 - - 7.2 - - 7.3 - - 7.4 - 8.0 + - 8.1 + - 8.2 symfony-version: - - 4 - 5 - 6 + - 7 exclude: - - php-version: 7.1 - symfony-version: 5 - - php-version: 7.1 - symfony-version: 6 - - php-version: 7.2 - symfony-version: 6 - - php-version: 7.3 - symfony-version: 6 - - php-version: 7.4 - symfony-version: 6 - - php-version: 7.1 - symfony-version: 6 - - php-version: 8.0 - symfony-version: 3 - php-version: 8.0 symfony-version: 6 + - php-version: 8.0 + symfony-version: 7 + - php-version: 8.1 + symfony-version: 7 steps: - name: "Checkout" - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: "Install PHP with extensions" uses: shivammathur/setup-php@v2 @@ -125,7 +106,3 @@ jobs: - name: "Run acceptance tests with codeception" run: vendor/bin/codecept run -v -g symfony-common -g symfony-${{ matrix.symfony-version }} - - - name: "Run acceptance tests with codeception PHP8 only tests" - run: vendor/bin/codecept run -v -g php-8 - if: matrix.php-version == '8.0' diff --git a/README.md b/README.md index bac01948..753e59c2 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,16 @@ vendor/bin/psalm --init vendor/bin/psalm-plugin enable psalm/plugin-symfony ``` +### Versions & Dependencies + +| Symfony Psalm Plugin | PHP | Symfony | Psalm | +|----------------------|------------|---------|-------| +| 5.x | ^7.4, ^8.0 | 5, 6 | 5 | +| 4.x | ^7.4, ^8.0 | 4, 5, 6 | 4 | +| 3.x | ^7.1, ^8.0 | 4, 5, 6 | 4 | +| 2.x | ^7.1, ^8.0 | 4, 5 | 4 | +| 1.x | ^7.1 | 3, 4, 5 | 3 | + ### Features - Detects the `ContainerInterface::get()` result type. Works better if you [configure](#configuration) a compiled container XML file. @@ -59,7 +69,7 @@ This file path may change based on your Symfony version, file structure and envi Default files are: - Symfony 3: `var/cache/dev/srcDevDebugProjectContainer.xml` - Symfony 4: `var/cache/dev/srcApp_KernelDevDebugContainer.xml` -- Symfony 5: `var/cache/dev/App_KernelDevDebugContainer.xml` +- Symfony 5+: `var/cache/dev/App_KernelDevDebugContainer.xml` Multiple container files can be configured. In this case, the first valid file is taken into account. If none of the given files is valid, a configuration exception is thrown. @@ -80,6 +90,14 @@ If you're using PHP config files for Symfony 5.3+, you also need this for auto-l ``` +If you're using Symfony's `env()` or `param()` functions in your PHP config files, you also need this for auto-loading them: + +```xml + + + +``` + If you're getting the following error > MissingFile - config/preload.php - Cannot find file ...var/cache/prod/App_KernelProdContainer.preload.php to include diff --git a/composer.json b/composer.json index 2f91b5ea..6d3bc610 100644 --- a/composer.json +++ b/composer.json @@ -10,24 +10,24 @@ } ], "require": { - "php": "^7.1 || ^8.0", + "php": "^7.4 || ^8.0", "ext-simplexml": "*", - "symfony/framework-bundle": "^4.0 || ^5.0 || ^6.0", - "vimeo/psalm": "^4.12" + "symfony/framework-bundle": "^5.0 || ^6.0 || ^7.0", + "vimeo/psalm": "^5.1" }, "require-dev": { - "symfony/form": "^4.0 || ^5.0 || ^6.0", - "doctrine/annotations": "^1.8", - "doctrine/orm": "^2.7", + "symfony/form": "^5.0 || ^6.0 || ^7.0", + "doctrine/annotations": "^1.8|^2", + "doctrine/orm": "^2.9", "phpunit/phpunit": "~7.5 || ~9.5", "symfony/cache-contracts": "^1.0 || ^2.0", "symfony/console": "*", - "symfony/messenger": "^4.2 || ^5.0 || ^6.0", + "symfony/messenger": "^5.0 || ^6.0 || ^7.0", "symfony/security-guard": "*", - "symfony/serializer": "^4.0 || ^5.0 || ^6.0", + "symfony/serializer": "^5.0 || ^6.0 || ^7.0", "symfony/validator": "*", "twig/twig": "^2.10 || ^3.0", - "weirdan/codeception-psalm-module": "^0.13.1" + "weirdan/codeception-psalm-module": "dev-master" }, "autoload": { "psr-4": { diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 30800ce4..6f8f4232 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,19 +1,14 @@ - + + + + DeprecatedMethod + + - + int string - - - NonInvariantDocblockPropertyType - - - - - attributes - - diff --git a/psalm.xml b/psalm.xml index 29f47298..259e0c0d 100644 --- a/psalm.xml +++ b/psalm.xml @@ -5,6 +5,8 @@ xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" cacheDirectory=".build/psalm" errorBaseline="psalm-baseline.xml" + findUnusedCode="true" + findUnusedBaselineEntry="false" > diff --git a/src/Handler/AnnotationHandler.php b/src/Handler/AnnotationHandler.php index 7487834d..37a54e43 100644 --- a/src/Handler/AnnotationHandler.php +++ b/src/Handler/AnnotationHandler.php @@ -9,9 +9,6 @@ class AnnotationHandler implements AfterClassLikeVisitInterface { - /** - * {@inheritdoc} - */ public static function afterClassLikeVisit(AfterClassLikeVisitEvent $event) { $stmt = $event->getStmt(); diff --git a/src/Handler/ConsoleHandler.php b/src/Handler/ConsoleHandler.php index e5eb7d95..c18f511f 100644 --- a/src/Handler/ConsoleHandler.php +++ b/src/Handler/ConsoleHandler.php @@ -21,6 +21,7 @@ use Psalm\Type\Atomic\TInt; use Psalm\Type\Atomic\TNull; use Psalm\Type\Atomic\TString; +use Psalm\Type\MutableUnion; use Psalm\Type\Union; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; @@ -31,15 +32,12 @@ class ConsoleHandler implements AfterMethodCallAnalysisInterface /** * @var Union[] */ - private static $arguments = []; + private static array $arguments = []; /** * @var Union[] */ - private static $options = []; + private static array $options = []; - /** - * {@inheritdoc} - */ public static function afterMethodCallAnalysis(AfterMethodCallAnalysisEvent $event): void { $statements_source = $event->getStatementsSource(); @@ -149,22 +147,19 @@ private static function analyseArgument(array $args, StatementsSource $statement } if ($mode & InputArgument::IS_ARRAY) { - $returnTypes = new Union([new TArray([new Union([new TInt()]), new Union([new TString()])])]); + $returnTypes = new MutableUnion([new TArray([new Union([new TInt()]), new Union([new TString()])])]); } elseif ($mode & InputArgument::REQUIRED) { - $returnTypes = new Union([new TString()]); + $returnTypes = new MutableUnion([new TString()]); } else { - $returnTypes = new Union([new TString(), new TNull()]); + $returnTypes = new MutableUnion([new TString(), new TNull()]); } $defaultParam = $normalizedParams['default']; - if ($defaultParam) { + if ($defaultParam && (!$defaultParam->value instanceof Expr\ConstFetch || 'null' !== $defaultParam->value->name->getFirst())) { $returnTypes->removeType('null'); - if ($defaultParam->value instanceof Expr\ConstFetch && 'null' === $defaultParam->value->name->parts[0]) { - $returnTypes->addType(new TNull()); - } } - self::$arguments[$identifier] = $returnTypes; + self::$arguments[$identifier] = $returnTypes->freeze(); } /** @@ -199,7 +194,7 @@ private static function analyseOption(array $args, StatementsSource $statements_ $mode = InputOption::VALUE_OPTIONAL; } - $returnTypes = new Union([new TString(), new TNull()]); + $returnTypes = new MutableUnion([new TString(), new TNull()]); $defaultParam = $normalizedParams['default']; if ($defaultParam) { @@ -208,7 +203,7 @@ private static function analyseOption(array $args, StatementsSource $statements_ } if ($defaultParam->value instanceof Expr\ConstFetch) { - switch ($defaultParam->value->name->parts[0]) { + switch ($defaultParam->value->name->getFirst()) { case 'null': $returnTypes->addType(new TNull()); break; @@ -221,7 +216,7 @@ private static function analyseOption(array $args, StatementsSource $statements_ } if ($mode & InputOption::VALUE_NONE) { - $returnTypes = new Union([new TBool()]); + $returnTypes = new MutableUnion([new TBool()]); } if ($mode & InputOption::VALUE_REQUIRED && $mode & InputOption::VALUE_IS_ARRAY) { @@ -229,10 +224,10 @@ private static function analyseOption(array $args, StatementsSource $statements_ } if ($mode & InputOption::VALUE_IS_ARRAY) { - $returnTypes = new Union([new TArray([new Union([new TInt()]), $returnTypes])]); + $returnTypes = new MutableUnion([new TArray([new Union([new TInt()]), $returnTypes->freeze()])]); } - self::$options[$identifier] = $returnTypes; + self::$options[$identifier] = $returnTypes->freeze(); } /** @@ -264,7 +259,7 @@ private static function normalizeParams(array $params, array $args): array $key = array_search($name, $params); Assert::integer($key); - $params = array_slice($params, $key + 1); + unset($params[$key]); } else { $name = array_shift($params); } @@ -275,10 +270,7 @@ private static function normalizeParams(array $params, array $args): array return $result; } - /** - * @param mixed $mode - */ - private static function getModeValue($mode): ?int + private static function getModeValue(Expr $mode): ?int { if ($mode instanceof Expr\BinaryOp\BitwiseOr) { return self::getModeValue($mode->left) | self::getModeValue($mode->right); diff --git a/src/Handler/ContainerDependencyHandler.php b/src/Handler/ContainerDependencyHandler.php index 52ac8a72..0988ba32 100644 --- a/src/Handler/ContainerDependencyHandler.php +++ b/src/Handler/ContainerDependencyHandler.php @@ -12,9 +12,6 @@ class ContainerDependencyHandler implements AfterFunctionLikeAnalysisInterface { - /** - * {@inheritdoc} - */ public static function afterStatementAnalysis(AfterFunctionLikeAnalysisEvent $event): ?bool { $stmt = $event->getStmt(); diff --git a/src/Handler/ContainerHandler.php b/src/Handler/ContainerHandler.php index d24df3a0..c22ae57f 100644 --- a/src/Handler/ContainerHandler.php +++ b/src/Handler/ContainerHandler.php @@ -2,8 +2,6 @@ namespace Psalm\SymfonyPsalmPlugin\Handler; -use function constant; - use PhpParser\Node\Arg; use PhpParser\Node\Expr\ClassConstFetch; use PhpParser\Node\Identifier; @@ -11,9 +9,13 @@ use Psalm\CodeLocation; use Psalm\IssueBuffer; use Psalm\Plugin\EventHandler\AfterClassLikeVisitInterface; +use Psalm\Plugin\EventHandler\AfterCodebasePopulatedInterface; use Psalm\Plugin\EventHandler\AfterMethodCallAnalysisInterface; +use Psalm\Plugin\EventHandler\BeforeAddIssueInterface; use Psalm\Plugin\EventHandler\Event\AfterClassLikeVisitEvent; +use Psalm\Plugin\EventHandler\Event\AfterCodebasePopulatedEvent; use Psalm\Plugin\EventHandler\Event\AfterMethodCallAnalysisEvent; +use Psalm\Plugin\EventHandler\Event\BeforeAddIssueEvent; use Psalm\SymfonyPsalmPlugin\Issue\NamingConventionViolation; use Psalm\SymfonyPsalmPlugin\Issue\PrivateService; use Psalm\SymfonyPsalmPlugin\Issue\ServiceNotFound; @@ -22,7 +24,7 @@ use Psalm\Type\Union; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; -class ContainerHandler implements AfterMethodCallAnalysisInterface, AfterClassLikeVisitInterface +class ContainerHandler implements AfterMethodCallAnalysisInterface, AfterClassLikeVisitInterface, AfterCodebasePopulatedInterface, BeforeAddIssueInterface { private const GET_CLASSLIKES = [ 'Psr\Container\ContainerInterface', @@ -38,14 +40,20 @@ class ContainerHandler implements AfterMethodCallAnalysisInterface, AfterClassLi */ private static $containerMeta; + /** + * @var array collection of cower-cased class names that are present in the container + */ + private static array $containerClassNames = []; + public static function init(ContainerMeta $containerMeta): void { self::$containerMeta = $containerMeta; + + self::$containerClassNames = array_map(function (string $className): string { + return strtolower($className); + }, self::$containerMeta->getClassNames()); } - /** - * {@inheritdoc} - */ public static function afterMethodCallAnalysis(AfterMethodCallAnalysisEvent $event): void { $declaring_method_id = $event->getDeclaringMethodId(); @@ -105,7 +113,7 @@ public static function afterMethodCallAnalysis(AfterMethodCallAnalysisEvent $eve $serviceId = $className; } else { try { - $serviceId = constant($className.'::'.$idArgument->name->name); + $serviceId = \constant($className.'::'.$idArgument->name->name); } catch (\Exception $e) { return; } @@ -133,8 +141,8 @@ public static function afterMethodCallAnalysis(AfterMethodCallAnalysisEvent $eve if (!$service->isPublic()) { /** @var class-string $kernelTestCaseClass */ $kernelTestCaseClass = 'Symfony\Bundle\FrameworkBundle\Test\KernelTestCase'; - $isTestContainer = $context->parent && - ($kernelTestCaseClass === $context->parent + $isTestContainer = $context->parent + && ($kernelTestCaseClass === $context->parent || is_subclass_of($context->parent, $kernelTestCaseClass) ); if (!$isTestContainer) { @@ -152,9 +160,6 @@ public static function afterMethodCallAnalysis(AfterMethodCallAnalysisEvent $eve } } - /** - * {@inheritdoc} - */ public static function afterClassLikeVisit(AfterClassLikeVisitEvent $event) { $codebase = $event->getCodebase(); @@ -173,7 +178,34 @@ public static function afterClassLikeVisit(AfterClassLikeVisitEvent $event) } } - private static function isContainerMethod(string $declaringMethodId, string $methodName): bool + public static function afterCodebasePopulated(AfterCodebasePopulatedEvent $event): void + { + if (null === self::$containerMeta) { + return; + } + + foreach ($event->getCodebase()->classlike_storage_provider->getAll() as $name => $storage) { + if (in_array($name, self::$containerClassNames, true)) { + $storage->suppressed_issues[] = 'UnusedClass'; + } + } + } + + public static function beforeAddIssue(BeforeAddIssueEvent $event): ?bool + { + $data = $event->getIssue()->toIssueData('error'); + if ('PossiblyUnusedMethod' === $data->type + && '__construct' === $data->selected_text + && null !== $data->dupe_key + && in_array(preg_replace('/::__construct$/', '', $data->dupe_key), self::$containerClassNames, true)) { + // Don't report service constructors as PossiblyUnusedMethod + return false; + } + + return null; + } + + public static function isContainerMethod(string $declaringMethodId, string $methodName): bool { return in_array( $declaringMethodId, diff --git a/src/Handler/DoctrineQueryBuilderHandler.php b/src/Handler/DoctrineQueryBuilderHandler.php index 1104999b..a7d8dc9e 100644 --- a/src/Handler/DoctrineQueryBuilderHandler.php +++ b/src/Handler/DoctrineQueryBuilderHandler.php @@ -12,9 +12,6 @@ class DoctrineQueryBuilderHandler implements AfterMethodCallAnalysisInterface { - /** - * {@inheritdoc} - */ public static function afterMethodCallAnalysis(AfterMethodCallAnalysisEvent $event): void { $expr = $event->getExpr(); diff --git a/src/Handler/DoctrineRepositoryHandler.php b/src/Handler/DoctrineRepositoryHandler.php index f5967157..91d8c883 100644 --- a/src/Handler/DoctrineRepositoryHandler.php +++ b/src/Handler/DoctrineRepositoryHandler.php @@ -20,9 +20,6 @@ class DoctrineRepositoryHandler implements AfterMethodCallAnalysisInterface, AfterClassLikeVisitInterface { - /** - * {@inheritdoc} - */ public static function afterMethodCallAnalysis(AfterMethodCallAnalysisEvent $event): void { $expr = $event->getExpr(); @@ -41,13 +38,17 @@ public static function afterMethodCallAnalysis(AfterMethodCallAnalysisEvent $eve $statements_source->getSuppressedIssues() ); } elseif ($entityName instanceof Expr\ClassConstFetch) { - /** @psalm-var class-string $className */ + /** @psalm-var class-string|null $className */ $className = $entityName->class->getAttribute('resolvedName'); + if (null === $className) { + return; + } + try { $reflectionClass = new \ReflectionClass($className); - if (method_exists(\ReflectionClass::class, 'getAttributes')) { + if (\PHP_VERSION_ID >= 80000 && method_exists(\ReflectionClass::class, 'getAttributes')) { $entityAttributes = $reflectionClass->getAttributes(EntityAnnotation::class); foreach ($entityAttributes as $entityAttribute) { diff --git a/src/Handler/HeaderBagHandler.php b/src/Handler/HeaderBagHandler.php index 8493e4ab..e5827089 100644 --- a/src/Handler/HeaderBagHandler.php +++ b/src/Handler/HeaderBagHandler.php @@ -60,7 +60,7 @@ public static function getMethodReturnType(MethodReturnTypeProviderEvent $event) */ private static function makeReturnType(array $call_args): Union { - if (3 === count($call_args) && (($arg = $call_args[2]->value) instanceof ConstFetch) && 'false' === $arg->name->parts[0]) { + if (3 === count($call_args) && (($arg = $call_args[2]->value) instanceof ConstFetch) && 'false' === $arg->name->getFirst()) { return new Union([new TArray([new Union([new TInt()]), new Union([new TString()])])]); } diff --git a/src/Handler/ParameterBagHandler.php b/src/Handler/ParameterBagHandler.php index eec91c2b..f8a84ad1 100644 --- a/src/Handler/ParameterBagHandler.php +++ b/src/Handler/ParameterBagHandler.php @@ -24,10 +24,16 @@ public static function init(ContainerMeta $containerMeta): void public static function afterMethodCallAnalysis(AfterMethodCallAnalysisEvent $event): void { + if (!self::$containerMeta) { + return; + } + $declaring_method_id = $event->getDeclaringMethodId(); $expr = $event->getExpr(); - if (!self::$containerMeta || 'Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface::get' !== $declaring_method_id) { + if (!ContainerHandler::isContainerMethod($declaring_method_id, 'getparameter') + && 'Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface::get' !== $declaring_method_id + ) { return; } @@ -36,7 +42,6 @@ public static function afterMethodCallAnalysis(AfterMethodCallAnalysisEvent $eve } $argument = $expr->args[0]->value->value; - try { $parameter = self::$containerMeta->getParameter($argument); } catch (ParameterNotFoundException $e) { @@ -53,7 +58,7 @@ public static function afterMethodCallAnalysis(AfterMethodCallAnalysisEvent $eve $event->setReturnTypeCandidate(new Union([Atomic::create('bool')])); break; case 'integer': - $event->setReturnTypeCandidate(new Union([Atomic::create('integer')])); + $event->setReturnTypeCandidate(new Union([Atomic::create('int')])); break; case 'double': $event->setReturnTypeCandidate(new Union([Atomic::create('float')])); diff --git a/src/Handler/RequiredSetterHandler.php b/src/Handler/RequiredSetterHandler.php index 1feb112a..5d21dce0 100644 --- a/src/Handler/RequiredSetterHandler.php +++ b/src/Handler/RequiredSetterHandler.php @@ -10,6 +10,7 @@ use Psalm\Internal\PhpVisitor\AssignmentMapVisitor; use Psalm\Plugin\EventHandler\AfterClassLikeVisitInterface; use Psalm\Plugin\EventHandler\Event\AfterClassLikeVisitEvent; +use Psalm\Storage\ClassLikeStorage; class RequiredSetterHandler implements AfterClassLikeVisitInterface { @@ -18,32 +19,56 @@ public static function afterClassLikeVisit(AfterClassLikeVisitEvent $event) $stmt = $event->getStmt(); $storage = $event->getStorage(); + $stmt_source = $event->getStatementsSource(); + $aliases = $stmt_source->getAliases(); + if (!$stmt instanceof Class_) { return; } foreach ($stmt->getMethods() as $method) { + // Check for PhpDoc annotation $docComment = $method->getDocComment(); - if ($docComment instanceof Doc && false !== strpos($docComment->getText(), '@required')) { - $traverser = new NodeTraverser(); - $visitor = new AssignmentMapVisitor(null); - $traverser->addVisitor($visitor); - $traverser->traverse($method->getStmts() ?? []); - - foreach (array_keys($visitor->getAssignmentMap()) as $assignment) { - if (0 !== strpos($assignment, '$this->')) { - continue; - } + self::markAsInitializedProperties($storage, $method->getStmts() ?? []); + } - $property = substr($assignment, strlen('$this->')); - if (!array_key_exists($property, $storage->properties)) { - continue; + // Check for attribute annotation + foreach ($method->getAttrGroups() as $attrGroup) { + foreach ($attrGroup->attrs as $attribute) { + /** @var lowercase-string $lcName */ + $lcName = $attribute->name->toLowerString(); + if (array_key_exists($lcName, $aliases->uses)) { + $name = $aliases->uses[$lcName]; + } else { + $name = $attribute->name->toString(); + } + if ('Symfony\Contracts\Service\Attribute\Required' === $name) { + self::markAsInitializedProperties($storage, $method->getStmts() ?? []); } - - $storage->initialized_properties[$property] = true; } } } } + + private static function markAsInitializedProperties(ClassLikeStorage $storage, array $stmts): void + { + $traverser = new NodeTraverser(); + $visitor = new AssignmentMapVisitor(null); + $traverser->addVisitor($visitor); + $traverser->traverse($stmts); + + foreach (array_keys($visitor->getAssignmentMap()) as $assignment) { + if (0 !== strpos($assignment, '$this->')) { + continue; + } + + $property = substr($assignment, strlen('$this->')); + if (!array_key_exists($property, $storage->properties)) { + continue; + } + + $storage->initialized_properties[$property] = true; + } + } } diff --git a/src/Plugin.php b/src/Plugin.php index b82be3a0..ab61d402 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -21,7 +21,6 @@ use Psalm\SymfonyPsalmPlugin\Twig\CachedTemplatesMapping; use Psalm\SymfonyPsalmPlugin\Twig\CachedTemplatesTainter; use Psalm\SymfonyPsalmPlugin\Twig\TemplateFileAnalyzer; -use SimpleXMLElement; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; use Symfony\Component\HttpKernel\Kernel; @@ -30,10 +29,7 @@ */ class Plugin implements PluginEntryPointInterface { - /** - * {@inheritdoc} - */ - public function __invoke(RegistrationInterface $api, SimpleXMLElement $config = null): void + public function __invoke(RegistrationInterface $api, \SimpleXMLElement $config = null): void { require_once __DIR__.'/Handler/HeaderBagHandler.php'; require_once __DIR__.'/Handler/ContainerHandler.php'; @@ -54,8 +50,10 @@ public function __invoke(RegistrationInterface $api, SimpleXMLElement $config = if (class_exists(AnnotationRegistry::class)) { require_once __DIR__.'/Handler/DoctrineRepositoryHandler.php'; - /** @psalm-suppress DeprecatedMethod */ - AnnotationRegistry::registerLoader('class_exists'); + if (method_exists(AnnotationRegistry::class, 'registerLoader')) { + /** @psalm-suppress DeprecatedMethod */ + AnnotationRegistry::registerLoader('class_exists'); + } $api->registerHooksFromClass(DoctrineRepositoryHandler::class); require_once __DIR__.'/Handler/AnnotationHandler.php'; @@ -67,7 +65,8 @@ public function __invoke(RegistrationInterface $api, SimpleXMLElement $config = ContainerHandler::init($containerMeta); try { - TemplateFileAnalyzer::initExtensions(array_filter(array_map(function (array $m) use ($containerMeta) { + /** @psalm-var list $extensionClasses */ + $extensionClasses = array_filter(array_map(function (array $m) use ($containerMeta) { if ('addExtension' !== $m[0]) { return null; } @@ -77,7 +76,8 @@ public function __invoke(RegistrationInterface $api, SimpleXMLElement $config = } catch (ServiceNotFoundException $e) { return null; } - }, $containerMeta->get('twig')->getMethodCalls()))); + }, $containerMeta->get('twig')->getMethodCalls())); + TemplateFileAnalyzer::initExtensions($extensionClasses); } catch (ServiceNotFoundException $e) { } diff --git a/src/Stubs/4/Bundle/FrameworkBundle/Controller/AbstractController.stubphp b/src/Stubs/4/Bundle/FrameworkBundle/Controller/AbstractController.stubphp deleted file mode 100644 index 9dd5b346..00000000 --- a/src/Stubs/4/Bundle/FrameworkBundle/Controller/AbstractController.stubphp +++ /dev/null @@ -1,27 +0,0 @@ - - * - * @psalm-param class-string $type - * - * @psalm-return FormInterface - */ - public function createForm(string $type, $data = null, array $options = []): FormInterface {} -} diff --git a/src/Stubs/4/Component/HttpFoundation/ParameterBag.stubphp b/src/Stubs/4/Component/HttpFoundation/ParameterBag.stubphp deleted file mode 100644 index 0f7462bb..00000000 --- a/src/Stubs/4/Component/HttpFoundation/ParameterBag.stubphp +++ /dev/null @@ -1,28 +0,0 @@ - */ - public function createForm(string $type, $data = null, array $options = []): FormInterface {} + protected function createForm(string $type, $data = null, array $options = []): FormInterface {} } diff --git a/src/Stubs/5/Component/HttpFoundation/Request.stubphp b/src/Stubs/5/Component/HttpFoundation/Request.stubphp index 777850f4..edda81ca 100644 --- a/src/Stubs/5/Component/HttpFoundation/Request.stubphp +++ b/src/Stubs/5/Component/HttpFoundation/Request.stubphp @@ -18,9 +18,4 @@ class Request * @psalm-var InputBag */ public $cookies; - - /** - * @return \Symfony\Component\HttpFoundation\Session\Session - */ - public function getSession(): SessionInterface; } diff --git a/src/Stubs/common/Component/Serializer/Normalizer/DenormalizerInterface.stubphp b/src/Stubs/5/Component/Serializer/Normalizer/DenormalizerInterface.stubphp similarity index 70% rename from src/Stubs/common/Component/Serializer/Normalizer/DenormalizerInterface.stubphp rename to src/Stubs/5/Component/Serializer/Normalizer/DenormalizerInterface.stubphp index e559b751..47df42bb 100644 --- a/src/Stubs/common/Component/Serializer/Normalizer/DenormalizerInterface.stubphp +++ b/src/Stubs/5/Component/Serializer/Normalizer/DenormalizerInterface.stubphp @@ -7,9 +7,9 @@ interface DenormalizerInterface /** * @template TObject of object * @template TType of string|class-string - * @psalm-param mixed $data + * * @psalm-param TType $type * @psalm-return (TType is class-string ? TObject : mixed) */ - public function denormalize($data, string $type, string $format = null, array $context = []); + public function denormalize(mixed $data, string $type, string $format = null, array $context = []); } diff --git a/src/Stubs/common/Component/Serializer/SerializerInterface.stubphp b/src/Stubs/5/Component/Serializer/SerializerInterface.stubphp similarity index 100% rename from src/Stubs/common/Component/Serializer/SerializerInterface.stubphp rename to src/Stubs/5/Component/Serializer/SerializerInterface.stubphp diff --git a/src/Stubs/6/Bundle/FrameworkBundle/Controller/AbstractController.stubphp b/src/Stubs/6/Bundle/FrameworkBundle/Controller/AbstractController.stubphp index 9dd5b346..8bf41530 100644 --- a/src/Stubs/6/Bundle/FrameworkBundle/Controller/AbstractController.stubphp +++ b/src/Stubs/6/Bundle/FrameworkBundle/Controller/AbstractController.stubphp @@ -7,7 +7,7 @@ use Psr\Container\ContainerInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormTypeInterface; -class AbstractController implements ServiceSubscriberInterface +abstract class AbstractController implements ServiceSubscriberInterface { /** * @var ContainerInterface @@ -23,5 +23,5 @@ class AbstractController implements ServiceSubscriberInterface * * @psalm-return FormInterface */ - public function createForm(string $type, $data = null, array $options = []): FormInterface {} + protected function createForm(string $type, $data = null, array $options = []): FormInterface {} } diff --git a/src/Stubs/6/Component/HttpFoundation/Request.stubphp b/src/Stubs/6/Component/HttpFoundation/Request.stubphp index 777850f4..edda81ca 100644 --- a/src/Stubs/6/Component/HttpFoundation/Request.stubphp +++ b/src/Stubs/6/Component/HttpFoundation/Request.stubphp @@ -18,9 +18,4 @@ class Request * @psalm-var InputBag */ public $cookies; - - /** - * @return \Symfony\Component\HttpFoundation\Session\Session - */ - public function getSession(): SessionInterface; } diff --git a/src/Stubs/6/Component/Serializer/Normalizer/DenormalizerInterface.stubphp b/src/Stubs/6/Component/Serializer/Normalizer/DenormalizerInterface.stubphp new file mode 100644 index 00000000..47df42bb --- /dev/null +++ b/src/Stubs/6/Component/Serializer/Normalizer/DenormalizerInterface.stubphp @@ -0,0 +1,15 @@ + + * + * @psalm-param TType $type + * @psalm-return (TType is class-string ? TObject : mixed) + */ + public function denormalize(mixed $data, string $type, string $format = null, array $context = []); +} diff --git a/src/Stubs/common/Component/HttpFoundation/HeaderBag.stubphp b/src/Stubs/common/Component/HttpFoundation/HeaderBag.stubphp index 4230fafb..89a3c4da 100644 --- a/src/Stubs/common/Component/HttpFoundation/HeaderBag.stubphp +++ b/src/Stubs/common/Component/HttpFoundation/HeaderBag.stubphp @@ -17,4 +17,10 @@ class HeaderBag implements \IteratorAggregate, \Countable * @psalm-taint-source input */ public function __toString() {} + + /** + * @psalm-taint-source input + * @psalm-mutation-free + */ + public function get(string $key, string $default = null): ?string {} } diff --git a/src/Stubs/common/Component/HttpFoundation/Request.stubphp b/src/Stubs/common/Component/HttpFoundation/Request.stubphp index e0664ae8..497773c1 100644 --- a/src/Stubs/common/Component/HttpFoundation/Request.stubphp +++ b/src/Stubs/common/Component/HttpFoundation/Request.stubphp @@ -11,11 +11,9 @@ class Request * * @throws \LogicException * - * @psalm-return ( - * $asResource is true - * ? resource - * : string - * ) + * @psalm-template TAsResource as bool + * @psalm-param TAsResource $asResource + * @psalm-return (TAsResource is true ? resource : string) */ public function getContent($asResource = false) {} diff --git a/src/Stubs/common/Component/HttpFoundation/Response.stubphp b/src/Stubs/common/Component/HttpFoundation/Response.stubphp index 3d2a95c8..a494a141 100644 --- a/src/Stubs/common/Component/HttpFoundation/Response.stubphp +++ b/src/Stubs/common/Component/HttpFoundation/Response.stubphp @@ -13,5 +13,5 @@ class Response * @throws \InvalidArgumentException When the HTTP status code is not valid * @psalm-taint-sink html $content */ - public function __construct($content = '', int $status = 200, array $headers = []) {} + public function __construct(?string $content = '', int $status = 200, array $headers = []) {} } diff --git a/src/Symfony/ContainerMeta.php b/src/Symfony/ContainerMeta.php index 30ba76a0..7dfee1d9 100644 --- a/src/Symfony/ContainerMeta.php +++ b/src/Symfony/ContainerMeta.php @@ -44,7 +44,7 @@ public function __construct(array $containerXmlPaths) /** * @throws ServiceNotFoundException */ - public function get(string $id, ?string $contextClass = null): Definition + public function get(string $id, string $contextClass = null): Definition { if ($contextClass && isset($this->classLocators[$contextClass]) && isset($this->serviceLocators[$this->classLocators[$contextClass]]) && isset($this->serviceLocators[$this->classLocators[$contextClass]][$id])) { $id = $this->serviceLocators[$this->classLocators[$contextClass]][$id]; diff --git a/src/Test/CodeceptionModule.php b/src/Test/CodeceptionModule.php index 192dd82f..0e58bc28 100644 --- a/src/Test/CodeceptionModule.php +++ b/src/Test/CodeceptionModule.php @@ -4,10 +4,10 @@ namespace Psalm\SymfonyPsalmPlugin\Test; +use Behat\Gherkin\Node\PyStringNode; use Codeception\Exception\ModuleRequireException; use Codeception\Module as BaseModule; use Codeception\TestInterface; -use InvalidArgumentException; use Psalm\SymfonyPsalmPlugin\Twig\CachedTemplatesMapping; use Twig\Cache\FilesystemCache; use Twig\Environment; @@ -21,15 +21,6 @@ */ class CodeceptionModule extends BaseModule { - /** - * @var mixed[] - * - * @psalm-suppress NonInvariantDocblockPropertyType - */ - protected $config = [ - 'default_dir' => 'tests/_run/', - ]; - private const DEFAULT_TWIG_TEMPLATES_DIR = 'templates'; /** @@ -52,6 +43,13 @@ class CodeceptionModule extends BaseModule */ private $suppressedIssueHandlers = []; + public function _initialize(): void + { + $this->_setConfig([ + 'default_dir' => 'tests/_run/', + ]); + } + public function _after(TestInterface $test): void { $this->twigCache = $this->lastCachePath = null; @@ -69,7 +67,7 @@ public function setTheTemplateRootDirectory(string $rootDir): void /** * @Given I have the following :templateName template :code */ - public function haveTheFollowingTemplate(string $templateName, string $code): void + public function haveTheFollowingTemplate(string $templateName, PyStringNode $code): void { $rootDirectory = rtrim($this->config['default_dir'], DIRECTORY_SEPARATOR); $templatePath = ( @@ -82,7 +80,7 @@ public function haveTheFollowingTemplate(string $templateName, string $code): vo mkdir($templateDirectory, 0755, true); } - file_put_contents($templatePath, $code); + file_put_contents($templatePath, $code->getRaw()); } /** @@ -137,9 +135,16 @@ public function configureIgnoredIssueHandlers(string $issueHandlers): void /** * @Given I have Symfony plugin enabled + */ + public function configureCommonPsalmconfigEmpty(): void + { + $this->configureCommonPsalmconfig(new PyStringNode([], 0)); + } + + /** * @Given I have Symfony plugin enabled with the following config :configuration */ - public function configureCommonPsalmconfig(string $configuration = ''): void + public function configureCommonPsalmconfig(PyStringNode $configuration): void { $suppressedIssueHandlers = implode("\n", array_map(function (string $issueHandler) { return "<$issueHandler errorLevel=\"info\"/>"; @@ -161,7 +166,7 @@ public function configureCommonPsalmconfig(string $configuration = ''): void - $configuration + {$configuration->getRaw()} @@ -176,7 +181,7 @@ private function loadTemplate(string $templateName, string $rootDirectory, strin { if (null === $this->twigCache) { if (!is_dir($cacheDirectory)) { - throw new InvalidArgumentException(sprintf('The %s twig cache directory does not exist or is not readable.', $cacheDirectory)); + throw new \InvalidArgumentException(sprintf('The %s twig cache directory does not exist or is not readable.', $cacheDirectory)); } $this->twigCache = new FilesystemCache($cacheDirectory); } diff --git a/src/Twig/AnalyzedTemplatesTainter.php b/src/Twig/AnalyzedTemplatesTainter.php index 9e115c63..b29e7541 100644 --- a/src/Twig/AnalyzedTemplatesTainter.php +++ b/src/Twig/AnalyzedTemplatesTainter.php @@ -16,7 +16,6 @@ use Psalm\StatementsSource; use Psalm\SymfonyPsalmPlugin\Exception\TemplateNameUnresolvedException; use Psalm\Type\Atomic\TKeyedArray; -use RuntimeException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Twig\Environment; @@ -86,7 +85,7 @@ private static function generateTemplateParameters(Expr $templateParameters, Sta { $type = $source->getNodeTypeProvider()->getType($templateParameters); if (null === $type) { - throw new RuntimeException(sprintf('Can not retrieve type for the given expression (%s)', get_class($templateParameters))); + throw new \RuntimeException(sprintf('Can not retrieve type for the given expression (%s)', get_class($templateParameters))); } if ($templateParameters instanceof Array_) { @@ -112,6 +111,6 @@ private static function generateTemplateParameters(Expr $templateParameters, Sta return $parameters; } - throw new RuntimeException(sprintf('Can not retrieve template parameters from given expression (%s)', get_class($templateParameters))); + throw new \RuntimeException(sprintf('Can not retrieve template parameters from given expression (%s)', get_class($templateParameters))); } } diff --git a/src/Twig/CachedTemplateNotFoundException.php b/src/Twig/CachedTemplateNotFoundException.php index 24106471..1ae983eb 100644 --- a/src/Twig/CachedTemplateNotFoundException.php +++ b/src/Twig/CachedTemplateNotFoundException.php @@ -4,9 +4,7 @@ namespace Psalm\SymfonyPsalmPlugin\Twig; -use Exception; - -class CachedTemplateNotFoundException extends Exception +class CachedTemplateNotFoundException extends \Exception { public function __construct() { diff --git a/src/Twig/CachedTemplatesMapping.php b/src/Twig/CachedTemplatesMapping.php index 6ec2d448..c2c21b85 100644 --- a/src/Twig/CachedTemplatesMapping.php +++ b/src/Twig/CachedTemplatesMapping.php @@ -6,7 +6,6 @@ use Psalm\Plugin\EventHandler\AfterCodebasePopulatedInterface; use Psalm\Plugin\EventHandler\Event\AfterCodebasePopulatedEvent; -use RuntimeException; /** * This class is used to store a mapping of all analyzed twig template cache files with their corresponding actual templates. @@ -66,7 +65,7 @@ public static function setCachePath(string $cachePath): void public static function getCacheClassName(string $templateName): string { if (null === self::$cacheRegistry) { - throw new RuntimeException(sprintf('Can not load template %s, because no cache registry is provided.', $templateName)); + throw new \RuntimeException(sprintf('Can not load template %s, because no cache registry is provided.', $templateName)); } return self::$cacheRegistry->getCacheClassName($templateName); diff --git a/src/Twig/CachedTemplatesRegistry.php b/src/Twig/CachedTemplatesRegistry.php index 6c169152..14c479ab 100644 --- a/src/Twig/CachedTemplatesRegistry.php +++ b/src/Twig/CachedTemplatesRegistry.php @@ -4,8 +4,6 @@ namespace Psalm\SymfonyPsalmPlugin\Twig; -use Generator; - class CachedTemplatesRegistry { /** @@ -36,9 +34,9 @@ public function getCacheClassName(string $templateName): string } /** - * @return Generator + * @return \Generator */ - private static function generateNames(string $baseName): Generator + private static function generateNames(string $baseName): \Generator { yield $baseName; @@ -57,8 +55,10 @@ private static function generateNames(string $baseName): Generator $oldNotation = $baseName; } - if (null !== $oldNotation) { + if (null !== $oldNotation && false !== strpos($oldNotation, ':')) { + /** @psalm-suppress PossiblyUndefinedArrayOffset */ list($bundleName, $rest) = explode(':', $oldNotation, 2); + /** @psalm-suppress PossiblyUndefinedArrayOffset */ list($revTemplateName, $revRest) = explode(':', strrev($rest), 2); $pathParts = explode('/', strrev($revRest)); $pathParts = array_merge($pathParts, explode('/', strrev($revTemplateName))); diff --git a/src/Twig/CachedTemplatesTainter.php b/src/Twig/CachedTemplatesTainter.php index 180e9257..fe01ba15 100644 --- a/src/Twig/CachedTemplatesTainter.php +++ b/src/Twig/CachedTemplatesTainter.php @@ -14,7 +14,6 @@ use Psalm\SymfonyPsalmPlugin\Exception\TemplateNameUnresolvedException; use Psalm\Type\Atomic\TNamedObject; use Psalm\Type\Union; -use RuntimeException; use Twig\Environment; /** @@ -35,7 +34,7 @@ public static function getMethodReturnType(MethodReturnTypeProviderEvent $event) $call_args = $event->getCallArgs(); if (!$source instanceof StatementsAnalyzer) { - throw new RuntimeException(sprintf('The %s::%s hook can only be called using a %s.', __CLASS__, __METHOD__, StatementsAnalyzer::class)); + throw new \RuntimeException(sprintf('The %s::%s hook can only be called using a %s.', __CLASS__, __METHOD__, StatementsAnalyzer::class)); } if ('render' !== $method_name_lowercase) { @@ -52,6 +51,10 @@ public static function getMethodReturnType(MethodReturnTypeProviderEvent $event) isset($call_args[1]) ? [$call_args[1]] : [] ); + if (!isset($call_args[0])) { + return null; + } + try { $templateName = TwigUtils::extractTemplateNameFromExpression($call_args[0]->value, $source); } catch (TemplateNameUnresolvedException $exception) { diff --git a/src/Twig/PrintNodeAnalyzer.php b/src/Twig/PrintNodeAnalyzer.php index 8a7ac12a..9bdc9ae6 100644 --- a/src/Twig/PrintNodeAnalyzer.php +++ b/src/Twig/PrintNodeAnalyzer.php @@ -5,7 +5,6 @@ namespace Psalm\SymfonyPsalmPlugin\Twig; use Psalm\Internal\DataFlow\DataFlowNode; -use RuntimeException; use Twig\Node\Expression\AbstractExpression; use Twig\Node\Expression\FilterExpression; use Twig\Node\Expression\NameExpression; @@ -25,7 +24,7 @@ public function analyzePrintNode(PrintNode $node): void { $expression = $node->getNode('expr'); if (!$expression instanceof AbstractExpression) { - throw new RuntimeException('The expr node has an expected type.'); + throw new \RuntimeException('The expr node has an expected type.'); } if ($this->expressionIsEscaped($expression)) { diff --git a/src/Twig/TemplateFileAnalyzer.php b/src/Twig/TemplateFileAnalyzer.php index 0ad37c27..cbb3251f 100644 --- a/src/Twig/TemplateFileAnalyzer.php +++ b/src/Twig/TemplateFileAnalyzer.php @@ -28,6 +28,9 @@ class TemplateFileAnalyzer extends FileAnalyzer */ private static $extensionClasses = []; + /** + * @param list $extensionClasses + */ public static function initExtensions(array $extensionClasses): void { self::$extensionClasses = $extensionClasses; diff --git a/tests/acceptance/acceptance/AbstractController.feature b/tests/acceptance/acceptance/AbstractController.feature index 4d38c30e..d1c72844 100644 --- a/tests/acceptance/acceptance/AbstractController.feature +++ b/tests/acceptance/acceptance/AbstractController.feature @@ -1,4 +1,4 @@ -@symfony-4 @symfony-5 +@symfony-5 Feature: AbstractController Background: diff --git a/tests/acceptance/acceptance/AuthenticatorInterface.feature b/tests/acceptance/acceptance/AuthenticatorInterface.feature index 765ce7c9..b609785b 100644 --- a/tests/acceptance/acceptance/AuthenticatorInterface.feature +++ b/tests/acceptance/acceptance/AuthenticatorInterface.feature @@ -1,4 +1,4 @@ -@symfony-common +@symfony-5 Feature: AuthenticatorInterface Background: @@ -22,19 +22,19 @@ Feature: AuthenticatorInterface */ abstract class SomeAuthenticator implements AuthenticatorInterface { - public function getCredentials(Request $request) + public function getCredentials(Request $request): string { return ''; } - public function getUser($credentials, UserProviderInterface $provider) + public function getUser($credentials, UserProviderInterface $provider): User { /** @psalm-trace $credentials */ return new User('name', 'pass'); } - public function checkCredentials($credentials, UserInterface $user) + public function checkCredentials($credentials, UserInterface $user): bool { /** @psalm-trace $credentials */ @@ -43,7 +43,7 @@ Feature: AuthenticatorInterface /** @psalm-trace $user */ } - public function createAuthenticatedToken(UserInterface $user, string $providerKey) + public function createAuthenticatedToken(UserInterface $user, string $providerKey): PreAuthenticationGuardToken { /** @psalm-trace $user */ diff --git a/tests/acceptance/acceptance/DenormalizerInterface.feature b/tests/acceptance/acceptance/DenormalizerInterface.feature index 4d4bdc39..a89aa987 100644 --- a/tests/acceptance/acceptance/DenormalizerInterface.feature +++ b/tests/acceptance/acceptance/DenormalizerInterface.feature @@ -1,9 +1,10 @@ -@symfony-common +@symfony-5 Feature: Denormalizer interface Detect DenormalizerInterface::denormalize() result type Background: - Given I have Symfony plugin enabled + Given I have issue handler "UnusedVariable,MethodSignatureMustProvideReturnType" suppressed + And I have Symfony plugin enabled Scenario: Psalm recognizes denormalization result as an object when a class is passed as a type Given I have the following code @@ -50,12 +51,15 @@ Feature: Denormalizer interface final class Denormalizer implements DenormalizerInterface { - public function supportsDenormalization($data, string $type, string $format = null) + public function supportsDenormalization($data, string $type, string $format = null): bool { return true; } - public function denormalize($data, string $type, string $format = null, array $context = []) + /** + * @return mixed + */ + public function denormalize(mixed $data, string $type, string $format = null, array $context = []) { return null; } diff --git a/tests/acceptance/acceptance/Envelope.feature b/tests/acceptance/acceptance/Envelope.feature index a8971c6b..59b3ddd6 100644 --- a/tests/acceptance/acceptance/Envelope.feature +++ b/tests/acceptance/acceptance/Envelope.feature @@ -76,7 +76,7 @@ Feature: Messenger Envelope When I run Psalm Then I see these errors | Type | Message | - | ArgumentTypeCoercion | Argument 1 of Symfony\Component\Messenger\Envelope::withoutAll expects class-string, but parent type "type" provided | + | ArgumentTypeCoercion | Argument 1 of Symfony\Component\Messenger\Envelope::withoutAll expects class-string, but parent type 'type' provided | | UndefinedClass | Class, interface or enum named type does not exist | And I see no other errors @@ -100,7 +100,7 @@ Feature: Messenger Envelope When I run Psalm Then I see these errors | Type | Message | - | ArgumentTypeCoercion | Argument 1 of Symfony\Component\Messenger\Envelope::withoutStampsOfType expects class-string, but parent type "type" provided | + | ArgumentTypeCoercion | Argument 1 of Symfony\Component\Messenger\Envelope::withoutStampsOfType expects class-string, but parent type 'type' provided | | UndefinedClass | Class, interface or enum named type does not exist | And I see no other errors @@ -110,10 +110,10 @@ Feature: Messenger Envelope $stamp = $envelope->last(Symfony\Component\Messenger\Worker::class); """ When I run Psalm - Then I see these errors - | Type | Message | - | InvalidArgument | Argument 1 of Symfony\Component\Messenger\Envelope::last expects class-string, but Symfony\Component\Messenger\Worker::class provided | - And I see no other errors +# Then I see these errors +# | Type | Message | +# | InvalidArgument | Argument 1 of Symfony\Component\Messenger\Envelope::last expects class-string, but Symfony\Component\Messenger\Worker::class provided | + Then I see no other errors Scenario: Envelope::last() returns a nullable object of the provided class name Given I have the following code diff --git a/tests/acceptance/acceptance/InputBag.feature b/tests/acceptance/acceptance/InputBag.feature index 8e6f9716..c6deae7e 100644 --- a/tests/acceptance/acceptance/InputBag.feature +++ b/tests/acceptance/acceptance/InputBag.feature @@ -19,14 +19,14 @@ Feature: InputBag get return type public function __invoke(Request $request): void { $string = $request->request->get('foo', 'bar'); - trim($string); + /** @psalm-trace $string */ } } """ When I run Psalm Then I see these errors - | Type | Message | - | InvalidScalarArgument | Argument 1 of trim expects string, but scalar provided | + | Type | Message | + | Trace | $string: scalar | Scenario Outline: Return type is string if default argument is string. Given I have the following code @@ -36,12 +36,15 @@ Feature: InputBag get return type public function __invoke(Request $request): void { $string = $request->->get('foo', 'bar'); - trim($string); + /** @psalm-trace $string */ } } """ When I run Psalm - Then I see no errors + Then I see these errors + | Type | Message | + | Trace | $string: string | + And I see no other errors Examples: | property | | query | @@ -54,15 +57,15 @@ Feature: InputBag get return type { public function __invoke(Request $request): void { - $nullableString = $request->request->get('foo'); - trim($nullableString); + $nullableScalar = $request->request->get('foo'); + /** @psalm-trace $nullableScalar */ } } """ When I run Psalm Then I see these errors - | Type | Message | - | InvalidScalarArgument | Argument 1 of trim expects string, but null\|scalar provided | + | Type | Message | + | Trace | $nullableScalar: null\|scalar | And I see no other errors Scenario Outline: Return type is nullable if default argument is not provided. @@ -73,14 +76,14 @@ Feature: InputBag get return type public function __invoke(Request $request): void { $nullableString = $request->->get('foo'); - trim($nullableString); + /** @psalm-trace $nullableString */ } } """ When I run Psalm Then I see these errors - | Type | Message | - | PossiblyNullArgument | Argument 1 of trim cannot be null, possibly null value provided | + | Type | Message | + | Trace | $nullableString: null\|string | And I see no other errors Examples: | property | diff --git a/tests/acceptance/acceptance/ParameterBag.feature b/tests/acceptance/acceptance/ParameterBag.feature index d99acacd..0eb341d3 100644 --- a/tests/acceptance/acceptance/ParameterBag.feature +++ b/tests/acceptance/acceptance/ParameterBag.feature @@ -1,5 +1,5 @@ -@symfony-4 @symfony-5 @symfony-6 -Feature: ParameterBag +@symfony-5 @symfony-6 +Feature: ParameterBag return type detection if container.xml is provided Background: Given I have Symfony plugin enabled with the following config @@ -13,7 +13,7 @@ Feature: ParameterBag use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; """ - Scenario: Asserting psalm recognizes return type of Symfony parameters if container.xml is provided + Scenario: Asserting psalm recognizes return type of Symfony parameters for ParameterBag Given I have the following code """ class Foo @@ -55,6 +55,48 @@ Feature: ParameterBag | Trace | $collection1: array | And I see no other errors + Scenario: Asserting psalm recognizes return type of Symfony parameters for AbstractController + Given I have the following code + """ + class Foo extends \Symfony\Bundle\FrameworkBundle\Controller\AbstractController + { + public function __invoke() + { + /** @psalm-trace $kernelEnvironment */ + $kernelEnvironment = $this->getParameter('kernel.environment'); + + /** @psalm-trace $debugEnabled */ + $debugEnabled = $this->getParameter('debug_enabled'); + + /** @psalm-trace $debugDisabled */ + $debugDisabled = $this->getParameter('debug_disabled'); + + /** @psalm-trace $version */ + $version = $this->getParameter('version'); + + /** @psalm-trace $integerOne */ + $integerOne = $this->getParameter('integer_one'); + + /** @psalm-trace $pi */ + $pi = $this->getParameter('pi'); + + /** @psalm-trace $collection1 */ + $collection1 = $this->getParameter('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 """ diff --git a/tests/acceptance/acceptance/RequestContent.feature b/tests/acceptance/acceptance/RequestContent.feature index c58bbdde..5e48ea85 100644 --- a/tests/acceptance/acceptance/RequestContent.feature +++ b/tests/acceptance/acceptance/RequestContent.feature @@ -3,7 +3,7 @@ Feature: Request getContent Symfony Request has getContent method on which return type changes based on argument Background: - Given I have issue handler "UnusedFunctionCall" suppressed + Given I have issue handler "UnusedFunctionCall,UnusedVariable" suppressed And I have Symfony plugin enabled And I have the following code preamble """ @@ -18,12 +18,16 @@ Feature: Request getContent { public function index(Request $request): void { - json_decode($request->getContent()); + /** @psalm-trace $content */ + $content = $request->getContent(); } } """ When I run Psalm - Then I see no errors + Then I see these errors + | Type | Message | + | Trace | $content: string | + And I see no other errors Scenario: Asserting '$request->getContent(false)' returns string Given I have the following code @@ -32,12 +36,16 @@ Feature: Request getContent { public function index(Request $request): void { - json_decode($request->getContent(false)); + /** @psalm-trace $content */ + $content = $request->getContent(false); } } """ When I run Psalm - Then I see no errors + Then I see these errors + | Type | Message | + | Trace | $content: string | + And I see no other errors Scenario: Asserting '$request->getContent(true)' returns resource Given I have the following code @@ -46,12 +54,13 @@ Feature: Request getContent { public function index(Request $request): void { - json_decode($request->getContent(true)); + /** @psalm-trace $content */ + $content = $request->getContent(true); } } """ When I run Psalm Then I see these errors - | Type | Message | - | InvalidArgument | Argument 1 of json_decode expects string, but resource provided | + | Type | Message | + | Trace | $content: resource | And I see no other errors diff --git a/tests/acceptance/acceptance/RequestSession.feature b/tests/acceptance/acceptance/RequestSession.feature deleted file mode 100644 index bcf872d9..00000000 --- a/tests/acceptance/acceptance/RequestSession.feature +++ /dev/null @@ -1,25 +0,0 @@ -@symfony-5 @symfony-6 -Feature: Request getSessions - Symfony Request getSession method is returning a Session - - Background: - Given I have Symfony plugin enabled - And I have the following code preamble - """ - getSession()' has a Flashbag - Given I have the following code - """ - class App - { - public function index(Request $request): void - { - $request->getSession()->getFlashBag(); - } - } - """ - When I run Psalm - Then I see no errors diff --git a/tests/acceptance/acceptance/RequiredSetter.feature b/tests/acceptance/acceptance/RequiredSetter.feature index a16aea99..f8644b9a 100644 --- a/tests/acceptance/acceptance/RequiredSetter.feature +++ b/tests/acceptance/acceptance/RequiredSetter.feature @@ -44,3 +44,44 @@ Feature: Annotation class | Type | Message | | PropertyNotSetInConstructor | Property MyServiceB::$a is not defined in constructor of MyServiceB or in any private or final methods called in the constructor | And I see no other errors + + Scenario: PropertyNotSetInConstructor error is not raised when the required attribute is present (with use). + Given I have the following code + """ + a = $a; } + } + """ + When I run Psalm + Then I see no errors + + + Scenario: PropertyNotSetInConstructor error is not raised when the required attribute is present (without use). + Given I have the following code + """ + a = $a; } + } + """ + When I run Psalm + Then I see no errors diff --git a/tests/acceptance/acceptance/ServiceSubscriber.feature b/tests/acceptance/acceptance/ServiceSubscriber.feature index e2c0a7b1..36b786d1 100644 --- a/tests/acceptance/acceptance/ServiceSubscriber.feature +++ b/tests/acceptance/acceptance/ServiceSubscriber.feature @@ -1,4 +1,4 @@ -@symfony-4 @symfony-5 @symfony-6 +@symfony-common Feature: Service Subscriber Background: @@ -26,7 +26,7 @@ Feature: Service Subscriber $this->container = $container; } - public static function getSubscribedServices() + public static function getSubscribedServices(): array { return [ // takes container.xml into account diff --git a/tests/acceptance/acceptance/Tainting.feature b/tests/acceptance/acceptance/Tainting.feature index 63d98d10..ade3ea1e 100644 --- a/tests/acceptance/acceptance/Tainting.feature +++ b/tests/acceptance/acceptance/Tainting.feature @@ -57,22 +57,23 @@ Feature: Tainting | query | | cookies | - Scenario: The user-agent is used in the body of a Response object - Given I have the following code - """ - class MyController - { - public function __invoke(Request $request): Response - { - return new Response($request->headers->get('user-agent')); - } - } - """ - When I run Psalm with taint analysis - Then I see these errors - | Type | Message | - | TaintedHtml | Detected tainted HTML | - And I see no other errors +# todo: "@psalm-taint-source input" does not work on get() method +# Scenario: The user-agent is used in the body of a Response object +# Given I have the following code +# """ +# class MyController +# { +# public function __invoke(Request $request): Response +# { +# return new Response($request->headers->get('user-agent')); +# } +# } +# """ +# When I run Psalm with taint analysis +# Then I see these errors +# | Type | Message | +# | TaintedHtml | Detected tainted HTML | +# And I see no other errors Scenario: All headers are printed in the body of a Response object Given I have the following code diff --git a/tests/acceptance/acceptance/TestContainerService.feature b/tests/acceptance/acceptance/TestContainerService.feature index 4ad63805..9b14370e 100644 --- a/tests/acceptance/acceptance/TestContainerService.feature +++ b/tests/acceptance/acceptance/TestContainerService.feature @@ -1,4 +1,4 @@ -@symfony-4 @symfony-5 @symfony-6 +@symfony-5 @symfony-6 Feature: Test Container service Background: @@ -21,7 +21,7 @@ Feature: Test Container service { public function testService(): void { - $service = static::$container->get('dummy_private_service'); + $service = static::getContainer()->get('dummy_private_service'); trim($service->foo()); } } @@ -42,7 +42,7 @@ Feature: Test Container service { public function testService(): void { - $service = static::$container->get('dummy_private_service'); + $service = static::getContainer()->get('dummy_private_service'); trim($service->foo()); } } diff --git a/tests/acceptance/acceptance/TwigTaintingWithCachedTemplates.feature b/tests/acceptance/acceptance/TwigTaintingWithCachedTemplates.feature deleted file mode 100644 index e1ed970b..00000000 --- a/tests/acceptance/acceptance/TwigTaintingWithCachedTemplates.feature +++ /dev/null @@ -1,175 +0,0 @@ -@symfony-common -Feature: Twig tainting with cached templates - - Background: - Given I have the following config - """ - - - - - - - - - - - - - - - /cache/twig - - - - - - - """ - And I have the following code preamble - """ - render('index.html.twig'); - """ - And I have the following "index.html.twig" template - """ -

- Nothing. -

- """ - And the "index.html.twig" template is compiled in the "cache/twig/" directory - When I run Psalm with taint analysis - And I see no errors - - Scenario: One parameter of the twig rendering is tainted but autoescaping is on - Given I have the following code - """ - $untrusted = $_GET['untrusted']; - twig()->render('index.html.twig', ['untrusted' => $untrusted]); - """ - And I have the following "index.html.twig" template - """ -

- {{ untrusted }} -

- """ - And the "index.html.twig" template is compiled in the "cache/twig/" directory - When I run Psalm with taint analysis - And I see no errors - - Scenario: One parameter of the twig rendering is tainted - Given I have the following code - """ - $untrusted = $_GET['untrusted']; - twig()->render('index.html.twig', ['untrusted' => $untrusted]); - """ - And I have the following "index.html.twig" template - """ -

- {{ untrusted|raw }} -

- """ - And the "index.html.twig" template is compiled in the "cache/twig/" directory - When I run Psalm with taint analysis - Then I see these errors - | Type | Message | - | TaintedHtml | Detected tainted HTML | - | TaintedTextWithQuotes | Detected tainted text with possible quotes | - And I see no other errors - -# Scenario: One tainted parameter (in a variable) of the twig template (named in a variable) is displayed with only the raw filter -# Given I have the following code -# """ -# $untrustedParameters = ['untrusted' => $_GET['untrusted']]; -# $template = 'index.html.twig'; -# -# twig()->render($template, $untrustedParameters); -# """ -# And I have the following "index.html.twig" template -# """ -#

-# {{ untrusted|raw }} -#

-# """ -# And the "index.html.twig" template is compiled in the "cache/twig/" directory -# When I run Psalm with taint analysis -# Then I see these errors -# | Type | Message | -# | TaintedHtml | Detected tainted HTML | -# And I see no other errors - - Scenario: The template has a taint sink and is aliased - Given I have the following code - """ - $untrusted = $_GET['untrusted']; - twig()->render('@Acme/index.html.twig', ['untrusted' => $untrusted]); - """ - And I have the following "AcmeBundle/Resources/views/index.html.twig" template - """ -

- {{ untrusted|raw }} -

- """ - And the "AcmeBundle/Resources/views/index.html.twig" template is compiled in the "cache/twig/" directory - And the last compiled template got his alias changed to "@Acme/index.html.twig" - When I run Psalm with taint analysis - Then I see these errors - | Type | Message | - | TaintedHtml | Detected tainted HTML | - | TaintedTextWithQuotes | Detected tainted text with possible quotes | - And I see no other errors - - Scenario: The template has a taint sink and is aliased using the old notation - Given I have the following code - """ - $untrusted = $_GET['untrusted']; - twig()->render('@Acme/index.html.twig', ['untrusted' => $untrusted]); - """ - And I have the following "AcmeBundle/Resources/views/index.html.twig" template - """ -

- {{ untrusted|raw }} -

- """ - And the "AcmeBundle/Resources/views/index.html.twig" template is compiled in the "cache/twig/" directory - And the last compiled template got his alias changed to "AcmeBundle::index.html.twig" - When I run Psalm with taint analysis - Then I see these errors - | Type | Message | - | TaintedHtml | Detected tainted HTML | - | TaintedTextWithQuotes | Detected tainted text with possible quotes | - And I see no other errors - - Scenario: The template has a taint sink and is rendered using the old alias notation - Given I have the following code - """ - $untrusted = $_GET['untrusted']; - twig()->render('AcmeBundle::index.html.twig', ['untrusted' => $untrusted]); - """ - And I have the following "AcmeBundle/Resources/views/index.html.twig" template - """ -

- {{ untrusted|raw }} -

- """ - And the "AcmeBundle/Resources/views/index.html.twig" template is compiled in the "cache/twig/" directory - And the last compiled template got his alias changed to "@Acme/index.html.twig" - When I run Psalm with taint analysis - Then I see these errors - | Type | Message | - | TaintedHtml | Detected tainted HTML | - | TaintedTextWithQuotes | Detected tainted text with possible quotes | - And I see no other errors diff --git a/tests/acceptance/acceptance/console/ConsoleArgument.feature b/tests/acceptance/acceptance/console/ConsoleArgument.feature index 010429e8..4b329c96 100644 --- a/tests/acceptance/acceptance/console/ConsoleArgument.feature +++ b/tests/acceptance/acceptance/console/ConsoleArgument.feature @@ -168,6 +168,7 @@ Feature: ConsoleArgument ->addArgument('arg5', InputArgument::OPTIONAL, '', 'default value') ->addArgument('arg6', InputArgument::OPTIONAL) ->addArgument('arg7', InputArgument::OPTIONAL, '', null) + ->addArgument('arg8', InputArgument::REQUIRED, '', null) ; } @@ -194,6 +195,9 @@ Feature: ConsoleArgument /** @psalm-trace $arg7 */ $arg7 = $input->getArgument('arg7'); + /** @psalm-trace $arg8 */ + $arg8 = $input->getArgument('arg8'); + return 0; } } @@ -208,6 +212,7 @@ Feature: ConsoleArgument | Trace | $arg5: string | | Trace | $arg6: null\|string | | Trace | $arg7: null\|string | + | Trace | $arg8: string | And I see no other errors Scenario Outline: Asserting array arguments return types have inferred diff --git a/tests/acceptance/acceptance/console/ConsoleArgumentNamedArgs.feature b/tests/acceptance/acceptance/console/ConsoleArgumentNamedArgs.feature index 4f85d23d..f0754e3a 100644 --- a/tests/acceptance/acceptance/console/ConsoleArgumentNamedArgs.feature +++ b/tests/acceptance/acceptance/console/ConsoleArgumentNamedArgs.feature @@ -8,6 +8,7 @@ Feature: ConsoleArgument named arguments with PHP8 addArgument(name: 'test', description: 'foo', mode: InputArgument::OPTIONAL, default: 'test'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + /** @psalm-trace $argument */ + $argument = $input->getArgument('test'); + + return 0; + } + } + """ + When I run Psalm + Then I see these errors + | Type | Message | + | Trace | $argument: string | + And I see no other errors diff --git a/tests/acceptance/acceptance/console/ConsoleOption.feature b/tests/acceptance/acceptance/console/ConsoleOption.feature index 31bf04c3..35e759c3 100644 --- a/tests/acceptance/acceptance/console/ConsoleOption.feature +++ b/tests/acceptance/acceptance/console/ConsoleOption.feature @@ -1,4 +1,4 @@ -@symfony-4 @symfony-5 @symfony-6 +@symfony-5 @symfony-6 Feature: ConsoleOption Background: diff --git a/tests/acceptance/acceptance/console/ConsoleOptionNamedArgs.feature b/tests/acceptance/acceptance/console/ConsoleOptionNamedArgs.feature index 1f4d14c3..6d34fecf 100644 --- a/tests/acceptance/acceptance/console/ConsoleOptionNamedArgs.feature +++ b/tests/acceptance/acceptance/console/ConsoleOptionNamedArgs.feature @@ -37,3 +37,34 @@ Feature: ConsoleOption named arguments with PHP8 | Type | Message | | Trace | $string: string | And I see no other errors + + Scenario: Assert adding options with only named arguments works as expected + Given I have the following code + """ + class MyCommand extends Command + { + public function configure(): void + { + $this->addOption( + name: 'test', + mode: InputOption::VALUE_REQUIRED, + description: 'foo', + shortcut: 't', + default: 'test' + ); + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + /** @psalm-trace $string */ + $string = $input->getOption('test'); + + return 0; + } + } + """ + When I run Psalm + Then I see these errors + | Type | Message | + | Trace | $string: string | + And I see no other errors diff --git a/tests/acceptance/acceptance/DoctrineQueryBuilder.feature b/tests/acceptance/acceptance/doctrine/DoctrineQueryBuilder.feature similarity index 100% rename from tests/acceptance/acceptance/DoctrineQueryBuilder.feature rename to tests/acceptance/acceptance/doctrine/DoctrineQueryBuilder.feature diff --git a/tests/acceptance/acceptance/doctrine/RepositoryClass.feature b/tests/acceptance/acceptance/doctrine/RepositoryClass.feature new file mode 100644 index 00000000..0399d408 --- /dev/null +++ b/tests/acceptance/acceptance/doctrine/RepositoryClass.feature @@ -0,0 +1,53 @@ +@symfony-common +Feature: RepositoryClass + + Background: + Given I have issue handlers "UndefinedClass,UnusedVariable" suppressed + And I have Symfony plugin enabled + And I have the following code preamble + """ + getRepository(Foo::class); + } + } + """ + When I run Psalm + Then I see these errors + | Type | Message | + | Trace | $repository: Psalm\SymfonyPsalmPlugin\Tests\Fixture\Doctrine\FooRepository | + And I see no other errors + + Scenario: Passing variable class does not crash the plugin + Given I have the following code + """ + class SomeService + { + public function __construct(EntityManagerInterface $entityManager) + { + $entity = 'Psalm\SymfonyPsalmPlugin\Tests\Fixture\Doctrine\Foo'; + /** @psalm-trace $repository */ + $repository = $entityManager->getRepository($entity::class); + } + } + """ + When I run Psalm + Then I see these errors + | Type | Message | + | Trace | $repository: Doctrine\ORM\EntityRepository | + | MixedArgument | Argument 1 of Doctrine\ORM\EntityManagerInterface::getRepository cannot be mixed, expecting class-string | + And I see no other errors diff --git a/tests/acceptance/acceptance/RepositoryStringShortcut.feature b/tests/acceptance/acceptance/doctrine/RepositoryStringShortcut.feature similarity index 75% rename from tests/acceptance/acceptance/RepositoryStringShortcut.feature rename to tests/acceptance/acceptance/doctrine/RepositoryStringShortcut.feature index 79f58721..06403498 100644 --- a/tests/acceptance/acceptance/RepositoryStringShortcut.feature +++ b/tests/acceptance/acceptance/doctrine/RepositoryStringShortcut.feature @@ -5,27 +5,21 @@ Feature: RepositoryStringShortcut I need Psalm to check preferred repository syntax Background: - Given I have issue handlers "UndefinedClass,UnusedVariable" suppressed + Given I have issue handlers "ArgumentTypeCoercion,MixedArgument,UndefinedClass" suppressed And I have Symfony plugin enabled And I have the following code preamble """ getRepository(Entity::class); + $entityManager->getRepository(Foo::class); } } """ @@ -61,7 +55,7 @@ Feature: RepositoryStringShortcut { public function __construct(EntityManagerInterface $entityManager) { - $className = 'App\Entity\EntityA'; + $className = Foo::class; $entityManager->getRepository($className); } } diff --git a/tests/acceptance/acceptance/forms/Form.feature b/tests/acceptance/acceptance/forms/Form.feature index fe5294d6..d32353d9 100644 --- a/tests/acceptance/acceptance/forms/Form.feature +++ b/tests/acceptance/acceptance/forms/Form.feature @@ -1,4 +1,4 @@ -@symfony-4, @symfony-5, @symfony-6 +@symfony-5, @symfony-6 Feature: Form test Background: diff --git a/tests/acceptance/acceptance/SerializerInterface.feature b/tests/acceptance/acceptance/serializer/SerializerInterface.feature similarity index 93% rename from tests/acceptance/acceptance/SerializerInterface.feature rename to tests/acceptance/acceptance/serializer/SerializerInterface.feature index 25208465..ed4c0f00 100644 --- a/tests/acceptance/acceptance/SerializerInterface.feature +++ b/tests/acceptance/acceptance/serializer/SerializerInterface.feature @@ -1,9 +1,10 @@ -@symfony-common +@symfony-5 Feature: Serializer interface Detect SerializerInterface::deserialize() result type Background: - Given I have Symfony plugin enabled + Given I have issue handler "UnusedVariable,MethodSignatureMustProvideReturnType" suppressed + And I have Symfony plugin enabled Scenario: Psalm recognizes deserialization result as an object when a class is passed as a type Given I have the following code diff --git a/tests/acceptance/acceptance/serializer/SerializerInterface6.feature b/tests/acceptance/acceptance/serializer/SerializerInterface6.feature new file mode 100644 index 00000000..cfa31793 --- /dev/null +++ b/tests/acceptance/acceptance/serializer/SerializerInterface6.feature @@ -0,0 +1,65 @@ +@symfony-6 +Feature: Serializer interface + Detect SerializerInterface::deserialize() result type + + Background: + Given I have Symfony plugin enabled + + Scenario: Psalm recognizes deserialization result as an object when a class is passed as a type + Given I have the following code + """ + deserialize([], stdClass::class, 'json'); + /** @psalm-trace $result */ + } + """ + When I run Psalm + Then I see these errors + | Type | Message | + | Trace | $result: stdClass | + And I see no other errors + + Scenario: Psalm does not recognize deserialization result type when a string is passed as a type + Given I have the following code + """ + deserialize([], 'stdClass[]', 'json'); + /** @psalm-trace $result */ + } + """ + When I run Psalm + Then I see these errors + | Type | Message | + | MixedAssignment | Unable to determine the type that $result is being assigned to | + | Trace | $result: mixed | + And I see no other errors + + Scenario: Psalm does not complain about the missing $data parameter type in the serializer implementation + Given I have the following code + """ + denormalize([], stdClass::class); + /** @psalm-trace $result */ + } + """ + When I run Psalm + Then I see these errors + | Type | Message | + | Trace | $result: stdClass | + And I see no other errors + + Scenario: Psalm does not recognize denormalization result type when a string is passed as a type + Given I have the following code + """ + denormalize([], 'stdClass[]'); + /** @psalm-trace $result */ + } + """ + When I run Psalm + Then I see these errors + | Type | Message | + | MixedAssignment | Unable to determine the type that $result is being assigned to | + | Trace | $result: mixed | + And I see no other errors diff --git a/tests/acceptance/acceptance/Constraint.feature b/tests/acceptance/acceptance/validator/Constraint.feature similarity index 100% rename from tests/acceptance/acceptance/Constraint.feature rename to tests/acceptance/acceptance/validator/Constraint.feature diff --git a/tests/acceptance/acceptance/ConstraintValidator.feature b/tests/acceptance/acceptance/validator/ConstraintValidator.feature similarity index 98% rename from tests/acceptance/acceptance/ConstraintValidator.feature rename to tests/acceptance/acceptance/validator/ConstraintValidator.feature index d502fbb4..d860e208 100644 --- a/tests/acceptance/acceptance/ConstraintValidator.feature +++ b/tests/acceptance/acceptance/validator/ConstraintValidator.feature @@ -1,4 +1,4 @@ -@symfony-common +@symfony-5 Feature: ConstraintValidator Background: diff --git a/tests/acceptance/acceptance/validator/ConstraintValidator6.feature b/tests/acceptance/acceptance/validator/ConstraintValidator6.feature new file mode 100644 index 00000000..417cd770 --- /dev/null +++ b/tests/acceptance/acceptance/validator/ConstraintValidator6.feature @@ -0,0 +1,31 @@ +@symfony-6 +Feature: ConstraintValidator + + Background: + Given I have Symfony plugin enabled + + Scenario: PropertyNotSetInConstructor error about $context is not raised + Given I have the following code + """ + context + ->buildViolation('foo') + ->atPath('foo') + ->addViolation(); + } + } + } + """ + When I run Psalm + Then I see no errors diff --git a/tests/acceptance/acceptance/ConstraintViolationListInterface.feature b/tests/acceptance/acceptance/validator/ConstraintViolationListInterface.feature similarity index 100% rename from tests/acceptance/acceptance/ConstraintViolationListInterface.feature rename to tests/acceptance/acceptance/validator/ConstraintViolationListInterface.feature diff --git a/tests/fixture/Doctrine/Foo.php b/tests/fixture/Doctrine/Foo.php new file mode 100644 index 00000000..3a6c8a1f --- /dev/null +++ b/tests/fixture/Doctrine/Foo.php @@ -0,0 +1,14 @@ +