Skip to content

Commit 72dc153

Browse files
staabmphpstan-bot
authored andcommitted
Resolve PHP version checks in return-type, throw-type and type-specifying extensions from Scope::getPhpVersion()
* Add the range-aware counterparts of the `PhpVersion` methods the extensions needed to `PhpVersions`: `arrayFunctionsReturnNullWithNonArray()`, `hasDateTimeExceptions()`, `hasFilterThrowOnFailureConstant()`, `hasPDOSubclasses()`, `hasStricterRoundFunctions()`, `highlightStringDoesNotReturnFalse()`, `strSplitReturnsEmptyArray()`, `substrReturnFalseInsteadOfEmptyString()`, `throwsOnInvalidMbStringEncoding()`, `supportsHhPrintfSpecifier()`, `supportsPassNoneEncodings()`, `supportsPregUnmatchedAsNull()`, `supportsPregCaptureOnlyNamedGroups()`, the three `mb_substitute_character` predicates and `supportsNativeReflectionAdapterReturnTypes()`. * Add `PhpVersions::pickType()`, which returns the branch matching a certain version check and the union of both branches when the analysed version range spans them, so a `Maybe` answer never produces a type narrower than the truth. * Convert every `Dynamic*ReturnTypeExtension`, `Dynamic*ThrowTypeExtension` and `*TypeSpecifyingExtension` in `src/Type/Php/` that injected `PhpVersion` (array functions, `substr`, `str_split`, `round`, `highlight_string`, `count_chars`, `hash`, `mb_*`, `min`/`max`, `openssl_cipher_*`, `printf`, `trigger_error`, `version_compare`, `filter_*`, `bcmath`, `PDO::connect`, the `DateTime`/ `DateInterval`/`DateTimeZone` family) to read the version from the call's Scope. * Thread `PhpVersions` through the helpers those extensions share: `ArrayColumnHelper`, `ArrayFilterFunctionReturnTypeHelper`, `FilterFunctionReturnTypeHelper`, `MbFunctionsReturnTypeExtensionTrait`, `PrintfFormatParser`, `RegexArrayShapeMatcher` and `RegexGroupParser`. * `RegexArrayShapeMatcher` now folds the analysed version's `PREG_UNMATCHED_AS_NULL` support into the parsed flags, so `containsUnmatchedAsNull()` no longer needs a version of its own. * `PDOConnectReturnTypeExtension` moved its version gate out of `isStaticMethodSupported()` (which gets no Scope) into `getTypeFromStaticMethodCall()`. * The same treatment for the three BetterReflection adapter return-type extensions in `src/Reflection/`, and for `FilterVarRule` and the three `Printf*Rule`s that share the converted helpers. * Add `PHPStan\Build\NoInjectedPhpVersionInScopeAwareExtensionRule` so extensions implementing a Scope-receiving interface cannot drift back to injecting `PhpVersion`. `OperatorTypeSpecifyingExtension` is deliberately not covered - its interface hands over no Scope, so `BcMathNumber*OperatorTypeSpecifyingExtension` keep their injected `PhpVersion`.
1 parent c5fe7d5 commit 72dc153

70 files changed

