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..10b512d34ec --- /dev/null +++ b/src/Type/Php/DateIntervalFormatReturnTypeHelper.php @@ -0,0 +1,76 @@ +getConstantStrings(); + if (count($constantStrings) === 0) { + if ($formatType->isNonEmptyString()->yes()) { + return new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]); + } + + return null; + } + + $daysIsInt = $intervalType->hasInstanceProperty('days')->yes() + && $intervalType->getInstanceProperty('days', $scope)->getReadableType()->isInteger()->yes(); + + $dateInterval = $daysIsInt + ? (new DateTime('2000-01-01'))->diff(new DateTime('2000-01-01')) + : 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' || $daysIsInt)) { + $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')); +} 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; +}