Skip to content
91 changes: 89 additions & 2 deletions src/Analyser/TypeSpecifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -719,7 +719,14 @@ public function specifyTypesInCondition(
$leftTypes = $this->specifyTypesInCondition($scope, $expr->left, $context)->setRootExpr($expr);
$rightScope = $scope->filterByTruthyValue($expr->left);
$rightTypes = $this->specifyTypesInCondition($rightScope, $expr->right, $context)->setRootExpr($expr);
$types = $context->true() ? $leftTypes->unionWith($rightTypes) : $leftTypes->normalize($scope)->intersectWith($rightTypes->normalize($rightScope));
if ($context->true()) {
$types = $leftTypes->unionWith($rightTypes);
} else {
$leftNormalized = $leftTypes->normalize($scope);
$rightNormalized = $rightTypes->normalize($rightScope);
$types = $leftNormalized->intersectWith($rightNormalized);
$types = $this->augmentDisjunctionTypes($scope, $rightScope, $leftNormalized, $rightNormalized, $expr->left, $expr->right, false, $types);
}
if ($context->false()) {
$leftTypesForHolders = $leftTypes;
$rightTypesForHolders = $rightTypes;
Expand Down Expand Up @@ -773,8 +780,11 @@ public function specifyTypesInCondition(
) {
$types = $leftTypes->normalize($scope);
} else {
$types = $leftTypes->normalize($scope)->intersectWith($rightTypes->normalize($rightScope));
$leftNormalized = $leftTypes->normalize($scope);
$rightNormalized = $rightTypes->normalize($rightScope);
$types = $leftNormalized->intersectWith($rightNormalized);
$types = $this->augmentBooleanOrTruthyWithConditionalHolders($scope, $rightScope, $expr, $types);
$types = $this->augmentDisjunctionTypes($scope, $rightScope, $leftNormalized, $rightNormalized, $expr->left, $expr->right, true, $types);
}
} else {
$types = $leftTypes->unionWith($rightTypes);
Expand Down Expand Up @@ -2061,6 +2071,83 @@ private function augmentBooleanOrTruthyWithConditionalHolders(MutatingScope $sco
return $types;
}

private function augmentDisjunctionTypes(
MutatingScope $scope,
MutatingScope $rightScope,
SpecifiedTypes $leftNormalized,
SpecifiedTypes $rightNormalized,
Expr $leftExpr,
Expr $rightExpr,
bool $truthy,
SpecifiedTypes $types,
): SpecifiedTypes
{
$candidateExprs = [];
foreach ($leftNormalized->getSureTypes() as $exprString => [$exprNode, $type]) {
$candidateExprs[$exprString] = $exprNode;
}
foreach ($rightNormalized->getSureTypes() as $exprString => [$exprNode, $type]) {
$candidateExprs[$exprString] = $exprNode;
Comment thread
VincentLanglet marked this conversation as resolved.
}

$existingSureTypes = $types->getSureTypes();

$viableCandidates = [];
foreach ($candidateExprs as $exprString => $targetExpr) {
if (isset($existingSureTypes[$exprString])) {
continue;
}
if (!$scope->hasExpressionType($targetExpr)->yes()) {
continue;
}
$viableCandidates[$exprString] = $targetExpr;
}

if ($viableCandidates === []) {
return $types;
}

if ($truthy) {
$leftFilteredScope = $scope->filterByTruthyValue($leftExpr);
$rightFilteredScope = $rightScope->filterByTruthyValue($rightExpr);
} else {
$leftFilteredScope = $scope->filterByFalseyValue($leftExpr);
$rightFilteredScope = $rightScope->filterByFalseyValue($rightExpr);
}

foreach ($viableCandidates as $targetExpr) {
if (!$leftFilteredScope->hasExpressionType($targetExpr)->yes()) {
continue;
}
if (!$rightFilteredScope->hasExpressionType($targetExpr)->yes()) {
continue;
}

$originalType = $scope->getType($targetExpr);
$leftType = $leftFilteredScope->getType($targetExpr);
$rightType = $rightFilteredScope->getType($targetExpr);

if ($leftType->equals($originalType) || !$originalType->isSuperTypeOf($leftType)->yes()) {
continue;
}

if ($rightType->equals($originalType) || !$originalType->isSuperTypeOf($rightType)->yes()) {
continue;
}

$unionType = TypeCombinator::union($leftType, $rightType);
if ($unionType->equals($originalType)) {
continue;
}

$types = $types->unionWith(
$this->create($targetExpr, $unionType, TypeSpecifierContext::createTrue(), $scope),
);
}

return $types;
}

/**
* @return array<string, ConditionalExpressionHolder[]>
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@
if ((new \PHPStan\Tests\AssertionClass())->assertString($foo) && \PHPStan\Tests\AssertionClass::assertInt($bar)) {
}

assertType('string|null', $foo);
assertType('string', $foo);
assertType('int|null', $bar);
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@
if ((new \PHPStan\Tests\AssertionClass())->assertString($foo) && \PHPStan\Tests\AssertionClass::assertInt($bar)) {
}

assertType('string|null', $foo);
assertType('string', $foo);
assertType('int|null', $bar);
68 changes: 68 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-13061.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php declare(strict_types = 1);

namespace Bug13061;

use function PHPStan\Testing\assertType;

interface ScenarioInterface {}

class ScenarioNode implements ScenarioInterface {}
class OutlineNode implements ScenarioInterface {}

class FeatureNode
{
/**
* @param ScenarioInterface[] $scenarios
*/
public function __construct(
public readonly ?string $title,
public readonly array $scenarios,
) {
}
}

/**
* @phpstan-type TFeatureHash array{title?: string|null, scenarios?: array<int, TScenarioHash|TOutlineHash>}
* @phpstan-type TScenarioHash array{type?: 'scenario', title?: string|null}
* @phpstan-type TOutlineHash array{type: 'outline', title?: string|null, examples?: array<array-key, TExampleTableHash>}
* @phpstan-type TExampleTableHash array<int, list<string>>
*/
abstract class GherkinArrayLoader
{
/**
* @phpstan-param TFeatureHash $hash
*/
protected function loadFeatureHash(array $hash, int $line = 0): FeatureNode
{
$hash = array_merge(
[
'title' => null,
'scenarios' => [],
],
$hash
);

$scenarios = [];
foreach ((array) $hash['scenarios'] as $scenarioIterator => $scenarioHash) {
if (isset($scenarioHash['type']) && $scenarioHash['type'] === 'outline') {
assertType("array{type: 'outline', title?: string|null, examples?: array<array<int, list<string>>>}", $scenarioHash);
$scenarios[] = $this->loadOutlineHash($scenarioHash, $scenarioIterator);
} else {
assertType("array{type?: 'scenario', title?: string|null}", $scenarioHash);
$scenarios[] = $this->loadScenarioHash($scenarioHash, $scenarioIterator);
}
}

return new FeatureNode($hash['title'], $scenarios);
}

/**
* @phpstan-param TScenarioHash $hash
*/
abstract protected function loadScenarioHash(array $hash, int $line = 0): ScenarioNode;

/**
* @phpstan-param TOutlineHash $hash
*/
abstract protected function loadOutlineHash(array $hash, int $line = 0): OutlineNode;
}
98 changes: 98 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14566.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php declare(strict_types = 1);

namespace Bug14566;

use function PHPStan\Testing\assertType;

/**
* @param array{}|array{hi: 'hello'}|array{hi: array{0: 42, 1?: 42}} $test
*/
function fooNestedIfs(array $test): void {
if (isset($test['hi'])) {
if (is_string($test['hi'])) {
return;
}
}
assertType("array{}|array{hi: array{0: 42, 1?: 42}}", $test);
}

/**
* @param array{}|array{hi: 'hello'}|array{hi: array{0: 42, 1?: 42}} $test
*/
function fooCombinedAnd(array $test): void {
if (isset($test['hi']) && is_string($test['hi'])) {
return;
}
assertType("array{}|array{hi: array{0: 42, 1?: 42}}", $test);
}

/**
* @param array{}|array{hi: 'hello'}|array{hi: array{0: 42, 1?: 42}} $test
*/
function fooCombinedAndAssign(array $test): void {
if (isset($test['hi']) && is_string($test['hi'])) {
return;
}
$test['hi'][] = 42;
}

/**
* @param array{}|array{hi: 'hello'}|array{hi: array{0: 42, 1?: 42}} $test
*/
function fooBooleanOrDual(array $test): void {
if (!isset($test['hi']) || !is_string($test['hi'])) {
assertType("array{}|array{hi: array{0: 42, 1?: 42}}", $test);
return;
}
assertType("array{hi: 'hello'}", $test);
}

/**
* @param array{}|array{hi: 42}|array{hi: array{0: 42, 1?: 42}} $testIsArray
*/
function fooIsArray(array $testIsArray): void {
if (isset($testIsArray['hi']) && is_array($testIsArray['hi'])) {
return;
}
assertType("array{}|array{hi: 42}", $testIsArray);
}

/**
* @param array{}|array{hi: 42}|array{hi: 'hello'} $testIsInt
*/
function fooIsInt(array $testIsInt): void {
if (isset($testIsInt['hi']) && is_int($testIsInt['hi'])) {
return;
}
assertType("array{}|array{hi: 'hello'}", $testIsInt);
}

/**
* @param array{}|array{hi: 42}|array{hi: 1.5} $testIsFloat
*/
function fooIsFloat(array $testIsFloat): void {
if (isset($testIsFloat['hi']) && is_float($testIsFloat['hi'])) {
return;
}
assertType("array{}|array{hi: 42}", $testIsFloat);
}

/**
* @param array{}|array{hi: true}|array{hi: 'hello'} $testIsBool
*/
function fooIsBool(array $testIsBool): void {
if (isset($testIsBool['hi']) && is_bool($testIsBool['hi'])) {
return;
}
assertType("array{}|array{hi: 'hello'}", $testIsBool);
}

/**
* @param array{}|array{val: 'hello'}|array{val: array{0: 42}} $testArrayKeyExists
*/
function fooArrayKeyExists(array $testArrayKeyExists): void {
if (array_key_exists('val', $testArrayKeyExists) && is_string($testArrayKeyExists['val'])) {
return;
}
assertType("array{}|array{val: array{42}}", $testArrayKeyExists);
}
73 changes: 73 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-7259.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php declare(strict_types = 1);

namespace Bug7259;

use function PHPStan\Testing\assertType;

class HelloWorldNullable
{
public function __construct(
private ?\DateTimeImmutable $from,
private ?\DateTimeImmutable $till,
)
{
$newFrom = $this->from;
$newTill = $this->till;

if ($newFrom !== null || $newTill !== null) {
if ($newFrom !== null && $newTill === null) {
$newFrom = $newFrom->setTime(0, 0);
$newTill = new \DateTimeImmutable('2300-12-31 23:59:59');
}

if ($newTill !== null && $newFrom === null) {
$newTill = $newTill->setTime(23, 59, 59, 999999);
$newFrom = new \DateTimeImmutable('1970-01-01 00:00:00');
}

assertType('DateTimeImmutable', $newFrom);
assertType('DateTimeImmutable', $newTill);
$this->checkDates($newFrom, $newTill);
}
}

private function checkDates(
\DateTimeImmutable $from,
\DateTimeImmutable $till,
): void
{
}
}

class HelloWorldStringInt
{
public function __construct(
private string|int $from,
private string|int $till,
)
{
$newFrom = $this->from;
$newTill = $this->till;

if (is_string($newFrom) || is_string($newTill)) {
if (is_string($newFrom) && is_string($newTill) === false) {
$newTill = 'test';
}

if (is_string($newTill) && is_string($newFrom) === false) {
$newFrom = 'test2';
}

assertType('string', $newFrom);
assertType('string', $newTill);
$this->checkDates($newFrom, $newTill);
}
}

private function checkDates(
string $from,
string $till,
): void
{
}
}
5 changes: 4 additions & 1 deletion tests/PHPStan/Rules/Classes/ImpossibleInstanceOfRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,10 @@ public function testBug3632(): void
[
'Instanceof between Bug3632\NiceClass and Bug3632\NiceClass will always evaluate to true.',
36,
$tipText,
],
[
'Instanceof between null and Bug3632\NiceClass will always evaluate to false.',
36,
],
]);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,6 @@ public function testBug11903(): void
[
'Negated boolean expression is always true.',
21,
'Because the type is coming from a PHPDoc, you can turn off this check by setting <fg=cyan>treatPhpDocTypesAsCertain: false</> in your <fg=cyan>%configurationFile%</>.',
],
]);
}
Expand Down
Loading