Lines changed: 871 additions & 521 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Build;
4+
5+
use PhpParser\Node;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\Node\InClassNode;
8+
use PHPStan\Php\PhpVersion;
9+
use PHPStan\Rules\Rule;
10+
use PHPStan\Rules\RuleErrorBuilder;
11+
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
12+
use PHPStan\Type\DynamicFunctionThrowTypeExtension;
13+
use PHPStan\Type\DynamicMethodReturnTypeExtension;
14+
use PHPStan\Type\DynamicMethodThrowTypeExtension;
15+
use PHPStan\Type\DynamicStaticMethodReturnTypeExtension;
16+
use PHPStan\Type\DynamicStaticMethodThrowTypeExtension;
17+
use PHPStan\Type\FunctionTypeSpecifyingExtension;
18+
use PHPStan\Type\MethodTypeSpecifyingExtension;
19+
use PHPStan\Type\StaticMethodTypeSpecifyingExtension;
20+
use function sprintf;
21+
22+
/**
23+
* Every one of these extension interfaces hands the call's Scope to the extension,
24+
* so the analysed PHP version must be read from Scope::getPhpVersion(). A DI-injected
25+
* PhpVersion always answers with the configured version and silently ignores both
26+
* PHP_VERSION_ID narrowing in the analysed code and configured version ranges.
27+
*
28+
* @implements Rule<InClassNode>
29+
*/
30+
final class NoInjectedPhpVersionInScopeAwareExtensionRule implements Rule
31+
{
32+
33+
private const SCOPE_AWARE_EXTENSIONS = [
34+
DynamicFunctionReturnTypeExtension::class,
35+
DynamicMethodReturnTypeExtension::class,
36+
DynamicStaticMethodReturnTypeExtension::class,
37+
DynamicFunctionThrowTypeExtension::class,
38+
DynamicMethodThrowTypeExtension::class,
39+
DynamicStaticMethodThrowTypeExtension::class,
40+
FunctionTypeSpecifyingExtension::class,
41+
MethodTypeSpecifyingExtension::class,
42+
StaticMethodTypeSpecifyingExtension::class,
43+
];
44+
45+
public function getNodeType(): string
46+
{
47+
return InClassNode::class;
48+
}
49+
50+
public function processNode(Node $node, Scope $scope): array
51+
{
52+
$classReflection = $node->getClassReflection();
53+
if (!$classReflection->hasConstructor()) {
54+
return [];
55+
}
56+
57+
$implementedExtension = null;
58+
foreach (self::SCOPE_AWARE_EXTENSIONS as $extensionInterface) {
59+
if (!$classReflection->is($extensionInterface)) {
60+
continue;
61+
}
62+
63+
$implementedExtension = $extensionInterface;
64+
break;
65+
}
66+
67+
if ($implementedExtension === null) {
68+
return [];
69+
}
70+
71+
$constructorVariant = $classReflection->getConstructor()->getOnlyVariant();
72+
$errors = [];
73+
foreach ($constructorVariant->getParameters() as $parameter) {
74+
foreach ($parameter->getType()->getObjectClassNames() as $className) {
75+
if ($className !== PhpVersion::class) {
76+
continue;
77+
}
78+
79+
$errors[] = RuleErrorBuilder::message(sprintf(
80+
'%s implements %s and must not inject %s - read the analysed PHP version from Scope::getPhpVersion() instead.',
81+
$classReflection->getDisplayName(),
82+
$implementedExtension,
83+
PhpVersion::class,
84+
))->identifier('phpstanBuild.phpVersionInExtension')->build();
85+
}
86+
}
87+
88+
return $errors;
89+
}
90+
91+
}

‎build/phpstan.neon‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ parameters:
201201
rules:
202202
- PHPStan\Build\FinalClassRule
203203
- PHPStan\Build\NoScopeTypeReadInEngineRule
204+
- PHPStan\Build\NoInjectedPhpVersionInScopeAwareExtensionRule
204205
- PHPStan\Build\AttributeNamedArgumentsRule
205206
- PHPStan\Build\NamedArgumentsRule
206207
- PHPStan\Build\OverrideAttributeThirdPartyMethodRule

‎src/Php/PhpVersions.php‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use PHPStan\Turbo\ReferencedByTurboExtension;
77
use PHPStan\Type\IntegerRangeType;
88
use PHPStan\Type\Type;
9+
use PHPStan\Type\TypeCombinator;
910

