Skip to content
39 changes: 39 additions & 0 deletions src/Analyser/TypeSpecifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,7 @@ public function specifyTypesInCondition(
if ($context->false()) {
$leftTypesForHolders = $leftTypes;
$rightTypesForHolders = $rightTypes;
// In a mixed truthy-and-false context, re-derive empty holders from the falsey narrowing.
if ($context->truthy()) {
if ($leftTypesForHolders->getSureTypes() === [] && $leftTypesForHolders->getSureNotTypes() === []) {
$leftTypesForHolders = $this->specifyTypesInCondition($scope, $expr->left, TypeSpecifierContext::createFalsey())->setRootExpr($expr);
Expand All @@ -735,6 +736,20 @@ public function specifyTypesInCondition(
$rightTypesForHolders = $this->specifyTypesInCondition($rightScope, $expr->right, TypeSpecifierContext::createFalsey())->setRootExpr($expr);
}
}
// For arms still empty (e.g. isset() on an array dim fetch), derive conditions
// from the truthy narrowing instead, swapping sure/sureNot types.
if ($leftTypesForHolders->getSureTypes() === [] && $leftTypesForHolders->getSureNotTypes() === []) {
$truthyLeftTypes = $this->specifyTypesInCondition($scope, $expr->left, TypeSpecifierContext::createTruthy());
if ($this->allExpressionsTrackable($truthyLeftTypes)) {
$leftTypesForHolders = new SpecifiedTypes($truthyLeftTypes->getSureNotTypes(), $truthyLeftTypes->getSureTypes());
}
}
if ($rightTypesForHolders->getSureTypes() === [] && $rightTypesForHolders->getSureNotTypes() === []) {
$truthyRightTypes = $this->specifyTypesInCondition($rightScope, $expr->right, TypeSpecifierContext::createTruthy());
if ($this->allExpressionsTrackable($truthyRightTypes)) {
$rightTypesForHolders = new SpecifiedTypes($truthyRightTypes->getSureNotTypes(), $truthyRightTypes->getSureTypes());
}
}
$result = new SpecifiedTypes(
$types->getSureTypes(),
$types->getSureNotTypes(),
Expand All @@ -747,6 +762,10 @@ public function specifyTypesInCondition(
$this->processBooleanConditionalTypes($scope, $rightTypesForHolders, false, $leftTypesForHolders, false, $scope),
$this->processBooleanConditionalTypes($scope, $leftTypesForHolders, true, $rightTypesForHolders, true, $rightScope),
$this->processBooleanConditionalTypes($scope, $rightTypesForHolders, true, $leftTypesForHolders, true, $scope),
$this->processBooleanConditionalTypes($scope, $leftTypesForHolders, false, $rightTypesForHolders, true, $rightScope),
$this->processBooleanConditionalTypes($scope, $rightTypesForHolders, false, $leftTypesForHolders, true, $scope),
$this->processBooleanConditionalTypes($scope, $leftTypesForHolders, true, $rightTypesForHolders, false, $rightScope),
$this->processBooleanConditionalTypes($scope, $rightTypesForHolders, true, $leftTypesForHolders, false, $scope),
))->setRootExpr($expr);
}

Expand Down Expand Up @@ -800,6 +819,10 @@ public function specifyTypesInCondition(
$this->processBooleanConditionalTypes($scope, $rightTypes, false, $leftTypes, false, $scope),
$this->processBooleanConditionalTypes($scope, $leftTypes, true, $rightTypes, true, $rightScope),
$this->processBooleanConditionalTypes($scope, $rightTypes, true, $leftTypes, true, $scope),
$this->processBooleanConditionalTypes($scope, $leftTypes, false, $rightTypes, true, $rightScope),
$this->processBooleanConditionalTypes($scope, $rightTypes, false, $leftTypes, true, $scope),
$this->processBooleanConditionalTypes($scope, $leftTypes, true, $rightTypes, false, $rightScope),
$this->processBooleanConditionalTypes($scope, $rightTypes, true, $leftTypes, false, $scope),
))->setRootExpr($expr);
}

Expand Down Expand Up @@ -2143,6 +2166,22 @@ private function isTrackableExpression(Expr $expr): bool
|| $expr instanceof Expr\StaticPropertyFetch;
}

private function allExpressionsTrackable(SpecifiedTypes $types): bool
{
foreach ($types->getSureTypes() as [$expr]) {
if (!$this->isTrackableExpression($expr)) {
return false;
}
}
foreach ($types->getSureNotTypes() as [$expr]) {
if (!$this->isTrackableExpression($expr)) {
return false;
}
}

return $types->getSureTypes() !== [] || $types->getSureNotTypes() !== [];
}

/**
* Flatten a deep BooleanOr chain into leaf expressions and process them
* without recursive filterByFalseyValue calls. This reduces O(n^2) to O(n)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php declare(strict_types = 1);

namespace BooleanAndConditionalHoldersMixedContext;

use function PHPStan\Testing\assertType;

// Comparing a `&&` condition with `=== true` and taking the `else` branch
// specifies the inner BooleanAnd in a mixed truthy-and-false context (both
// truthy() and false() hold). When an arm's falsey narrowing is empty there
// (e.g. isset()/array_key_exists() on an array dim fetch) the cross-kind
// conditional holders must still be derived from that arm's truthy narrowing.

/**
* @param array<string, mixed> $data
*/
function issetVarKey(array $data, string $key): void
{
if ((isset($data[$key]) && !is_string($data[$key])) === true) {
return;
}

assertType('string', $data[$key] ?? 'fallback');
}

