From cb8ac26307066c3d07374026a04e8f21b6b473f3 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sat, 16 May 2026 12:13:55 +0000 Subject: [PATCH 1/5] Infer `numeric-string` for `DateInterval::format('%a')` when interval comes from `diff()` - Extract shared format type inference logic into DateIntervalFormatReturnTypeHelper - Check if the receiver's `days` property is narrowed to `int` (indicating the interval came from DateTimeInterface::diff() or date_diff()) - When `days` is `int`, use a diff-created DateInterval for evaluating `%a` format specifier, which produces a numeric string instead of "(unknown)" - Add DateIntervalFormatFunctionReturnTypeExtension for the procedural date_interval_format() function, which previously had no type inference --- ...tervalFormatDynamicReturnTypeExtension.php | 66 +++------------ ...ervalFormatFunctionReturnTypeExtension.php | 40 +++++++++ .../DateIntervalFormatReturnTypeHelper.php | 81 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-1452.php | 44 ++++++++-- 4 files changed, 168 insertions(+), 63 deletions(-) create mode 100644 src/Type/Php/DateIntervalFormatFunctionReturnTypeExtension.php create mode 100644 src/Type/Php/DateIntervalFormatReturnTypeHelper.php diff --git a/src/Type/Php/DateIntervalFormatDynamicReturnTypeExtension.php b/src/Type/Php/DateIntervalFormatDynamicReturnTypeExtension.php index 08f66913cc2..88306319a0a 100644 --- a/src/Type/Php/DateIntervalFormatDynamicReturnTypeExtension.php +++ b/src/Type/Php/DateIntervalFormatDynamicReturnTypeExtension.php @@ -7,25 +7,17 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\MethodReflection; -use PHPStan\Type\Accessory\AccessoryLowercaseStringType; -use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; -use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; -use PHPStan\Type\Accessory\AccessoryNumericStringType; -use PHPStan\Type\Accessory\AccessoryUppercaseStringType; use PHPStan\Type\DynamicMethodReturnTypeExtension; -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 { + public function __construct(private DateIntervalFormatReturnTypeHelper $helper) + { + } + public function getClass(): string { return DateInterval::class; @@ -44,51 +36,11 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method 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 is 0. - // `%a` format gives `(unknown)` and removes numeric and uppercase accessory but then - // we'll have to manually check for the non-falsy one. - $dateInterval = new DateInterval('P0D'); - - $possibleReturnTypes = []; - foreach ($constantStrings as $string) { - $formatString = $string->getValue(); - $value = $dateInterval->format($formatString); - - $accessories = []; - if (is_numeric($value)) { - $accessories[] = new AccessoryNumericStringType(); - } - if ($value !== '0' && $value !== '' && $formatString !== '%a') { - $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); + return $this->helper->getType( + $scope->getType($arguments[0]->value), + $scope->getType($methodCall->var), + $scope, + ); } } diff --git a/src/Type/Php/DateIntervalFormatFunctionReturnTypeExtension.php b/src/Type/Php/DateIntervalFormatFunctionReturnTypeExtension.php new file mode 100644 index 00000000000..3b1de714ad5 --- /dev/null +++ b/src/Type/Php/DateIntervalFormatFunctionReturnTypeExtension.php @@ -0,0 +1,40 @@ +getName() === 'date_interval_format'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + if (count($args) < 2) { + return null; + } + + return $this->helper->getType( + $scope->getType($args[1]->value), + $scope->getType($args[0]->value), + $scope, + ); + } + +} diff --git a/src/Type/Php/DateIntervalFormatReturnTypeHelper.php b/src/Type/Php/DateIntervalFormatReturnTypeHelper.php new file mode 100644 index 00000000000..f354b4b26b0 --- /dev/null +++ b/src/Type/Php/DateIntervalFormatReturnTypeHelper.php @@ -0,0 +1,81 @@ +getConstantStrings(); + if (count($constantStrings) === 0) { + if ($formatType->isNonEmptyString()->yes()) { + return new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]); + } + + return null; + } + + $daysIsInt = $intervalType->hasInstanceProperty('days')->yes() + && (new IntegerType())->isSuperTypeOf($intervalType->getInstanceProperty('days', $scope)->getReadableType())->yes(); + + $dateInterval = new DateInterval('P0D'); + $diffDateInterval = $daysIsInt + ? (new DateTime('2000-01-01'))->diff(new DateTime('2000-01-01')) + : null; + + $possibleReturnTypes = []; + foreach ($constantStrings as $string) { + $formatString = $string->getValue(); + + $useDiffInterval = $diffDateInterval !== null && str_contains($formatString, '%a'); + $value = ($useDiffInterval ? $diffDateInterval : $dateInterval)->format($formatString); + + $accessories = []; + if (is_numeric($value)) { + $accessories[] = new AccessoryNumericStringType(); + } + if ($value !== '0' && $value !== '' && !($formatString === '%a' && !$useDiffInterval)) { + $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/bug-1452.php b/tests/PHPStan/Analyser/nsrt/bug-1452.php index 75e4676bddc..8e01ed8888a 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-1452.php +++ b/tests/PHPStan/Analyser/nsrt/bug-1452.php @@ -1,12 +1,44 @@ -diff(new \DateTimeImmutable('now')); +function doFoo(): void { + $dateInterval = (new DateTimeImmutable('now -60 minutes'))->diff(new DateTimeImmutable('now')); + assertType('lowercase-string&non-empty-string&numeric-string&uppercase-string', $dateInterval->format('%a')); + assertType('float|int', $dateInterval->format('%a') * 60); +} -assertType( - 'lowercase-string&non-empty-string', - $dateInterval->format('%a') -); +function doBar(DateTime $a, DateTime $b): void { + $interval = $a->diff($b); + assertType('lowercase-string&non-empty-string&numeric-string&uppercase-string', $interval->format('%a')); + assertType('lowercase-string&non-falsy-string&numeric-string&uppercase-string', $interval->format('%R%a')); +} + +function doBaz(DateTimeInterface $a, DateTimeInterface $b): void { + $interval = $a->diff($b); + assertType('lowercase-string&non-empty-string&numeric-string&uppercase-string', $interval->format('%a')); +} + +function doDateDiff(DateTime $a, DateTime $b): void { + $interval = date_diff($a, $b); + assertType('lowercase-string&non-empty-string&numeric-string&uppercase-string', $interval->format('%a')); +} + +function doPlainInterval(DateInterval $interval): void { + assertType('lowercase-string&non-empty-string', $interval->format('%a')); +} + +function doDateIntervalFormat(DateTime $a, DateTime $b): void { + $interval = date_diff($a, $b); + assertType('lowercase-string&non-empty-string&numeric-string&uppercase-string', date_interval_format($interval, '%a')); +} + +function doDateIntervalFormatPlain(DateInterval $interval): void { + assertType('lowercase-string&non-empty-string', date_interval_format($interval, '%a')); +} From 242da8e329ca279a61fcd2ed6ec71bbaad87afe8 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 16 May 2026 12:35:02 +0000 Subject: [PATCH 2/5] Simplify DateIntervalFormatReturnTypeHelper to use a single DateInterval Use a single $dateInterval selected based on $daysIsInt instead of maintaining two separate intervals and checking str_contains for %a. Co-Authored-By: Claude Opus 4.6 --- src/Type/Php/DateIntervalFormatReturnTypeHelper.php | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Type/Php/DateIntervalFormatReturnTypeHelper.php b/src/Type/Php/DateIntervalFormatReturnTypeHelper.php index f354b4b26b0..0b0cfccd9e3 100644 --- a/src/Type/Php/DateIntervalFormatReturnTypeHelper.php +++ b/src/Type/Php/DateIntervalFormatReturnTypeHelper.php @@ -18,7 +18,6 @@ use PHPStan\Type\TypeCombinator; use function count; use function is_numeric; -use function str_contains; use function strtolower; use function strtoupper; @@ -40,23 +39,20 @@ public function getType(Type $formatType, Type $intervalType, Scope $scope): ?Ty $daysIsInt = $intervalType->hasInstanceProperty('days')->yes() && (new IntegerType())->isSuperTypeOf($intervalType->getInstanceProperty('days', $scope)->getReadableType())->yes(); - $dateInterval = new DateInterval('P0D'); - $diffDateInterval = $daysIsInt + $dateInterval = $daysIsInt ? (new DateTime('2000-01-01'))->diff(new DateTime('2000-01-01')) - : null; + : new DateInterval('P0D'); $possibleReturnTypes = []; foreach ($constantStrings as $string) { $formatString = $string->getValue(); - - $useDiffInterval = $diffDateInterval !== null && str_contains($formatString, '%a'); - $value = ($useDiffInterval ? $diffDateInterval : $dateInterval)->format($formatString); + $value = $dateInterval->format($formatString); $accessories = []; if (is_numeric($value)) { $accessories[] = new AccessoryNumericStringType(); } - if ($value !== '0' && $value !== '' && !($formatString === '%a' && !$useDiffInterval)) { + if ($value !== '0' && $value !== '' && !($formatString === '%a' && !$daysIsInt)) { $accessories[] = new AccessoryNonFalsyStringType(); } elseif ($value !== '') { $accessories[] = new AccessoryNonEmptyStringType(); From af759155daaf46ecb6c57a321c717330b29c0abf Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 16 May 2026 12:52:59 +0000 Subject: [PATCH 3/5] Simplify boolean condition using De Morgan's law Co-Authored-By: Claude Opus 4.6 --- src/Type/Php/DateIntervalFormatReturnTypeHelper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/Php/DateIntervalFormatReturnTypeHelper.php b/src/Type/Php/DateIntervalFormatReturnTypeHelper.php index 0b0cfccd9e3..ea6a5eb651a 100644 --- a/src/Type/Php/DateIntervalFormatReturnTypeHelper.php +++ b/src/Type/Php/DateIntervalFormatReturnTypeHelper.php @@ -52,7 +52,7 @@ public function getType(Type $formatType, Type $intervalType, Scope $scope): ?Ty if (is_numeric($value)) { $accessories[] = new AccessoryNumericStringType(); } - if ($value !== '0' && $value !== '' && !($formatString === '%a' && !$daysIsInt)) { + if ($value !== '0' && $value !== '' && ($formatString !== '%a' || $daysIsInt)) { $accessories[] = new AccessoryNonFalsyStringType(); } elseif ($value !== '') { $accessories[] = new AccessoryNonEmptyStringType(); From af487e4ecb0cef25029fa8d2bba033db5c6b3bfe Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 17 May 2026 09:13:49 +0000 Subject: [PATCH 4/5] Use isInteger() instead of isSuperTypeOf(new IntegerType()) Co-Authored-By: Claude Opus 4.6 --- src/Type/Php/DateIntervalFormatReturnTypeHelper.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Type/Php/DateIntervalFormatReturnTypeHelper.php b/src/Type/Php/DateIntervalFormatReturnTypeHelper.php index ea6a5eb651a..10b512d34ec 100644 --- a/src/Type/Php/DateIntervalFormatReturnTypeHelper.php +++ b/src/Type/Php/DateIntervalFormatReturnTypeHelper.php @@ -11,7 +11,6 @@ use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\Accessory\AccessoryNumericStringType; use PHPStan\Type\Accessory\AccessoryUppercaseStringType; -use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -37,7 +36,7 @@ public function getType(Type $formatType, Type $intervalType, Scope $scope): ?Ty } $daysIsInt = $intervalType->hasInstanceProperty('days')->yes() - && (new IntegerType())->isSuperTypeOf($intervalType->getInstanceProperty('days', $scope)->getReadableType())->yes(); + && $intervalType->getInstanceProperty('days', $scope)->getReadableType()->isInteger()->yes(); $dateInterval = $daysIsInt ? (new DateTime('2000-01-01'))->diff(new DateTime('2000-01-01')) From bbbccd2eba3daecf56d76a4f0fc369cd2c7f945a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 17 May 2026 13:12:10 +0000 Subject: [PATCH 5/5] Add rule test verifying no binary operation error for DateInterval::format('%a') * 60 Co-Authored-By: Claude Opus 4.6 --- .../Rules/Operators/InvalidBinaryOperationRuleTest.php | 5 +++++ tests/PHPStan/Rules/Operators/data/bug-1452.php | 10 ++++++++++ 2 files changed, 15 insertions(+) create mode 100644 tests/PHPStan/Rules/Operators/data/bug-1452.php diff --git a/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php b/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php index ea25383fde3..771080edd3b 100644 --- a/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php +++ b/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php @@ -853,4 +853,9 @@ public function testBug10349(): void ]); } + public function testBug1452(): void + { + $this->analyse([__DIR__ . '/data/bug-1452.php'], []); + } + } 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 00000000000..283c988ac9c --- /dev/null +++ b/tests/PHPStan/Rules/Operators/data/bug-1452.php @@ -0,0 +1,10 @@ +diff(new DateTimeImmutable('now')); + $minutes = $dateInterval->format('%a') * 60; +}