diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index 2ad962c8d3..ad0a70f0d5 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -22,3 +22,4 @@ parameters: checkDynamicConstantNameValues: true unusedLabel: true newOnNonObject: true + composerPhp64Bit: true diff --git a/conf/config.neon b/conf/config.neon index b622c8a6dd..828619d8ef 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -49,6 +49,7 @@ parameters: checkDynamicConstantNameValues: false unusedLabel: false newOnNonObject: false + composerPhp64Bit: false fileExtensions: - php checkAdvancedIsset: false @@ -103,6 +104,7 @@ parameters: minimumNumberOfJobsPerProcess: 2 buffer: 134217728 # 128 MB loadLimit: 1.0 + phpIntSize: null phpVersion: null polluteScopeWithLoopInitialAssignments: true polluteScopeWithAlwaysIterableForeach: true diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 3c1819a146..db2dfa25c1 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -51,6 +51,7 @@ parametersSchema: checkDynamicConstantNameValues: bool() unusedLabel: bool() newOnNonObject: bool() + composerPhp64Bit: bool() ]) fileExtensions: listOf(string()) checkAdvancedIsset: bool() @@ -110,6 +111,7 @@ parametersSchema: buffer: int(), loadLimit: schema(float(), nullable()) ]) + phpIntSize: schema(anyOf(8), nullable()) phpVersion: schema(anyOf( schema(int(), min(70100), max(80599)), structure([ diff --git a/src/Analyser/ConstantResolver.php b/src/Analyser/ConstantResolver.php index 338dc1ece9..c5f1eaa3af 100644 --- a/src/Analyser/ConstantResolver.php +++ b/src/Analyser/ConstantResolver.php @@ -5,6 +5,7 @@ use PhpParser\Node\Name; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Container; +use PHPStan\Php\ConfiguredPhpIntSizeHelper; use PHPStan\Php\ConfiguredPhpVersionRangeHelper; use PHPStan\PhpDoc\TypeStringResolver; use PHPStan\Reflection\NamespaceAnswerer; @@ -51,6 +52,7 @@ public function __construct( private ReflectionProviderProvider $reflectionProviderProvider, private array $dynamicConstantNames, private ConfiguredPhpVersionRangeHelper $configuredPhpVersionRangeHelper, + private ConfiguredPhpIntSizeHelper $configuredPhpIntSizeHelper, private ?Container $container, ) { @@ -219,17 +221,30 @@ public function resolvePredefinedConstant(string $resolvedConstantName): ?Type ]); } if ($resolvedConstantName === 'PHP_INT_MAX') { - return PHP_INT_SIZE === 8 - ? new UnionType([new ConstantIntegerType(2147483647), new ConstantIntegerType(9223372036854775807)]) - : new ConstantIntegerType(2147483647); + // The 64bit literals below cannot be constructed when PHPStan itself runs on 32bit, + // so they stay inside branches that are never taken there. + if (PHP_INT_SIZE !== 8) { + return new ConstantIntegerType(2147483647); + } + if ($this->configuredPhpIntSizeHelper->getIntSize() === 8) { + return new ConstantIntegerType(9223372036854775807); + } + return new UnionType([new ConstantIntegerType(2147483647), new ConstantIntegerType(9223372036854775807)]); } if ($resolvedConstantName === 'PHP_INT_MIN') { // Why the -1 you might wonder, the answer is to fit it into an int :/ see https://3v4l.org/4SHIQ - return PHP_INT_SIZE === 8 - ? new UnionType([new ConstantIntegerType(-9223372036854775807 - 1), new ConstantIntegerType(-2147483647 - 1)]) - : new ConstantIntegerType(-2147483647 - 1); + if (PHP_INT_SIZE !== 8) { + return new ConstantIntegerType(-2147483647 - 1); + } + if ($this->configuredPhpIntSizeHelper->getIntSize() === 8) { + return new ConstantIntegerType(-9223372036854775807 - 1); + } + return new UnionType([new ConstantIntegerType(-9223372036854775807 - 1), new ConstantIntegerType(-2147483647 - 1)]); } if ($resolvedConstantName === 'PHP_INT_SIZE') { + if ($this->configuredPhpIntSizeHelper->getIntSize() === 8) { + return new ConstantIntegerType(8); + } return new UnionType([ new ConstantIntegerType(4), new ConstantIntegerType(8), diff --git a/src/Analyser/ConstantResolverFactory.php b/src/Analyser/ConstantResolverFactory.php index 338245eb45..81e77e3073 100644 --- a/src/Analyser/ConstantResolverFactory.php +++ b/src/Analyser/ConstantResolverFactory.php @@ -5,6 +5,7 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Container; use PHPStan\Php\ComposerPhpVersionFactory; +use PHPStan\Php\ConfiguredPhpIntSizeHelper; use PHPStan\Php\ConfiguredPhpVersionRangeHelper; use PHPStan\Reflection\ReflectionProvider\ReflectionProviderProvider; @@ -30,6 +31,7 @@ public function create(): ConstantResolver $this->container->getParameter('phpVersion'), $composerFactory, ), + $this->container->getByType(ConfiguredPhpIntSizeHelper::class), $this->container, ); } diff --git a/src/DependencyInjection/ValidateIgnoredErrorsExtension.php b/src/DependencyInjection/ValidateIgnoredErrorsExtension.php index b4c90d8199..e534735ca5 100644 --- a/src/DependencyInjection/ValidateIgnoredErrorsExtension.php +++ b/src/DependencyInjection/ValidateIgnoredErrorsExtension.php @@ -15,6 +15,7 @@ use PHPStan\DependencyInjection\Type\UnaryOperatorTypeSpecifyingExtensionRegistryProvider; use PHPStan\File\FileExcluder; use PHPStan\Php\ComposerPhpVersionFactory; +use PHPStan\Php\ConfiguredPhpIntSizeHelper; use PHPStan\Php\ConfiguredPhpVersionRangeHelper; use PHPStan\Php\PhpVersion; use PHPStan\PhpDoc\DirectTypeNodeResolverExtensionRegistryProvider; @@ -91,7 +92,7 @@ public function loadConfiguration(): void try { $composerPhpVersionFactory = new ComposerPhpVersionFactory([]); - $constantResolver = new ConstantResolver($reflectionProviderProvider, [], new ConfiguredPhpVersionRangeHelper(null, $composerPhpVersionFactory), container: null); + $constantResolver = new ConstantResolver($reflectionProviderProvider, [], new ConfiguredPhpVersionRangeHelper(null, $composerPhpVersionFactory), new ConfiguredPhpIntSizeHelper(null, false, []), container: null); $phpDocParserConfig = new ParserConfig([]); $ignoredRegexValidator = new IgnoredRegexValidator( diff --git a/src/Php/ComposerPhpVersionFactory.php b/src/Php/ComposerPhpVersionFactory.php index c9af575661..be107df11a 100644 --- a/src/Php/ComposerPhpVersionFactory.php +++ b/src/Php/ComposerPhpVersionFactory.php @@ -26,6 +26,8 @@ final class ComposerPhpVersionFactory public function __construct( #[AutowiredParameter] private array $composerAutoloaderProjectPaths, + #[AutowiredParameter(ref: '%featureToggles.composerPhp64Bit%')] + private bool $composerPhp64Bit = false, ) { } @@ -37,14 +39,14 @@ private function initializeVersions(): void // don't limit minVersion... PHPStan can analyze even PHP5 $this->maxVersion = new PhpVersion(PhpVersionFactory::MAX_PHP_VERSION); - // fallback to composer.json based php-version constraint - $composerPhpVersion = $this->getComposerRequireVersion(); - if ($composerPhpVersion === null) { + // fallback to composer.json based php-version constraints + $composerPhpVersions = $this->getComposerRequireVersions(); + if (count($composerPhpVersions) === 0) { return; } $parser = new ComposerPhpVersionParser(); - [$minVersion, $maxVersion] = $parser->parse($composerPhpVersion, static function (string $version, int $versionId, bool $isMaxVersion): PhpVersion { + [$minVersion, $maxVersion] = $parser->parse($composerPhpVersions, static function (string $version, int $versionId, bool $isMaxVersion): PhpVersion { if ($isMaxVersion && $version === '6.0.0.0-dev') { $versionId = min($versionId, PhpVersionFactory::MAX_PHP5_VERSION); } elseif ($isMaxVersion && $version === '8.0.0.0-dev') { @@ -83,22 +85,34 @@ public function getMaxVersion(): ?PhpVersion return $this->maxVersion; } - private function getComposerRequireVersion(): ?string + /** + * Composer registers php-64bit as a virtual package carrying the very same version as php, + * so a requirement on either one constrains the PHP version, and requiring both means + * both constraints have to hold at once. + * + * @return list + */ + private function getComposerRequireVersions(): array { - $composerPhpVersion = null; + $packageNames = $this->composerPhp64Bit ? ['php', 'php-64bit'] : ['php']; + $composerPhpVersions = []; if (count($this->composerAutoloaderProjectPaths) > 0) { $composer = ComposerHelper::getComposerConfig(end($this->composerAutoloaderProjectPaths)); if ($composer !== null) { - $requiredVersion = $composer['require']['php'] ?? null; + foreach ($packageNames as $packageName) { + $requiredVersion = $composer['require'][$packageName] ?? null; + + if (!is_string($requiredVersion)) { + continue; + } - if (is_string($requiredVersion)) { - $composerPhpVersion = $requiredVersion; + $composerPhpVersions[] = $requiredVersion; } } } - return $composerPhpVersion; + return $composerPhpVersions; } } diff --git a/src/Php/ComposerPhpVersionParser.php b/src/Php/ComposerPhpVersionParser.php index 1a37d3d57b..9b3c8c4371 100644 --- a/src/Php/ComposerPhpVersionParser.php +++ b/src/Php/ComposerPhpVersionParser.php @@ -2,24 +2,31 @@ namespace PHPStan\Php; +use Composer\Semver\Constraint\MultiConstraint; use Composer\Semver\VersionParser; use Nette\Utils\Strings; +use function array_map; use function sprintf; final class ComposerPhpVersionParser { /** + * @param non-empty-list $versions constraints that all have to be satisfied at once, + * like Composer's `php` and `php-64bit` requirements * @param callable(string, int, bool):PhpVersion $buildPhpVersion * * @return array{PhpVersion|null, PhpVersion|null} */ - public function parse(string $version, callable $buildPhpVersion): array + public function parse(array $versions, callable $buildPhpVersion): array { $minVersion = null; $parser = new VersionParser(); - $constraint = $parser->parseConstraints($version); + $constraint = MultiConstraint::create( + array_map(static fn (string $version) => $parser->parseConstraints($version), $versions), + true, + ); if (!$constraint->getLowerBound()->isZero()) { $minVersion = $this->buildVersion($constraint->getLowerBound()->getVersion(), false, $buildPhpVersion); diff --git a/src/Php/ConfiguredPhpIntSizeHelper.php b/src/Php/ConfiguredPhpIntSizeHelper.php new file mode 100644 index 0000000000..00ea3f3532 --- /dev/null +++ b/src/Php/ConfiguredPhpIntSizeHelper.php @@ -0,0 +1,84 @@ +initialized) { + $this->initialized = true; + $this->intSize = $this->configPhpIntSize ?? ($this->composerRequiresPhp64Bit() ? self::SUPPORTED_INT_SIZE : null); + } + + return $this->intSize; + } + + private function composerRequiresPhp64Bit(): bool + { + if (!$this->composerPhp64Bit) { + return false; + } + + if (count($this->composerAutoloaderProjectPaths) === 0) { + return false; + } + + $composer = ComposerHelper::getComposerConfig(end($this->composerAutoloaderProjectPaths)); + if ($composer === null) { + return false; + } + + return is_string($composer['require']['php-64bit'] ?? null); + } + +} diff --git a/src/Testing/PHPStanTestCase.php b/src/Testing/PHPStanTestCase.php index 2845f99768..f918c88d15 100644 --- a/src/Testing/PHPStanTestCase.php +++ b/src/Testing/PHPStanTestCase.php @@ -16,6 +16,7 @@ use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Parser\Parser; use PHPStan\Php\ComposerPhpVersionFactory; +use PHPStan\Php\ConfiguredPhpIntSizeHelper; use PHPStan\Php\ConfiguredPhpVersionRangeHelper; use PHPStan\Php\PhpVersion; use PHPStan\PhpDoc\TypeNodeResolver; @@ -96,7 +97,7 @@ public static function createScopeFactory(ReflectionProvider $reflectionProvider $reflectionProviderProvider = new DirectReflectionProviderProvider($reflectionProvider); $composerPhpVersionFactory = $container->getByType(ComposerPhpVersionFactory::class); - $constantResolver = new ConstantResolver($reflectionProviderProvider, $dynamicConstantNames, new ConfiguredPhpVersionRangeHelper(null, $composerPhpVersionFactory), container: $container); + $constantResolver = new ConstantResolver($reflectionProviderProvider, $dynamicConstantNames, new ConfiguredPhpVersionRangeHelper(null, $composerPhpVersionFactory), $container->getByType(ConfiguredPhpIntSizeHelper::class), container: $container); $initializerExprTypeResolver = new InitializerExprTypeResolver( $constantResolver, diff --git a/tests/PHPStan/Analyser/PhpIntSize8Test.php b/tests/PHPStan/Analyser/PhpIntSize8Test.php new file mode 100644 index 0000000000..5dda6f94a7 --- /dev/null +++ b/tests/PHPStan/Analyser/PhpIntSize8Test.php @@ -0,0 +1,41 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/php-int-size-8.neon', + ]; + } + +} diff --git a/tests/PHPStan/Analyser/data/php-int-size-8.php b/tests/PHPStan/Analyser/data/php-int-size-8.php new file mode 100644 index 0000000000..0bcd6cbeba --- /dev/null +++ b/tests/PHPStan/Analyser/data/php-int-size-8.php @@ -0,0 +1,16 @@ + [[], true, null]; + yield 'require php' => [[__DIR__ . '/data/composer-php-only'], true, 80100]; + + // php-64bit is versioned with the PHP version, so it constrains it the same way php does + yield 'require php-64bit only' => [[__DIR__ . '/data/composer-php-64bit-only'], true, 80100]; + + // "php-64bit": "*" carries no constraint, the one on php is the one that counts + yield 'require both' => [[__DIR__ . '/data/composer-php-64bit'], true, 80100]; + + // both requirements have to hold at once, so the narrower one wins + yield 'require both, php-64bit narrower' => [[__DIR__ . '/data/composer-php-64bit-narrower'], true, 80300]; + + yield 'require neither' => [[__DIR__ . '/data/composer-no-php-require'], true, null]; + + // Without the feature toggle, php-64bit is not read at all. + yield 'toggle off, require php-64bit only' => [[__DIR__ . '/data/composer-php-64bit-only'], false, null]; + yield 'toggle off, php-64bit narrower' => [[__DIR__ . '/data/composer-php-64bit-narrower'], false, 80100]; + } + + /** + * @param string[] $composerAutoloaderProjectPaths + */ + #[DataProvider('dataMinVersion')] + public function testMinVersion(array $composerAutoloaderProjectPaths, bool $composerPhp64Bit, ?int $expectedMinVersionId): void + { + $factory = new ComposerPhpVersionFactory($composerAutoloaderProjectPaths, $composerPhp64Bit); + $minVersion = $factory->getMinVersion(); + + $this->assertSame($expectedMinVersionId, $minVersion !== null ? $minVersion->getVersionId() : null); + } + +} diff --git a/tests/PHPStan/Php/ConfiguredPhpIntSizeHelperTest.php b/tests/PHPStan/Php/ConfiguredPhpIntSizeHelperTest.php new file mode 100644 index 0000000000..e95f1a8bbb --- /dev/null +++ b/tests/PHPStan/Php/ConfiguredPhpIntSizeHelperTest.php @@ -0,0 +1,42 @@ + [null, true, [], null]; + yield 'config wins' => [8, true, [], 8]; + + yield 'composer requires php-64bit' => [null, true, [__DIR__ . '/data/composer-php-64bit'], 8]; + yield 'composer requires php-64bit only' => [null, true, [__DIR__ . '/data/composer-php-64bit-only'], 8]; + yield 'composer requires php without php-64bit' => [null, true, [__DIR__ . '/data/composer-php-only'], null]; + yield 'composer requires neither' => [null, true, [__DIR__ . '/data/composer-no-php-require'], null]; + yield 'no composer.json' => [null, true, [__DIR__ . '/data/does-not-exist'], null]; + + yield 'config wins over composer' => [8, true, [__DIR__ . '/data/composer-php-only'], 8]; + + // Reading composer.json is behind a feature toggle, setting phpIntSize is not. + yield 'toggle off, composer requires php-64bit' => [null, false, [__DIR__ . '/data/composer-php-64bit'], null]; + yield 'toggle off, config set' => [8, false, [__DIR__ . '/data/composer-php-64bit'], 8]; + } + + /** + * @param 8|null $configPhpIntSize + * @param string[] $composerAutoloaderProjectPaths + * @param 8|null $expectedIntSize + */ + #[DataProvider('dataGetIntSize')] + public function testGetIntSize(?int $configPhpIntSize, bool $composerPhp64Bit, array $composerAutoloaderProjectPaths, ?int $expectedIntSize): void + { + $helper = new ConfiguredPhpIntSizeHelper($configPhpIntSize, $composerPhp64Bit, $composerAutoloaderProjectPaths); + + $this->assertSame($expectedIntSize, $helper->getIntSize()); + } + +} diff --git a/tests/PHPStan/Php/data/composer-no-php-require/composer.json b/tests/PHPStan/Php/data/composer-no-php-require/composer.json new file mode 100644 index 0000000000..fdf25ac9d1 --- /dev/null +++ b/tests/PHPStan/Php/data/composer-no-php-require/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "ext-json": "*" + } +} diff --git a/tests/PHPStan/Php/data/composer-php-64bit-narrower/composer.json b/tests/PHPStan/Php/data/composer-php-64bit-narrower/composer.json new file mode 100644 index 0000000000..cfe25d107c --- /dev/null +++ b/tests/PHPStan/Php/data/composer-php-64bit-narrower/composer.json @@ -0,0 +1,6 @@ +{ + "require": { + "php": "^8.1", + "php-64bit": "^8.3" + } +} diff --git a/tests/PHPStan/Php/data/composer-php-64bit-only/composer.json b/tests/PHPStan/Php/data/composer-php-64bit-only/composer.json new file mode 100644 index 0000000000..16e2599961 --- /dev/null +++ b/tests/PHPStan/Php/data/composer-php-64bit-only/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "php-64bit": "^8.1" + } +} diff --git a/tests/PHPStan/Php/data/composer-php-64bit/composer.json b/tests/PHPStan/Php/data/composer-php-64bit/composer.json new file mode 100644 index 0000000000..32ccc5752b --- /dev/null +++ b/tests/PHPStan/Php/data/composer-php-64bit/composer.json @@ -0,0 +1,6 @@ +{ + "require": { + "php": "^8.1", + "php-64bit": "*" + } +} diff --git a/tests/PHPStan/Php/data/composer-php-only/composer.json b/tests/PHPStan/Php/data/composer-php-only/composer.json new file mode 100644 index 0000000000..9be64619f1 --- /dev/null +++ b/tests/PHPStan/Php/data/composer-php-only/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "php": "^8.1" + } +}