diff --git a/conf/config.neon b/conf/config.neon index 84a957bf87..4789d17753 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -1193,6 +1193,20 @@ services: tags: - phpstan.broker.dynamicFunctionReturnTypeExtension + - + class: PHPStan\Type\Php\DateTimeModifyReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + arguments: + dateTimeClass: DateTime + + - + class: PHPStan\Type\Php\DateTimeModifyReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + arguments: + dateTimeClass: DateTimeImmutable + - class: PHPStan\Type\Php\DateTimeConstructorThrowTypeExtension tags: diff --git a/resources/functionMap.php b/resources/functionMap.php index 86638947e2..be4268e791 100644 --- a/resources/functionMap.php +++ b/resources/functionMap.php @@ -1604,7 +1604,7 @@ 'DateTime::getOffset' => ['int'], 'DateTime::getTimestamp' => ['int'], 'DateTime::getTimezone' => ['DateTimeZone'], -'DateTime::modify' => ['static', 'modify'=>'string'], +'DateTime::modify' => ['static|false', 'modify'=>'string'], 'DateTime::setDate' => ['static', 'year'=>'int', 'month'=>'int', 'day'=>'int'], 'DateTime::setISODate' => ['static', 'year'=>'int', 'week'=>'int', 'day='=>'int'], 'DateTime::setTime' => ['static', 'hour'=>'int', 'minute'=>'int', 'second='=>'int', 'microseconds='=>'int'], @@ -1623,7 +1623,7 @@ 'DateTimeImmutable::getOffset' => ['int'], 'DateTimeImmutable::getTimestamp' => ['int'], 'DateTimeImmutable::getTimezone' => ['DateTimeZone'], -'DateTimeImmutable::modify' => ['static', 'modify'=>'string'], +'DateTimeImmutable::modify' => ['static|false', 'modify'=>'string'], 'DateTimeImmutable::setDate' => ['static', 'year'=>'int', 'month'=>'int', 'day'=>'int'], 'DateTimeImmutable::setISODate' => ['static', 'year'=>'int', 'week'=>'int', 'day='=>'int'], 'DateTimeImmutable::setTime' => ['static', 'hour'=>'int', 'minute'=>'int', 'second='=>'int', 'microseconds='=>'int'], diff --git a/src/Type/Php/DateTimeModifyReturnTypeExtension.php b/src/Type/Php/DateTimeModifyReturnTypeExtension.php new file mode 100644 index 0000000000..a6dbb4133d --- /dev/null +++ b/src/Type/Php/DateTimeModifyReturnTypeExtension.php @@ -0,0 +1,79 @@ + */ + private $dateTimeClass; + + /** @param class-string $dateTimeClass */ + public function __construct(string $dateTimeClass = DateTime::class) + { + $this->dateTimeClass = $dateTimeClass; + } + + public function getClass(): string + { + return $this->dateTimeClass; + } + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'modify'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type + { + $defaultReturnType = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); + if (count($methodCall->getArgs()) < 1) { + return $defaultReturnType; + } + + $valueType = $scope->getType($methodCall->getArgs()[0]->value); + $constantStrings = TypeUtils::getConstantStrings($valueType); + + $hasFalse = false; + $hasDateTime = false; + + foreach ($constantStrings as $constantString) { + if (@(new DateTime())->modify($constantString->getValue()) === false) { + $hasFalse = true; + } else { + $hasDateTime = true; + } + + $valueType = TypeCombinator::remove($valueType, $constantString); + } + + if (!$valueType instanceof NeverType) { + return $defaultReturnType; + } + + if ($hasFalse && !$hasDateTime) { + return new ConstantBooleanType(false); + } + if ($hasDateTime && !$hasFalse) { + return new StaticType($methodReflection->getDeclaringClass()); + } + + return $defaultReturnType; + } + +} diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index 008456768f..1fe124010f 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -435,6 +435,7 @@ public function dataFileAsserts(): iterable yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-2906.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/DateTimeDynamicReturnTypes.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/DateTimeModifyReturnTypes.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-4821.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-4838.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-4879.php'); diff --git a/tests/PHPStan/Analyser/data/DateTimeModifyReturnTypes.php b/tests/PHPStan/Analyser/data/DateTimeModifyReturnTypes.php new file mode 100644 index 0000000000..578010cf17 --- /dev/null +++ b/tests/PHPStan/Analyser/data/DateTimeModifyReturnTypes.php @@ -0,0 +1,39 @@ +modify($modify)); + assertType('DateTimeImmutable|false', $dateTimeImmutable->modify($modify)); + } + + /** + * @param '+1 day'|'+2 day' $modify + */ + public function modifyWithValidConstant(DateTime $datetime, DateTimeImmutable $dateTimeImmutable, string $modify): void { + assertType('DateTime', $datetime->modify($modify)); + assertType('DateTimeImmutable', $dateTimeImmutable->modify($modify)); + } + + /** + * @param 'kewk'|'koko' $modify + */ + public function modifyWithInvalidConstant(DateTime $datetime, DateTimeImmutable $dateTimeImmutable, string $modify): void { + assertType('false', $datetime->modify($modify)); + assertType('false', $dateTimeImmutable->modify($modify)); + } + + /** + * @param '+1 day'|'koko' $modify + */ + public function modifyWithBothConstant(DateTime $datetime, DateTimeImmutable $dateTimeImmutable, string $modify): void { + assertType('DateTime|false', $datetime->modify($modify)); + assertType('DateTimeImmutable|false', $dateTimeImmutable->modify($modify)); + } + +}