/**
* @param array<string, mixed> $data
*/
function issetConstKey(array $data): void
{
if ((isset($data['k']) && !is_string($data['k'])) === true) {
return;
}

assertType('string', $data['k'] ?? 'fallback');
}

/**
* @param array<string, mixed> $data
*/
function arrayKeyExistsMixed(array $data): void
{
if ((array_key_exists('k', $data) && !is_string($data['k'])) === true) {
return;
}

assertType('string', $data['k'] ?? 'fallback');
}

/**
* @param mixed $y
*/
function simpleBoolMixed(bool $a, $y): void
{
if (($a && !is_string($y)) === true) {
return;
}

if ($a) {
assertType('string', $y);
}
}
127 changes: 127 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-10644.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php declare(strict_types = 1);

namespace Bug10644;

use function PHPStan\Testing\assertType;

/**
* @param array<string, mixed> $data
*/
function testIssetCoalesce(array $data): void
{
if (isset($data['subtitle']) && !is_string($data['subtitle'])) {
throw new \InvalidArgumentException('Subtitle must be a string');
}

if (isset($data['subtitle'])) {
assertType("string", $data['subtitle']);
}
assertType("string", $data['subtitle'] ?? '');
}

/**
* @param mixed $y
*/
function testSimpleBool(bool $a, $y): void
{
if ($a && !is_string($y)) {
throw new \Exception();
}

if ($a) {
assertType("string", $y);
}
assertType("mixed", $y);
}

/**
* @param mixed $y
*/
function testSimpleInt(bool $a, $y): void
{
if ($a && !is_int($y)) {
throw new \Exception();
}

if ($a) {
assertType("int", $y);
}
}

/**
* @param mixed $y
*/
function testSimpleArray(bool $a, $y): void
{
if ($a && !is_array($y)) {
throw new \Exception();
}

if ($a) {
assertType("array<mixed, mixed>", $y);
}
}

/**
* @param mixed $y
*/
function testNotNull(?int $x, $y): void
{
if ($x !== null && !is_string($y)) {
throw new \Exception();
}

if ($x !== null) {
assertType("string", $y);
}
}

/**
* @param mixed $x
* @param mixed $y
*/
function testInstanceof($x, $y): void
{
if ($x instanceof \stdClass && !is_int($y)) {
throw new \Exception();
}

if ($x instanceof \stdClass) {
assertType("int", $y);
}
}

/**
* @param array<string, mixed> $data
*/
function testIssetMultipleKeys(array $data): void
{
if (isset($data['a']) && !is_string($data['a'])) {
throw new \Exception();
}
if (isset($data['b']) && !is_int($data['b'])) {
throw new \Exception();
}

if (isset($data['a'])) {
assertType("string", $data['a']);
}
if (isset($data['b'])) {
assertType("int", $data['b']);
}
assertType("string", $data['a'] ?? '');
assertType("int", $data['b'] ?? 0);
}

/**
* @param array<string, mixed> $data
*/
function testArrayKeyExists(array $data): void
{
if (array_key_exists('subtitle', $data) && !is_string($data['subtitle'])) {
throw new \Exception();
}
if (array_key_exists('subtitle', $data)) {
assertType("string", $data['subtitle']);
}
}
18 changes: 18 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-11918.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php declare(strict_types = 1);

namespace Bug11918;

use function PHPStan\Testing\assertType;

/**
* @param array<string, list<mixed>|string|false> $options
*/
function testArrayKeyExistsCoalesce(array $options): void
{
if (array_key_exists('a', $options) && !is_string($options['a'])) {
exit(1);
}

$a = $options['a'] ?? 'fallback';
assertType('string', $a);
}
27 changes: 27 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14455.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php declare(strict_types = 1);

namespace Bug14455;

use function PHPStan\Testing\assertType;

/**
* @param array<string, mixed> $aggregation
* @param non-falsy-string $type
*/
function testTriviallyTrueConditionSkipped(array $aggregation, string $type): void
{
if (empty($aggregation['field']) && $type === 'filter') {
return;
}

assertType("array<string, mixed>", $aggregation);
assertType('non-falsy-string', $type);

if ($type === 'filter') {
assertType("non-empty-array<string, mixed>&hasOffset('field')", $aggregation);
} else {
assertType("array<string, mixed>", $aggregation);
}

assertType('non-falsy-string', $type);
}
40 changes: 40 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-3385.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php declare(strict_types = 1);

namespace Bug3385;

use function PHPStan\Testing\assertType;

class Greeter
{

public function sayHello(): string
{
return 'hello';
}

public function isEqualTo(Greeter $otherGreeter): bool
{
return $this->sayHello() === $otherGreeter->sayHello();
}

}

function isGreeterDifferent(?Greeter $greeterA, ?Greeter $greeterB): bool
{
if ($greeterA === null && $greeterB !== null) {
return true;
}

if ($greeterA !== null && $greeterB === null) {
return true;
}

if ($greeterA === null && $greeterB === null) {
return false;
}

assertType('Bug3385\Greeter', $greeterA);
assertType('Bug3385\Greeter', $greeterB);

return $greeterA->isEqualTo($greeterB);
}
21 changes: 21 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-6202.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php declare(strict_types = 1);

namespace Bug6202;

use function PHPStan\Testing\assertType;

class HelloWorld
{

/**
* @param array<string, mixed> $array
*/
public function sayHello(array $array): void
{
if (isset($array['mightExist']) && !is_string($array['mightExist'])) {
throw new \Exception('Has to be string if set');
}
assertType('string', $array['mightExist'] ?? '');
}

}
Loading
Loading