From 58488b8f9bff130e39e3f2387b9c23ca2f047838 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 17 Apr 2026 09:48:21 +0200 Subject: [PATCH 1/2] Improve DateInterval inference --- resources/functionMap_php83delta.php | 1 + ...CreateFromDateStringThrowTypeExtension.php | 61 +++++++++++++++++++ ...DateIntervalDynamicReturnTypeExtension.php | 44 +++++++------ .../Php/DateTimeModifyReturnTypeExtension.php | 14 ++--- tests/PHPStan/Analyser/data/bug-14479-82.php | 22 +++++++ tests/PHPStan/Analyser/data/bug-14479-83.php | 22 +++++++ tests/PHPStan/Analyser/nsrt/bug-8442-83.php | 40 ++++++++++++ tests/PHPStan/Analyser/nsrt/bug-8442.php | 3 +- .../CatchWithUnthrownExceptionRuleTest.php | 6 ++ .../Rules/Exceptions/data/bug-14479.php | 33 ++++++++++ 10 files changed, 220 insertions(+), 26 deletions(-) create mode 100644 src/Type/Php/DateIntervalCreateFromDateStringThrowTypeExtension.php create mode 100644 tests/PHPStan/Analyser/data/bug-14479-82.php create mode 100644 tests/PHPStan/Analyser/data/bug-14479-83.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-8442-83.php create mode 100644 tests/PHPStan/Rules/Exceptions/data/bug-14479.php diff --git a/resources/functionMap_php83delta.php b/resources/functionMap_php83delta.php index 43676fb0bca..d41cb14b131 100644 --- a/resources/functionMap_php83delta.php +++ b/resources/functionMap_php83delta.php @@ -21,6 +21,7 @@ */ return [ 'new' => [ + 'DateInterval::createFromDateString' => ['static', 'modify'=>'string'], 'DateTime::modify' => ['static', 'modify'=>'string'], 'DateTimeImmutable::modify' => ['static', 'modify'=>'string'], 'str_decrement' => ['non-empty-string', 'string'=>'non-empty-string'], diff --git a/src/Type/Php/DateIntervalCreateFromDateStringThrowTypeExtension.php b/src/Type/Php/DateIntervalCreateFromDateStringThrowTypeExtension.php new file mode 100644 index 00000000000..756576b5e66 --- /dev/null +++ b/src/Type/Php/DateIntervalCreateFromDateStringThrowTypeExtension.php @@ -0,0 +1,61 @@ +getName() === 'createFromDateString' + && $methodReflection->getDeclaringClass()->getName() === DateInterval::class; + } + + public function getThrowTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + if (count($methodCall->getArgs()) === 0) { + return null; + } + + if (!$this->phpVersion->hasDateTimeExceptions()) { + return null; + } + + $valueType = $scope->getType($methodCall->getArgs()[0]->value); + $constantStrings = $valueType->getConstantStrings(); + + foreach ($constantStrings as $constantString) { + try { + DateInterval::createFromDateString($constantString->getValue()); + } catch (\Exception) { // phpcs:ignore + return $methodReflection->getThrowType(); + } + + $valueType = TypeCombinator::remove($valueType, $constantString); + } + + if (!$valueType instanceof NeverType) { + return $methodReflection->getThrowType(); + } + + return null; + } + +} diff --git a/src/Type/Php/DateIntervalDynamicReturnTypeExtension.php b/src/Type/Php/DateIntervalDynamicReturnTypeExtension.php index c5f597fcdc4..bc57c6b7e24 100644 --- a/src/Type/Php/DateIntervalDynamicReturnTypeExtension.php +++ b/src/Type/Php/DateIntervalDynamicReturnTypeExtension.php @@ -6,20 +6,23 @@ use PhpParser\Node\Expr\StaticCall; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Php\PhpVersion; use PHPStan\Reflection\MethodReflection; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\DynamicStaticMethodReturnTypeExtension; +use PHPStan\Type\NeverType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use Throwable; -use function count; -use function in_array; #[AutowiredService] -final class DateIntervalDynamicReturnTypeExtension -implements DynamicStaticMethodReturnTypeExtension +final class DateIntervalDynamicReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension { + public function __construct(private PhpVersion $phpVersion) + { + } + public function getClass(): string { return DateInterval::class; @@ -40,31 +43,38 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, $strings = $scope->getType($arguments[0]->value)->getConstantStrings(); - $possibleReturnTypes = []; + $hasFalse = false; + $hasDateInterval = false; foreach ($strings as $string) { try { $result = @DateInterval::createFromDateString($string->getValue()); } catch (Throwable) { - $possibleReturnTypes[] = false; - continue; + $result = false; } - $possibleReturnTypes[] = $result instanceof DateInterval ? DateInterval::class : false; - } - // the error case, when wrong types are passed - if (count($possibleReturnTypes) === 0) { - return null; + if ($result === false) { + $hasFalse = true; + } else { + $hasDateInterval = true; + } } - if (in_array(false, $possibleReturnTypes, true) && in_array(DateInterval::class, $possibleReturnTypes, true)) { + if ($hasFalse) { + if (!$hasDateInterval) { + if ($this->phpVersion->hasDateTimeExceptions()) { + return new NeverType(); + } + + return new ConstantBooleanType(false); + } + return null; } - - if (in_array(false, $possibleReturnTypes, true)) { - return new ConstantBooleanType(false); + if ($hasDateInterval) { + return new ObjectType(DateInterval::class); } - return new ObjectType(DateInterval::class); + return null; } } diff --git a/src/Type/Php/DateTimeModifyReturnTypeExtension.php b/src/Type/Php/DateTimeModifyReturnTypeExtension.php index 9b927a19780..a5a3223dc62 100644 --- a/src/Type/Php/DateTimeModifyReturnTypeExtension.php +++ b/src/Type/Php/DateTimeModifyReturnTypeExtension.php @@ -57,8 +57,7 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method try { $result = @(new DateTime())->modify($constantString->getValue()); } catch (Throwable) { - $valueType = TypeCombinator::remove($valueType, $constantString); - continue; + $result = false; } if ($result === false) { @@ -76,11 +75,16 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method if ($hasFalse) { if (!$hasDateTime) { + if ($this->phpVersion->hasDateTimeExceptions()) { + return new NeverType(); + } + return new ConstantBooleanType(false); } return null; - } elseif ($hasDateTime) { + } + if ($hasDateTime) { $callerType = $scope->getType($methodCall->var); $dateTimeInterfaceType = new ObjectType(DateTimeInterface::class); @@ -102,10 +106,6 @@ static function (Type $type, callable $traverse) use ($dateTimeInterfaceType): T ); } - if ($this->phpVersion->hasDateTimeExceptions()) { - return new NeverType(); - } - return null; } diff --git a/tests/PHPStan/Analyser/data/bug-14479-82.php b/tests/PHPStan/Analyser/data/bug-14479-82.php new file mode 100644 index 00000000000..3a5a5bc773c --- /dev/null +++ b/tests/PHPStan/Analyser/data/bug-14479-82.php @@ -0,0 +1,22 @@ += 8.3 + +namespace Bug14479Php83; + +use function PHPStan\Testing\assertType; + +function test(string $input) { + assertType('DateInterval', \DateInterval::createFromDateString($input)); +} + +function testValid() { + assertType('DateInterval', \DateInterval::createFromDateString('P1D')); +} + +function testInvalid() { + assertType('*NEVER*', \DateInterval::createFromDateString('foo')); +} + +/** @param 'P1D'|'foo' $input */ +function testUnion(string $input) { + assertType('DateInterval', \DateInterval::createFromDateString($input)); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-8442-83.php b/tests/PHPStan/Analyser/nsrt/bug-8442-83.php new file mode 100644 index 00000000000..96554d6f8bc --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-8442-83.php @@ -0,0 +1,40 @@ += 8.3 + +namespace Bug8442Php83; + +use stdClass; +use function PHPStan\Testing\assertType; +use DateInterval; + +function () { + assertType('*NEVER*', DateInterval::createFromDateString('foo')); + assertType('DateInterval', DateInterval::createFromDateString('1 Day')); + + if (rand(0,1)) { + $interval = '1 day'; + } else { + $interval = '2 day'; + } + + assertType('DateInterval', DateInterval::createFromDateString($interval)); + + if (rand(0,1)) { + $interval = 'foo'; + } else { + $interval = '2 day'; + } + + assertType('DateInterval', DateInterval::createFromDateString($interval)); + + if (rand(0,1)) { + $interval = 'foo'; + } else { + $interval = 'foo'; + } + + assertType('*NEVER*', DateInterval::createFromDateString($interval)); + + assertType('DateInterval',DateInterval::createFromDateString(str_shuffle('1 day'))); + assertType('DateInterval',DateInterval::createFromDateString()); + assertType('DateInterval',DateInterval::createFromDateString(new stdClass())); +}; diff --git a/tests/PHPStan/Analyser/nsrt/bug-8442.php b/tests/PHPStan/Analyser/nsrt/bug-8442.php index 96005d7d857..1e4761a95a0 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-8442.php +++ b/tests/PHPStan/Analyser/nsrt/bug-8442.php @@ -1,4 +1,4 @@ -= 8.3.0')] + public function testBug14479(): void + { + $this->analyse([__DIR__ . '/data/bug-14479.php'], []); + } + public function testBug5952(): void { $this->analyse([__DIR__ . '/data/bug-5952.php'], [ diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-14479.php b/tests/PHPStan/Rules/Exceptions/data/bug-14479.php new file mode 100644 index 00000000000..15bd639a419 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/bug-14479.php @@ -0,0 +1,33 @@ += 8.3 + +namespace Bug14479; + + +function test(string $input) { + try { + \DateInterval::createFromDateString($input); + } catch (\Exception $e) { + } +} + +function testValid() { + try { + \DateInterval::createFromDateString('P1D'); + } catch (\Exception $e) { + } +} + +function testInvalid() { + try { + \DateInterval::createFromDateString('foo'); + } catch (\Exception $e) { + } +} + +/** @param 'P1D'|'foo' $input */ +function testUnion(string $input) { + try { + \DateInterval::createFromDateString($input); + } catch (\Exception $e) { + } +} From 9afe071c7b2ebb31cc6effdf7e1865bd89a12f6a Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 17 Apr 2026 11:00:59 +0200 Subject: [PATCH 2/2] Fix phpstan --- build/datetime-php-83.neon | 11 ----------- build/ignore-by-php-version.neon.php | 4 ---- 2 files changed, 15 deletions(-) delete mode 100644 build/datetime-php-83.neon diff --git a/build/datetime-php-83.neon b/build/datetime-php-83.neon deleted file mode 100644 index 953379bf4f9..00000000000 --- a/build/datetime-php-83.neon +++ /dev/null @@ -1,11 +0,0 @@ -parameters: - ignoreErrors: - - - message: "#^If condition is always false\\.$#" - count: 1 - path: ../src/Type/Php/DateTimeModifyReturnTypeExtension.php - - - - message: "#^Strict comparison using \\=\\=\\= between DateTime and false will always evaluate to false\\.$#" - count: 1 - path: ../src/Type/Php/DateTimeModifyReturnTypeExtension.php diff --git a/build/ignore-by-php-version.neon.php b/build/ignore-by-php-version.neon.php index b6f92dfaae7..c5d3602e7f4 100644 --- a/build/ignore-by-php-version.neon.php +++ b/build/ignore-by-php-version.neon.php @@ -28,10 +28,6 @@ $includes[] = __DIR__ . '/spl-autoload-functions-php-8.neon'; } -if (PHP_VERSION_ID >= 80300) { - $includes[] = __DIR__ . '/datetime-php-83.neon'; -} - if (PHP_VERSION_ID >= 80400) { $includes[] = __DIR__ . '/deprecated-8.4.neon'; }