diff --git a/src/Rules/Properties/AccessPropertiesCheck.php b/src/Rules/Properties/AccessPropertiesCheck.php index b1e0514e8e3..ce6717addfc 100644 --- a/src/Rules/Properties/AccessPropertiesCheck.php +++ b/src/Rules/Properties/AccessPropertiesCheck.php @@ -140,6 +140,10 @@ private function processSingleProperty(Scope $scope, PropertyFetch $node, string return []; } + if ($type->getObjectClassNames() === []) { + return []; + } + $maybePropertyReflection = $this->pickProperty($scope, $type, $name); if ($maybePropertyReflection !== null && $maybePropertyReflection->isDummy()->no()) { return []; 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/AnalyserWithCheckDynamicPropertiesTest.php b/tests/PHPStan/Analyser/AnalyserWithCheckDynamicPropertiesTest.php index b6a160de932..5538225e419 100644 --- a/tests/PHPStan/Analyser/AnalyserWithCheckDynamicPropertiesTest.php +++ b/tests/PHPStan/Analyser/AnalyserWithCheckDynamicPropertiesTest.php @@ -12,9 +12,7 @@ class AnalyserWithCheckDynamicPropertiesTest extends PHPStanTestCase public function testBug13529(): void { $errors = $this->runAnalyse(__DIR__ . '/data/bug-13529.php'); - $this->assertCount(1, $errors); - $this->assertSame('Access to an undefined property object::$bar.', $errors[0]->getMessage()); - $this->assertSame(8, $errors[0]->getLine()); + $this->assertCount(0, $errors); } /** 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; +} diff --git a/tests/PHPStan/Rules/Properties/AccessPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/AccessPropertiesRuleTest.php index eeda2fb9222..dd60c3f9d48 100644 --- a/tests/PHPStan/Rules/Properties/AccessPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/AccessPropertiesRuleTest.php @@ -1272,4 +1272,26 @@ public function testBug13537(): void $this->analyse([__DIR__ . '/data/bug-13537.php'], $errors); } + public function testBug12390(): void + { + $this->checkThisOnly = false; + $this->checkUnionTypes = true; + $this->checkDynamicProperties = true; + $this->analyse([__DIR__ . '/data/bug-12390.php'], []); + } + + public function testBug13539(): void + { + $this->checkThisOnly = false; + $this->checkUnionTypes = true; + $this->checkDynamicProperties = true; + $this->analyse([__DIR__ . '/data/bug-13539.php'], [ + [ + 'Access to an undefined property object::$baz.', + 29, + 'Learn more: https://phpstan.org/blog/solving-phpstan-access-to-undefined-property', + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-12390.php b/tests/PHPStan/Rules/Properties/data/bug-12390.php new file mode 100644 index 00000000000..fd4ba67afda --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-12390.php @@ -0,0 +1,22 @@ +foo)) { + } +} + +function testChainedIssetOnGenericObject(object $obj): void +{ + if (isset($obj->foo) && isset($obj->bar)) { + } +} + +function testIssetAfterIsObjectNarrowing(string $date): void +{ + if (is_object($obj = json_decode($date))) { + if (isset($obj->crashId) && is_string($obj->crashId)) { + var_dump($obj->crashId); + } + } +} diff --git a/tests/PHPStan/Rules/Properties/data/bug-13539.php b/tests/PHPStan/Rules/Properties/data/bug-13539.php new file mode 100644 index 00000000000..845c927a48c --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-13539.php @@ -0,0 +1,31 @@ +foo) || !isset($tmp->bar)) { + } +} + +function works(string $x): void { + $tmp = json_decode($x, false); + if (!isset($tmp->foo, $tmp->bar)) { + } +} + +function works_too(string $x): void { + /** @var stdClass $tmp */ + $tmp = json_decode($x, false); + if (!isset($tmp->foo) || !isset($tmp->bar)) { + } +} + +/** + * @param mixed $tmp + */ +function also_ok($tmp): void { + if (isset($tmp->foo) && isset($tmp->bar)) { + echo $tmp->foo; + echo $tmp->bar; + echo $tmp->baz; // intentional: baz not checked by isset + } +}