1011
/**
1112
* Range-aware PHP version check that handles version uncertainty.
@@ -188,4 +189,120 @@ public function supportsDeprecatedTraits(): TrinaryLogic
188189
return IntegerRangeType::fromInterval(80500, null)->isSuperTypeOf($this->phpVersions)->result;
189190
}
190191

192+
public function arrayFunctionsReturnNullWithNonArray(): TrinaryLogic
193+
{
194+
return IntegerRangeType::fromInterval(null, 79999)->isSuperTypeOf($this->phpVersions)->result;
195+
}
196+
197+
public function hasDateTimeExceptions(): TrinaryLogic
198+
{
199+
return IntegerRangeType::fromInterval(80300, null)->isSuperTypeOf($this->phpVersions)->result;
200+
}
201+
202+
public function hasFilterThrowOnFailureConstant(): TrinaryLogic
203+
{
204+
return IntegerRangeType::fromInterval(80500, null)->isSuperTypeOf($this->phpVersions)->result;
205+
}
206+
207+
public function hasPDOSubclasses(): TrinaryLogic
208+
{
209+
return IntegerRangeType::fromInterval(80400, null)->isSuperTypeOf($this->phpVersions)->result;
210+
}
211+
212+
public function hasStricterRoundFunctions(): TrinaryLogic
213+
{
214+
return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result;
215+
}
216+
217+
public function highlightStringDoesNotReturnFalse(): TrinaryLogic
218+
{
219+
return IntegerRangeType::fromInterval(80400, null)->isSuperTypeOf($this->phpVersions)->result;
220+
}
221+
222+
public function isEmptyStringValidAliasForNoneInMbSubstituteCharacter(): TrinaryLogic
223+
{
224+
return IntegerRangeType::fromInterval(null, 79999)->isSuperTypeOf($this->phpVersions)->result;
225+
}
226+
227+
public function isNullValidArgInMbSubstituteCharacter(): TrinaryLogic
228+
{
229+
return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result;
230+
}
231+
232+
public function isNumericStringValidArgInMbSubstituteCharacter(): TrinaryLogic
233+
{
234+
return IntegerRangeType::fromInterval(null, 79999)->isSuperTypeOf($this->phpVersions)->result;
235+
}
236+
237+
public function strSplitReturnsEmptyArray(): TrinaryLogic
238+
{
239+
return IntegerRangeType::fromInterval(80200, null)->isSuperTypeOf($this->phpVersions)->result;
240+
}
241+
242+
public function substrReturnFalseInsteadOfEmptyString(): TrinaryLogic
243+
{
244+
return IntegerRangeType::fromInterval(null, 79999)->isSuperTypeOf($this->phpVersions)->result;
245+
}
246+
247+
public function supportsAllUnicodeScalarCodePointsInMbSubstituteCharacter(): TrinaryLogic
248+
{
249+
return IntegerRangeType::fromInterval(70200, null)->isSuperTypeOf($this->phpVersions)->result;
250+
}
251+
252+
public function supportsHhPrintfSpecifier(): TrinaryLogic
253+
{
254+
return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result;
255+
}
256+
257+
public function supportsPassNoneEncodings(): TrinaryLogic
258+
{
259+
return IntegerRangeType::fromInterval(null, 70299)->isSuperTypeOf($this->phpVersions)->result;
260+
}
261+
262+
public function supportsPregCaptureOnlyNamedGroups(): TrinaryLogic
263+
{
264+
// https://php.watch/versions/8.2/preg-n-no-capture-modifier
265+
return IntegerRangeType::fromInterval(80200, null)->isSuperTypeOf($this->phpVersions)->result;
266+
}
267+
268+
public function supportsPregUnmatchedAsNull(): TrinaryLogic
269+
{
270+
// while PREG_UNMATCHED_AS_NULL is defined in php-src since 7.2.x it starts working as expected with 7.4.x
271+
// https://3v4l.org/v3HE4
272+
return IntegerRangeType::fromInterval(70400, null)->isSuperTypeOf($this->phpVersions)->result;
273+
}
274+
275+
public function throwsOnInvalidMbStringEncoding(): TrinaryLogic
276+
{
277+
return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result;
278+
}
279+
280+
/**
281+
* PHPStan's BetterReflection adapters keep their narrowed native return types only on PHP 8+;
282+
* the downgraded PHP 7 build widens them back to the core Reflection ones.
283+
*/
284+
public function supportsNativeReflectionAdapterReturnTypes(): TrinaryLogic
285+
{
286+
return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result;
287+
}
288+
289+
/**
290+
* Resolves a version-dependent type.
291+
*
292+
* When the analysed PHP version range spans both sides of $versionCheck,
293+
* neither branch can be ruled out and the union of both is returned.
294+
*/
295+
public static function pickType(TrinaryLogic $versionCheck, Type $ifYes, Type $ifNo): Type
296+
{
297+
if ($versionCheck->yes()) {
298+
return $ifYes;
299+
}
300+
301+
if ($versionCheck->no()) {
302+
return $ifNo;
303+
}
304+
305+
return TypeCombinator::union($ifYes, $ifNo);
306+
}
307+
191308
}

