From 37924d18f523f0302c60b3d0fa2f7e560c4434c5 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 26 Jul 2025 16:07:29 +0200 Subject: [PATCH 1/6] Add DateIntervalFormatDynamicReturnTypeExtension --- ...tervalFormatDynamicReturnTypeExtension.php | 89 +++++++++++++++++++ .../Analyser/nsrt/date-interval-format.php | 40 +++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/Type/Php/DateIntervalFormatDynamicReturnTypeExtension.php create mode 100644 tests/PHPStan/Analyser/nsrt/date-interval-format.php diff --git a/src/Type/Php/DateIntervalFormatDynamicReturnTypeExtension.php b/src/Type/Php/DateIntervalFormatDynamicReturnTypeExtension.php new file mode 100644 index 0000000000..aec738e40e --- /dev/null +++ b/src/Type/Php/DateIntervalFormatDynamicReturnTypeExtension.php @@ -0,0 +1,89 @@ +getName() === 'format'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + $arguments = $methodCall->getArgs(); + + if (!isset($arguments[0])) { + return null; + } + + $arg = $scope->getType($arguments[0]->value); + + $constantStrings = $arg->getConstantStrings(); + if (count($constantStrings) === 0) { + if ($arg->isNonEmptyString()->yes()) { + return new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]); + } + + + return null; + } + + // The worst case scenario for the non-falsy-string check is that every number are 0. + $dateInterval = new DateInterval('P0D'); + + $possibleReturnTypes = []; + foreach ($constantStrings as $string) { + $value = $dateInterval->format($string->getValue()); + + $accessories = []; + if (is_numeric($value)) { + $accessories[] = new AccessoryNumericStringType(); + } + if ($value !== '0' && $value !== '') { + $accessories[] = new AccessoryNonFalsyStringType(); + } elseif ($value !== '') { + $accessories[] = new AccessoryNonEmptyStringType(); + } + if (strtolower($value) === $value) { + $accessories[] = new AccessoryLowercaseStringType(); + } + if (strtoupper($value) === $value) { + $accessories[] = new AccessoryUppercaseStringType(); + } + + if (count($accessories) === 0) { + return null; + } + + $possibleReturnTypes[] = new IntersectionType([new StringType(), ...$accessories]); + } + + return TypeCombinator::union(...$possibleReturnTypes); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/date-interval-format.php b/tests/PHPStan/Analyser/nsrt/date-interval-format.php new file mode 100644 index 0000000000..691d9e3328 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/date-interval-format.php @@ -0,0 +1,40 @@ +format($string)); + assertType('non-empty-string', $dateInterval->format($nonEmptyString)); + + assertType('lowercase-string&non-falsy-string&numeric-string&uppercase-string', $dateInterval->format('%Y')); // '00' + assertType('lowercase-string&non-empty-string&numeric-string&uppercase-string', $dateInterval->format('%y')); // '0' + assertType('lowercase-string&non-falsy-string&numeric-string&uppercase-string', $dateInterval->format($unionString1)); + assertType('lowercase-string&non-empty-string&numeric-string&uppercase-string', $dateInterval->format($unionString2)); + + assertType('non-falsy-string&uppercase-string', $dateInterval->format('%Y DAYS')); + assertType('non-falsy-string&uppercase-string', $dateInterval->format($unionString1. ' DAYS')); + + assertType('lowercase-string&non-falsy-string', $dateInterval->format('%Y days')); + assertType('lowercase-string&non-falsy-string', $dateInterval->format($unionString1. ' days')); + } +} From 68ad9c26cfe7ecfaf369f2aa163c5afd8641091d Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 26 Jul 2025 16:18:13 +0200 Subject: [PATCH 2/6] Simplify --- ...tervalFormatDynamicReturnTypeExtension.php | 36 +++++-------------- 1 file changed, 8 insertions(+), 28 deletions(-) diff --git a/src/Type/Php/DateIntervalFormatDynamicReturnTypeExtension.php b/src/Type/Php/DateIntervalFormatDynamicReturnTypeExtension.php index aec738e40e..8592ed939c 100644 --- a/src/Type/Php/DateIntervalFormatDynamicReturnTypeExtension.php +++ b/src/Type/Php/DateIntervalFormatDynamicReturnTypeExtension.php @@ -7,12 +7,11 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\MethodReflection; -use PHPStan\Type\Accessory\AccessoryLowercaseStringType; +use PHPStan\Type\Accessory\AccessoryLiteralStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; -use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; -use PHPStan\Type\Accessory\AccessoryNumericStringType; -use PHPStan\Type\Accessory\AccessoryUppercaseStringType; +use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\DynamicMethodReturnTypeExtension; +use PHPStan\Type\GeneralizePrecision; use PHPStan\Type\IntersectionType; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -53,37 +52,18 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return null; } - // The worst case scenario for the non-falsy-string check is that every number are 0. + // The worst case scenario for the non-falsy-string check is that every number is 0. $dateInterval = new DateInterval('P0D'); $possibleReturnTypes = []; foreach ($constantStrings as $string) { $value = $dateInterval->format($string->getValue()); - - $accessories = []; - if (is_numeric($value)) { - $accessories[] = new AccessoryNumericStringType(); - } - if ($value !== '0' && $value !== '') { - $accessories[] = new AccessoryNonFalsyStringType(); - } elseif ($value !== '') { - $accessories[] = new AccessoryNonEmptyStringType(); - } - if (strtolower($value) === $value) { - $accessories[] = new AccessoryLowercaseStringType(); - } - if (strtoupper($value) === $value) { - $accessories[] = new AccessoryUppercaseStringType(); - } - - if (count($accessories) === 0) { - return null; - } - - $possibleReturnTypes[] = new IntersectionType([new StringType(), ...$accessories]); + $possibleReturnTypes[] = new ConstantStringType($value); } - return TypeCombinator::union(...$possibleReturnTypes); + $result = TypeCombinator::union(...$possibleReturnTypes)->generalize(GeneralizePrecision::moreSpecific()); + + return TypeCombinator::remove($result, new AccessoryLiteralStringType()); } } From b2b2369ca8792831883d3af2d3a832829e3a3a1c Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 26 Jul 2025 16:20:26 +0200 Subject: [PATCH 3/6] Fix --- .../Php/DateIntervalFormatDynamicReturnTypeExtension.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Type/Php/DateIntervalFormatDynamicReturnTypeExtension.php b/src/Type/Php/DateIntervalFormatDynamicReturnTypeExtension.php index 8592ed939c..354bfc46d0 100644 --- a/src/Type/Php/DateIntervalFormatDynamicReturnTypeExtension.php +++ b/src/Type/Php/DateIntervalFormatDynamicReturnTypeExtension.php @@ -48,7 +48,6 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]); } - return null; } @@ -61,9 +60,10 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method $possibleReturnTypes[] = new ConstantStringType($value); } - $result = TypeCombinator::union(...$possibleReturnTypes)->generalize(GeneralizePrecision::moreSpecific()); - - return TypeCombinator::remove($result, new AccessoryLiteralStringType()); + return TypeCombinator::remove( + TypeCombinator::union(...$possibleReturnTypes)->generalize(GeneralizePrecision::moreSpecific()), + new AccessoryLiteralStringType(), + ); } } From e6490cdcf420e8a757263a236a82df5ca449b940 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 26 Jul 2025 16:25:29 +0200 Subject: [PATCH 4/6] Fix --- ...tervalFormatDynamicReturnTypeExtension.php | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/src/Type/Php/DateIntervalFormatDynamicReturnTypeExtension.php b/src/Type/Php/DateIntervalFormatDynamicReturnTypeExtension.php index 354bfc46d0..f6d32b02ed 100644 --- a/src/Type/Php/DateIntervalFormatDynamicReturnTypeExtension.php +++ b/src/Type/Php/DateIntervalFormatDynamicReturnTypeExtension.php @@ -7,16 +7,20 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\MethodReflection; -use PHPStan\Type\Accessory\AccessoryLiteralStringType; +use PHPStan\Type\Accessory\AccessoryLowercaseStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; -use PHPStan\Type\Constant\ConstantStringType; +use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; +use PHPStan\Type\Accessory\AccessoryNumericStringType; +use PHPStan\Type\Accessory\AccessoryUppercaseStringType; use PHPStan\Type\DynamicMethodReturnTypeExtension; -use PHPStan\Type\GeneralizePrecision; use PHPStan\Type\IntersectionType; use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function count; +use function is_numeric; +use function strtolower; +use function strtoupper; #[AutowiredService] final class DateIntervalFormatDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension @@ -57,13 +61,31 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method $possibleReturnTypes = []; foreach ($constantStrings as $string) { $value = $dateInterval->format($string->getValue()); - $possibleReturnTypes[] = new ConstantStringType($value); + + $accessories = []; + if (is_numeric($value)) { + $accessories[] = new AccessoryNumericStringType(); + } + if ($value !== '0' && $value !== '') { + $accessories[] = new AccessoryNonFalsyStringType(); + } elseif ($value !== '') { + $accessories[] = new AccessoryNonEmptyStringType(); + } + if (strtolower($value) === $value) { + $accessories[] = new AccessoryLowercaseStringType(); + } + if (strtoupper($value) === $value) { + $accessories[] = new AccessoryUppercaseStringType(); + } + + if (count($accessories) === 0) { + return null; + } + + $possibleReturnTypes[] = new IntersectionType([new StringType(), ...$accessories]); } - return TypeCombinator::remove( - TypeCombinator::union(...$possibleReturnTypes)->generalize(GeneralizePrecision::moreSpecific()), - new AccessoryLiteralStringType(), - ); + return TypeCombinator::union(...$possibleReturnTypes); } } From cfa69ca20c03e4854e934f51daa45cfe4af97833 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 10 Sep 2025 12:48:16 +0200 Subject: [PATCH 5/6] Add tests --- tests/PHPStan/Analyser/nsrt/bug-1452.php | 9 +++++++++ .../Rules/Operators/InvalidBinaryOperationRuleTest.php | 7 +++++++ tests/PHPStan/Rules/Operators/data/bug-1452.php | 7 +++++++ 3 files changed, 23 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-1452.php create mode 100644 tests/PHPStan/Rules/Operators/data/bug-1452.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-1452.php b/tests/PHPStan/Analyser/nsrt/bug-1452.php new file mode 100644 index 0000000000..e811d1a613 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-1452.php @@ -0,0 +1,9 @@ +diff(new \DateTimeImmutable('now')); + +assertType('lowercase-string&non-empty-string&numeric-string&uppercase-string', $dateInterval->format('%a')); diff --git a/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php b/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php index 840bd8b450..25a595054a 100644 --- a/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php +++ b/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php @@ -795,6 +795,13 @@ public function testBenevolentUnion(): void ]); } + public function testBug1452(): void + { + $this->checkImplicitMixed = true; + + $this->analyse([__DIR__ . '/data/bug-1452.php'], []); + } + public function testBug7863(): void { $this->checkImplicitMixed = true; diff --git a/tests/PHPStan/Rules/Operators/data/bug-1452.php b/tests/PHPStan/Rules/Operators/data/bug-1452.php new file mode 100644 index 0000000000..f047b74614 --- /dev/null +++ b/tests/PHPStan/Rules/Operators/data/bug-1452.php @@ -0,0 +1,7 @@ +diff(new \DateTimeImmutable('now')); + +echo $dateInterval->format('%a') * 60; From 2c501dae2a14ce4e80230166616662669902b4d7 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 10 Sep 2025 13:01:28 +0200 Subject: [PATCH 6/6] Simplify --- tests/PHPStan/Analyser/nsrt/bug-1452.php | 3 ++- .../Rules/Operators/InvalidBinaryOperationRuleTest.php | 7 ------- tests/PHPStan/Rules/Operators/data/bug-1452.php | 7 ------- 3 files changed, 2 insertions(+), 15 deletions(-) delete mode 100644 tests/PHPStan/Rules/Operators/data/bug-1452.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-1452.php b/tests/PHPStan/Analyser/nsrt/bug-1452.php index e811d1a613..c4c40325c6 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-1452.php +++ b/tests/PHPStan/Analyser/nsrt/bug-1452.php @@ -6,4 +6,5 @@ $dateInterval = (new \DateTimeImmutable('now -60 minutes'))->diff(new \DateTimeImmutable('now')); -assertType('lowercase-string&non-empty-string&numeric-string&uppercase-string', $dateInterval->format('%a')); +// Could be lowercase-string&non-falsy-string&numeric-string&uppercase-string +assertType('lowercase-string&non-falsy-string', $dateInterval->format('%a')); diff --git a/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php b/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php index 25a595054a..840bd8b450 100644 --- a/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php +++ b/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php @@ -795,13 +795,6 @@ public function testBenevolentUnion(): void ]); } - public function testBug1452(): void - { - $this->checkImplicitMixed = true; - - $this->analyse([__DIR__ . '/data/bug-1452.php'], []); - } - public function testBug7863(): void { $this->checkImplicitMixed = true; diff --git a/tests/PHPStan/Rules/Operators/data/bug-1452.php b/tests/PHPStan/Rules/Operators/data/bug-1452.php deleted file mode 100644 index f047b74614..0000000000 --- a/tests/PHPStan/Rules/Operators/data/bug-1452.php +++ /dev/null @@ -1,7 +0,0 @@ -diff(new \DateTimeImmutable('now')); - -echo $dateInterval->format('%a') * 60;