diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 713670a68cf..6d7b9b96df0 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -1480,6 +1480,34 @@ jobs: cd e2e/composer-no-versions composer install ../../bin/phpstan analyze test.php --level=0 + - script: | + # Scope::getPhpVersion() has to honour the composer.json "require.php" range: + # https://github.com/phpstan/phpstan/issues/15270 + cd e2e/composer-php-version-range-7-and-8 + composer install + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse --no-progress --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains '/errors.php:11:Non-capturing catch is supported only on PHP 8.0 and later.' "$OUTPUT" + ../bashunit -a contains '/errors.php:23:Named arguments are supported only on PHP 8.0 and later.' "$OUTPUT" + ../bashunit -a contains '/errors.php:29:Function ComposerPhpVersionRange7And8Errors\phpdocBoolReturn() never returns false so the return type can be changed to true.' "$OUTPUT" + ../bashunit -a not_contains 'no-errors.php' "$OUTPUT" + ../bashunit -a not_contains 'php-version-id.php' "$OUTPUT" + - script: | + cd e2e/composer-php-version-range-7 + composer install --ignore-platform-reqs + ../../bin/phpstan analyse --no-progress + - script: | + cd e2e/composer-php-version-range-8-0 + composer install + OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse --no-progress --error-format raw") + echo "$OUTPUT" + ../bashunit -a contains '/errors.php:16:Missing parameter $j (int) in call to function ComposerPhpVersionRange80Errors\acceptsThreeInts.' "$OUTPUT" + ../bashunit -a contains '/errors.php:16:Unpacked argument (...) cannot be followed by a non-unpacked argument.' "$OUTPUT" + ../bashunit -a not_contains 'no-errors.php' "$OUTPUT" + - script: | + cd e2e/composer-php-version-range-8-5 + composer install --ignore-platform-reqs + ../../bin/phpstan analyse --no-progress - script: | cd e2e/composer-version-config-invalid OUTPUT=$(../bashunit -a exit_code "1" ../../bin/phpstan) diff --git a/e2e/composer-php-version-range-7-and-8/.gitignore b/e2e/composer-php-version-range-7-and-8/.gitignore new file mode 100644 index 00000000000..3a9875b460f --- /dev/null +++ b/e2e/composer-php-version-range-7-and-8/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/e2e/composer-php-version-range-7-and-8/composer.json b/e2e/composer-php-version-range-7-and-8/composer.json new file mode 100644 index 00000000000..064ac1a853a --- /dev/null +++ b/e2e/composer-php-version-range-7-and-8/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "php": "^7.4 || ^8.0" + } +} diff --git a/e2e/composer-php-version-range-7-and-8/errors.php b/e2e/composer-php-version-range-7-and-8/errors.php new file mode 100644 index 00000000000..331c863433e --- /dev/null +++ b/e2e/composer-php-version-range-7-and-8/errors.php @@ -0,0 +1,40 @@ +', PHP_VERSION_ID); + +if (PHP_VERSION_ID >= 80000) { + // an explicit PHP_VERSION_ID check still narrows the range further down + assertType('int<80000, 80699>', PHP_VERSION_ID); + + try { + throw new RuntimeException('foo'); + } catch (RuntimeException) { + // no "Non-capturing catch is supported only on PHP 8.0 and later." + echo 'failed'; + } +} diff --git a/e2e/composer-php-version-range-7-and-8/phpstan.neon b/e2e/composer-php-version-range-7-and-8/phpstan.neon new file mode 100644 index 00000000000..11580d314f1 --- /dev/null +++ b/e2e/composer-php-version-range-7-and-8/phpstan.neon @@ -0,0 +1,9 @@ +includes: + - ../../conf/bleedingEdge.neon + +parameters: + level: 8 + paths: + - errors.php + - no-errors.php + - php-version-id.php diff --git a/e2e/composer-php-version-range-7/.gitignore b/e2e/composer-php-version-range-7/.gitignore new file mode 100644 index 00000000000..3a9875b460f --- /dev/null +++ b/e2e/composer-php-version-range-7/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/e2e/composer-php-version-range-7/composer.json b/e2e/composer-php-version-range-7/composer.json new file mode 100644 index 00000000000..4d55846fedf --- /dev/null +++ b/e2e/composer-php-version-range-7/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "php": "^7.4" + } +} diff --git a/e2e/composer-php-version-range-7/no-errors.php b/e2e/composer-php-version-range-7/no-errors.php new file mode 100644 index 00000000000..1f8a1146da8 --- /dev/null +++ b/e2e/composer-php-version-range-7/no-errors.php @@ -0,0 +1,31 @@ +', PHP_VERSION_ID); + +function variadicParameter(int ...$args): void +{ + // without named arguments a variadic parameter can only be a list + assertType('list', $args); +} + +class Foo +{ + + // no "cannot be final as it is never overridden by other classes" - + // PHP 7 does not warn about final private methods + final private function finalPrivateMethod(): void + { + } + + public function doFoo(): void + { + $this->finalPrivateMethod(); + } + +} diff --git a/e2e/composer-php-version-range-7/phpstan.neon b/e2e/composer-php-version-range-7/phpstan.neon new file mode 100644 index 00000000000..26573309c37 --- /dev/null +++ b/e2e/composer-php-version-range-7/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: 8 + paths: + - no-errors.php diff --git a/e2e/composer-php-version-range-8-0/.gitignore b/e2e/composer-php-version-range-8-0/.gitignore new file mode 100644 index 00000000000..3a9875b460f --- /dev/null +++ b/e2e/composer-php-version-range-8-0/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/e2e/composer-php-version-range-8-0/composer.json b/e2e/composer-php-version-range-8-0/composer.json new file mode 100644 index 00000000000..2da0adaf1cc --- /dev/null +++ b/e2e/composer-php-version-range-8-0/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "php": "^8.0" + } +} diff --git a/e2e/composer-php-version-range-8-0/errors.php b/e2e/composer-php-version-range-8-0/errors.php new file mode 100644 index 00000000000..d155f481778 --- /dev/null +++ b/e2e/composer-php-version-range-8-0/errors.php @@ -0,0 +1,17 @@ + $args + */ +function namedArgumentAfterUnpackedArgument(array $args): void +{ + // a named argument after an unpacked one is only allowed since PHP 8.1 + acceptsThreeInts(...$args, k: 3); +} diff --git a/e2e/composer-php-version-range-8-0/no-errors.php b/e2e/composer-php-version-range-8-0/no-errors.php new file mode 100644 index 00000000000..80ba02f080e --- /dev/null +++ b/e2e/composer-php-version-range-8-0/no-errors.php @@ -0,0 +1,14 @@ +', PHP_VERSION_ID); + +function variadicParameter(int ...$args): void +{ + // named arguments exist since PHP 8.0, so a variadic parameter is not always a list + assertType('array|string, int>', $args); +} diff --git a/e2e/composer-php-version-range-8-0/phpstan.neon b/e2e/composer-php-version-range-8-0/phpstan.neon new file mode 100644 index 00000000000..eb566ab954d --- /dev/null +++ b/e2e/composer-php-version-range-8-0/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 8 + paths: + - errors.php + - no-errors.php diff --git a/e2e/composer-php-version-range-8-5/.gitignore b/e2e/composer-php-version-range-8-5/.gitignore new file mode 100644 index 00000000000..3a9875b460f --- /dev/null +++ b/e2e/composer-php-version-range-8-5/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/e2e/composer-php-version-range-8-5/composer.json b/e2e/composer-php-version-range-8-5/composer.json new file mode 100644 index 00000000000..584ee342937 --- /dev/null +++ b/e2e/composer-php-version-range-8-5/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "php": "^8.5" + } +} diff --git a/e2e/composer-php-version-range-8-5/no-errors.php b/e2e/composer-php-version-range-8-5/no-errors.php new file mode 100644 index 00000000000..58ae5bf2314 --- /dev/null +++ b/e2e/composer-php-version-range-8-5/no-errors.php @@ -0,0 +1,15 @@ +', PHP_VERSION_ID); + +function iniGet(): void +{ + assertType('string', ini_get('memory_limit')); + // the max_memory_limit ini setting only exists since PHP 8.5 + assertType('string', ini_get('max_memory_limit')); +} diff --git a/e2e/composer-php-version-range-8-5/phpstan.neon b/e2e/composer-php-version-range-8-5/phpstan.neon new file mode 100644 index 00000000000..26573309c37 --- /dev/null +++ b/e2e/composer-php-version-range-8-5/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: 8 + paths: + - no-errors.php diff --git a/src/Analyser/DirectInternalScopeFactory.php b/src/Analyser/DirectInternalScopeFactory.php index 66c81a44cf3..602b2ed3718 100644 --- a/src/Analyser/DirectInternalScopeFactory.php +++ b/src/Analyser/DirectInternalScopeFactory.php @@ -9,6 +9,7 @@ use PHPStan\DependencyInjection\ExtensionsCollection; use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Parser\Parser; +use PHPStan\Php\ConfiguredPhpVersionRangeHelper; use PHPStan\Php\PhpVersion; use PHPStan\Reflection\AttributeReflectionFactory; use PHPStan\Reflection\InitializerExprTypeResolver; @@ -24,7 +25,6 @@ final class DirectInternalScopeFactory implements InternalScopeFactory private ExpressionResultStorageStack $expressionResultStorageStack; /** - * @param int|array{min: int, max: int}|null $configPhpVersion * @param callable(Node $node, Scope $scope): void|null $nodeCallback * @param ExtensionsCollection $expressionTypeResolverExtensions */ @@ -39,7 +39,7 @@ public function __construct( private Parser $parser, private PhpVersion $phpVersion, private AttributeReflectionFactory $attributeReflectionFactory, - private int|array|null $configPhpVersion, + private ConfiguredPhpVersionRangeHelper $configuredPhpVersionRangeHelper, private $nodeCallback, private ConstantResolver $constantResolver, private bool $createsNodeCallbackScopes = false, @@ -90,7 +90,7 @@ public function create( $context, $this->phpVersion, $this->attributeReflectionFactory, - $this->configPhpVersion, + $this->configuredPhpVersionRangeHelper, $this->nodeCallback, $declareStrictTypes, $function, @@ -135,7 +135,7 @@ private function withFlavor(bool $createsNodeCallbackScopes): self $this->parser, $this->phpVersion, $this->attributeReflectionFactory, - $this->configPhpVersion, + $this->configuredPhpVersionRangeHelper, $this->nodeCallback, $this->constantResolver, $createsNodeCallbackScopes, diff --git a/src/Analyser/DirectInternalScopeFactoryFactory.php b/src/Analyser/DirectInternalScopeFactoryFactory.php index a9e4310a20c..4e778139644 100644 --- a/src/Analyser/DirectInternalScopeFactoryFactory.php +++ b/src/Analyser/DirectInternalScopeFactoryFactory.php @@ -7,6 +7,7 @@ use PHPStan\DependencyInjection\ExtensionsCollection; use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Parser\Parser; +use PHPStan\Php\ConfiguredPhpVersionRangeHelper; use PHPStan\Php\PhpVersion; use PHPStan\Reflection\AttributeReflectionFactory; use PHPStan\Reflection\InitializerExprTypeResolver; @@ -18,7 +19,6 @@ final class DirectInternalScopeFactoryFactory implements InternalScopeFactoryFac { /** - * @param int|array{min: int, max: int}|null $configPhpVersion * @param ExtensionsCollection $expressionTypeResolverExtensions */ public function __construct( @@ -32,7 +32,7 @@ public function __construct( private Parser $parser, private PhpVersion $phpVersion, private AttributeReflectionFactory $attributeReflectionFactory, - private int|array|null $configPhpVersion, + private ConfiguredPhpVersionRangeHelper $configuredPhpVersionRangeHelper, private ConstantResolver $constantResolver, ) { @@ -54,7 +54,7 @@ public function create(?callable $nodeCallback): DirectInternalScopeFactory $this->parser, $this->phpVersion, $this->attributeReflectionFactory, - $this->configPhpVersion, + $this->configuredPhpVersionRangeHelper, $nodeCallback, $this->constantResolver, ); diff --git a/src/Analyser/LazyInternalScopeFactory.php b/src/Analyser/LazyInternalScopeFactory.php index aa627a186f1..50e475d0286 100644 --- a/src/Analyser/LazyInternalScopeFactory.php +++ b/src/Analyser/LazyInternalScopeFactory.php @@ -10,6 +10,7 @@ use PHPStan\DependencyInjection\GenerateFactory; use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Parser\Parser; +use PHPStan\Php\ConfiguredPhpVersionRangeHelper; use PHPStan\Php\PhpVersion; use PHPStan\Reflection\AttributeReflectionFactory; use PHPStan\Reflection\InitializerExprTypeResolver; @@ -24,9 +25,6 @@ final class LazyInternalScopeFactory implements InternalScopeFactory { - /** @var int|array{min: int, max: int}|null */ - private int|array|null $phpVersion; - private Parser $currentSimpleVersionParser; private ?ReflectionProvider $reflectionProvider = null; @@ -50,6 +48,8 @@ final class LazyInternalScopeFactory implements InternalScopeFactory private ?AttributeReflectionFactory $attributeReflectionFactory = null; + private ?ConfiguredPhpVersionRangeHelper $configuredPhpVersionRangeHelper = null; + private ?self $twin = null; /** @var WeakReference|null */ @@ -65,7 +65,6 @@ public function __construct( ?ExpressionResultStorageStack $expressionResultStorageStack = null, ) { - $this->phpVersion = $this->container->getParameter('phpVersion'); $this->currentSimpleVersionParser = $this->container->getService('currentPhpVersionSimpleParser'); $this->expressionResultStorageStack = $expressionResultStorageStack ?? new ExpressionResultStorageStack(); } @@ -107,6 +106,7 @@ public function create( $this->phpVersionType ??= $this->container->getByType(PhpVersion::class); $this->attributeReflectionFactory ??= $this->container->getByType(AttributeReflectionFactory::class); + $this->configuredPhpVersionRangeHelper ??= $this->container->getByType(ConfiguredPhpVersionRangeHelper::class); return new $className( $this->container, @@ -123,7 +123,7 @@ public function create( $context, $this->phpVersionType, $this->attributeReflectionFactory, - $this->phpVersion, + $this->configuredPhpVersionRangeHelper, $this->nodeCallback, $declareStrictTypes, $function, diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index a169dcef56c..fbfe11e4ab3 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -45,6 +45,7 @@ use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Node\VirtualNode; use PHPStan\Parser\Parser; +use PHPStan\Php\ConfiguredPhpVersionRangeHelper; use PHPStan\Php\PhpVersion; use PHPStan\Php\PhpVersionFactory; use PHPStan\Php\PhpVersions; @@ -130,7 +131,6 @@ use function get_class; use function implode; use function in_array; -use function is_array; use function is_string; use function ltrim; use function md5; @@ -170,7 +170,6 @@ class MutatingScope implements Scope, NodeCallbackInvoker, CollectedDataEmitter private ?self $scopeWithPromotedNativeTypes = null; /** - * @param int|array{min: int, max: int}|null $configPhpVersion * @param callable(Node $node, Scope $scope): void|null $nodeCallback * @param array $expressionTypes * @param array $conditionalExpressions @@ -196,7 +195,7 @@ public function __construct( protected ScopeContext $context, private PhpVersion $phpVersion, private AttributeReflectionFactory $attributeReflectionFactory, - private int|array|null $configPhpVersion, + private ConfiguredPhpVersionRangeHelper $configuredPhpVersionRangeHelper, private $nodeCallback = null, private bool $declareStrictTypes = false, private PhpFunctionFromParserNodeReflection|null $function = null, @@ -5890,26 +5889,39 @@ public function getIterableValueType(Type $iteratee): Type public function getPhpVersion(): PhpVersions { $constType = $this->getGlobalConstantType(new Name('PHP_VERSION_ID')); + if ($constType !== null && !$this->isOverallPhpVersionRange($constType)) { + return new PhpVersions($constType); + } - $isOverallPhpVersionRange = false; + // The analysed PHP version range comes either from the NEON phpVersion min/max + // config or from the composer.json "require.php" constraint - the very same + // source ConstantResolver narrows PHP_VERSION_ID with, so that + // Scope::getPhpVersion() never contradicts the PHP_VERSION_ID constant. + [$minPhpVersion, $maxPhpVersion] = $this->configuredPhpVersionRangeHelper->getVersionRange(); if ( - $constType instanceof IntegerRangeType - && $constType->getMin() === ConstantResolver::PHP_MIN_ANALYZABLE_VERSION_ID - && ($constType->getMax() === null || $constType->getMax() === PhpVersionFactory::MAX_PHP_VERSION) + $minPhpVersion !== null + || ($maxPhpVersion !== null && $maxPhpVersion->getVersionId() !== PhpVersionFactory::MAX_PHP_VERSION) ) { - $isOverallPhpVersionRange = true; - } - - if ($constType !== null && !$isOverallPhpVersionRange) { - return new PhpVersions($constType); + return new PhpVersions(IntegerRangeType::fromInterval( + $minPhpVersion !== null ? $minPhpVersion->getVersionId() : ConstantResolver::PHP_MIN_ANALYZABLE_VERSION_ID, + $maxPhpVersion !== null ? $maxPhpVersion->getVersionId() : null, + )); } - if (is_array($this->configPhpVersion)) { - return new PhpVersions(IntegerRangeType::fromInterval($this->configPhpVersion['min'], $this->configPhpVersion['max'])); - } return new PhpVersions(new ConstantIntegerType($this->phpVersion->getVersionId())); } + /** + * Whether the type carries no information about the analysed PHP version, + * i.e. it spans everything PHPStan is able to analyse. + */ + private function isOverallPhpVersionRange(Type $type): bool + { + return $type instanceof IntegerRangeType + && $type->getMin() === ConstantResolver::PHP_MIN_ANALYZABLE_VERSION_ID + && ($type->getMax() === null || $type->getMax() === PhpVersionFactory::MAX_PHP_VERSION); + } + public function invokeNodeCallback(Node $node): void { $nodeCallback = $this->nodeCallback; diff --git a/src/Testing/PHPStanTestCase.php b/src/Testing/PHPStanTestCase.php index d06c9e3b41a..3e833d275ec 100644 --- a/src/Testing/PHPStanTestCase.php +++ b/src/Testing/PHPStanTestCase.php @@ -120,7 +120,7 @@ public static function createScopeFactory(ReflectionProvider $reflectionProvider self::getParser(), $container->getByType(PhpVersion::class), $container->getByType(AttributeReflectionFactory::class), - $container->getParameter('phpVersion'), + $container->getByType(ConfiguredPhpVersionRangeHelper::class), $constantResolver, ), );