‎src/Reflection/BetterReflection/Type/AdapterReflectionEnumCaseDynamicReturnTypeExtension.php‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
use PhpParser\Node\Expr\MethodCall;
66
use PHPStan\Analyser\Scope;
77
use PHPStan\BetterReflection\Reflection\Adapter\ReflectionType;
8-
use PHPStan\Php\PhpVersion;
98
use PHPStan\Reflection\MethodReflection;
109
use PHPStan\Type\Constant\ConstantBooleanType;
1110
use PHPStan\Type\DynamicMethodReturnTypeExtension;
@@ -22,7 +21,7 @@ final class AdapterReflectionEnumCaseDynamicReturnTypeExtension implements Dynam
2221
/**
2322
* @param class-string $class
2423
*/
25-
public function __construct(private PhpVersion $phpVersion, private string $class)
24+
public function __construct(private string $class)
2625
{
2726
}
2827

@@ -41,7 +40,7 @@ public function isMethodSupported(MethodReflection $methodReflection): bool
4140

4241
public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type
4342
{
44-
if ($this->phpVersion->getVersionId() >= 80000) {
43+
if (!$scope->getPhpVersion()->supportsNativeReflectionAdapterReturnTypes()->no()) {
4544
return null;
4645
}
4746

‎src/Reflection/BetterReflection/Type/AdapterReflectionEnumDynamicReturnTypeExtension.php‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
use PHPStan\BetterReflection\Reflection\Adapter\ReflectionEnum;
1010
use PHPStan\BetterReflection\Reflection\Adapter\ReflectionNamedType;
1111
use PHPStan\DependencyInjection\AutowiredService;
12-
use PHPStan\Php\PhpVersion;
1312
use PHPStan\Reflection\MethodReflection;
1413
use PHPStan\Type\Accessory\AccessoryNonEmptyStringType;
1514
use PHPStan\Type\Constant\ConstantBooleanType;
@@ -27,10 +26,6 @@
2726
final class AdapterReflectionEnumDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
2827
{
2928

30-
public function __construct(private PhpVersion $phpVersion)
31-
{
32-
}
33-
3429
public function getClass(): string
3530
{
3631
return ReflectionEnum::class;
@@ -52,7 +47,7 @@ public function isMethodSupported(MethodReflection $methodReflection): bool
5247

5348
public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type
5449
{
55-
if ($this->phpVersion->getVersionId() >= 80000) {
50+
if (!$scope->getPhpVersion()->supportsNativeReflectionAdapterReturnTypes()->no()) {
5651
return null;
5752
}
5853

‎src/Reflection/PHPStan/NativeReflectionEnumReturnDynamicReturnTypeExtension.php‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
use PhpParser\Node\Expr\MethodCall;
66
use PHPStan\Analyser\Scope;
77
use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass;
8-
use PHPStan\Php\PhpVersion;
98
use PHPStan\Reflection\MethodReflection;
109
use PHPStan\Type\DynamicMethodReturnTypeExtension;
1110
use PHPStan\Type\ObjectType;
@@ -17,7 +16,7 @@ final class NativeReflectionEnumReturnDynamicReturnTypeExtension implements Dyna
1716
/**
1817
* @param class-string $className
1918
*/
20-
public function __construct(private PhpVersion $phpVersion, private string $className, private string $methodName)
19+
public function __construct(private string $className, private string $methodName)
2120
{
2221
}
2322

@@ -33,7 +32,7 @@ public function isMethodSupported(MethodReflection $methodReflection): bool
3332

3433
public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type
3534
{
36-
if ($this->phpVersion->getVersionId() >= 80000) {
35+
if (!$scope->getPhpVersion()->supportsNativeReflectionAdapterReturnTypes()->no()) {
3736
return null;
3837
}
3938

‎src/Rules/Functions/FilterVarRule.php‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
use PhpParser\Node\Name;
88
use PHPStan\Analyser\Scope;
99
use PHPStan\DependencyInjection\RegisteredRule;
10-
use PHPStan\Php\PhpVersion;
1110
use PHPStan\Reflection\ReflectionProvider;
1211
use PHPStan\Rules\Rule;
1312
use PHPStan\Rules\RuleErrorBuilder;
@@ -25,7 +24,6 @@ public function __construct(
2524
private ReflectionProvider $reflectionProvider,
2625
private FilterFunctionReturnTypeHelper $filterFunctionReturnTypeHelper,
2726
private FilterFunctionFlagsHelper $filterFunctionFlagsHelper,
28-
private PhpVersion $phpVersion,
2927
)
3028
{
3129
}
@@ -51,7 +49,7 @@ public function processNode(Node $node, Scope $scope): array
5149
}
5250

5351
if (
54-
!$this->phpVersion->hasFilterThrowOnFailureConstant()
52+
$scope->getPhpVersion()->hasFilterThrowOnFailureConstant()->no()
5553
|| !$this->reflectionProvider->hasConstant(new Name\FullyQualified('FILTER_THROW_ON_FAILURE'), null)
5654
) {
5755
return [];

‎src/Rules/Functions/PrintfArrayParametersRule.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ public function processNode(Node $node, Scope $scope): array
6767
foreach ($formatArgType->getConstantStrings() as $formatString) {
6868
$format = $formatString->getValue();
6969

70-
$uses = $this->printfFormatParser->parse($format);
70+
$uses = $this->printfFormatParser->parse($format, $scope->getPhpVersion());
7171
if ($uses === null) {
7272
return [
7373
RuleErrorBuilder::message(sprintf(

‎src/Rules/Functions/PrintfParameterTypeRule.php‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use PhpParser\Node;
66
use PHPStan\Analyser\Scope;
7+
use PHPStan\Php\PhpVersions;
78
use PHPStan\Reflection\ReflectionProvider;
89
use PHPStan\Rules\Rule;
910
use PHPStan\Rules\RuleErrorBuilder;
@@ -94,7 +95,7 @@ public function processNode(Node $node, Scope $scope): array
9495

9596
$formatString = $formatArgTypeStrings[0];
9697
$format = $formatString->getValue();
97-
$placeholderMap = $this->getPlaceholders($format);
98+
$placeholderMap = $this->getPlaceholders($format, $scope->getPhpVersion());
9899
if ($placeholderMap === null) {
99100
// Already reported by PrintfParametersRule.
100101
return [];
@@ -176,9 +177,9 @@ public function processNode(Node $node, Scope $scope): array
176177
/**
177178
* @return array<int, non-empty-list<PrintfPlaceholder>>|null parameter index => placeholders
178179
*/
179-
private function getPlaceholders(string $format): ?array
180+
private function getPlaceholders(string $format, PhpVersions $phpVersions): ?array
180181
{
181-
$uses = $this->printfFormatParser->parse($format);
182+
$uses = $this->printfFormatParser->parse($format, $phpVersions);
182183
if ($uses === null) {
183184
return null;
184185
}

0 commit comments

Comments
 (0)