Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions src/Type/Accessory/HasOffsetValueType.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\UnionType;
use PHPStan\Type\VerbosityLevel;
use function count;
use function sprintf;
use function strtolower;
use function strtoupper;
Expand Down Expand Up @@ -283,14 +284,31 @@ public function reverseArray(TrinaryLogic $preserveKeys): Type
public function searchArray(Type $needleType, ?TrinaryLogic $strict = null): Type
{
$strict ??= TrinaryLogic::createMaybe();

$found = false;
$valueFiniteTypes = $this->valueType->getFiniteTypes();
$needleFiniteTypes = $needleType->getFiniteTypes();
if (count($valueFiniteTypes) === 1 && $needleFiniteTypes !== []) {
$found = true;
foreach ($needleFiniteTypes as $needleFiniteType) {
if (!$valueFiniteTypes[0]->isSuperTypeOf($needleFiniteType)->yes()) {
$found = false;
break;
}
}
}

if (
$needleType instanceof ConstantScalarType && $this->valueType instanceof ConstantScalarType
&& (
$needleType->getValue() === $this->valueType->getValue()
// @phpstan-ignore equal.notAllowed
|| ($strict->no() && $needleType->getValue() == $this->valueType->getValue()) // phpcs:ignore
)
!$found
&& $strict->no()
&& $needleType instanceof ConstantScalarType && $this->valueType instanceof ConstantScalarType
// @phpstan-ignore equal.notAllowed
&& $needleType->getValue() == $this->valueType->getValue() // phpcs:ignore
) {
$found = true;
}

if ($found) {
return new UnionType([
new IntegerType(),
new StringType(),
Expand Down
46 changes: 46 additions & 0 deletions src/Type/Constant/ConstantArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -1675,6 +1675,10 @@ public function searchArray(Type $needleType, ?TrinaryLogic $strict = null): Typ
}
}

if (!$hasIdenticalValue && $strict->yes() && $this->needleIsGuaranteedToBeFound($needleType)) {
$hasIdenticalValue = true;
}

if (count($matches) > 0) {
if ($hasIdenticalValue) {
return TypeCombinator::union(...$matches);
Expand All @@ -1686,6 +1690,48 @@ public function searchArray(Type $needleType, ?TrinaryLogic $strict = null): Typ
return new ConstantBooleanType(false);
}

/**
* Whether every possible value of a finite needle type is guaranteed to be
* present under a non-optional key, so a strict search always finds it.
*/
private function needleIsGuaranteedToBeFound(Type $needleType): bool
{
$needleFiniteTypes = $needleType->getFiniteTypes();
if ($needleFiniteTypes === []) {
return false;
}

$guaranteedValueTypes = [];
foreach ($this->valueTypes as $index => $valueType) {
if ($this->isOptionalKey($index)) {
continue;
}

$valueFiniteTypes = $valueType->getFiniteTypes();
if (count($valueFiniteTypes) !== 1) {
continue;
}

$guaranteedValueTypes[] = $valueFiniteTypes[0];
}

foreach ($needleFiniteTypes as $needleFiniteType) {
$found = false;
foreach ($guaranteedValueTypes as $guaranteedValueType) {
if ($guaranteedValueType->isSuperTypeOf($needleFiniteType)->yes()) {
$found = true;
break;
}
}

if (!$found) {
return false;
}
}

return true;
}

public function shiftArray(): Type
{
return $this->removeFirstElements(1);
Expand Down
27 changes: 26 additions & 1 deletion src/Type/IntersectionType.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
use PHPStan\Type\Accessory\NonEmptyArrayType;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
use PHPStan\Type\Constant\ConstantBooleanType;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\Enum\EnumCaseObjectType;
Expand Down Expand Up @@ -1215,7 +1216,31 @@

public function searchArray(Type $needleType, ?TrinaryLogic $strict = null): Type
{
return $this->intersectTypes(static fn (Type $type): Type => $type->searchArray($needleType, $strict));
$result = $this->intersectTypes(static fn (Type $type): Type => $type->searchArray($needleType, $strict));

if (
$strict !== null
&& $strict->yes()

Check warning on line 1223 in src/Type/IntersectionType.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ if ( $strict !== null - && $strict->yes() + && !$strict->no() && $this->isIterableAtLeastOnce()->yes() ) { $valueFiniteTypes = $this->getIterableValueType()->getFiniteTypes();

Check warning on line 1223 in src/Type/IntersectionType.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ if ( $strict !== null - && $strict->yes() + && !$strict->no() && $this->isIterableAtLeastOnce()->yes() ) { $valueFiniteTypes = $this->getIterableValueType()->getFiniteTypes();
&& $this->isIterableAtLeastOnce()->yes()

Check warning on line 1224 in src/Type/IntersectionType.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ if ( $strict !== null && $strict->yes() - && $this->isIterableAtLeastOnce()->yes() + && !$this->isIterableAtLeastOnce()->no() ) { $valueFiniteTypes = $this->getIterableValueType()->getFiniteTypes(); $needleFiniteTypes = $needleType->getFiniteTypes();

Check warning on line 1224 in src/Type/IntersectionType.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ if ( $strict !== null && $strict->yes() - && $this->isIterableAtLeastOnce()->yes() + && !$this->isIterableAtLeastOnce()->no() ) { $valueFiniteTypes = $this->getIterableValueType()->getFiniteTypes(); $needleFiniteTypes = $needleType->getFiniteTypes();
) {
$valueFiniteTypes = $this->getIterableValueType()->getFiniteTypes();
$needleFiniteTypes = $needleType->getFiniteTypes();
if (count($valueFiniteTypes) === 1 && $needleFiniteTypes !== []) {
$allFound = true;
foreach ($needleFiniteTypes as $needleFiniteType) {
if (!$valueFiniteTypes[0]->isSuperTypeOf($needleFiniteType)->yes()) {

Check warning on line 1231 in src/Type/IntersectionType.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ if (count($valueFiniteTypes) === 1 && $needleFiniteTypes !== []) { $allFound = true; foreach ($needleFiniteTypes as $needleFiniteType) { - if (!$valueFiniteTypes[0]->isSuperTypeOf($needleFiniteType)->yes()) { + if ($valueFiniteTypes[0]->isSuperTypeOf($needleFiniteType)->no()) { $allFound = false; break; }

Check warning on line 1231 in src/Type/IntersectionType.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ if (count($valueFiniteTypes) === 1 && $needleFiniteTypes !== []) { $allFound = true; foreach ($needleFiniteTypes as $needleFiniteType) { - if (!$valueFiniteTypes[0]->isSuperTypeOf($needleFiniteType)->yes()) { + if ($valueFiniteTypes[0]->isSuperTypeOf($needleFiniteType)->no()) { $allFound = false; break; }
$allFound = false;
break;
}
}

if ($allFound) {
$result = TypeCombinator::remove($result, new ConstantBooleanType(false));
}
}
}

return $result;
}

public function shiftArray(): Type
Expand Down
127 changes: 127 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14877.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php // lint >= 8.1

declare(strict_types = 1);

namespace Bug14877;

use function PHPStan\Testing\assertType;
use function array_search;

enum Suit: string
{

case Hearts = 'H';
case Spades = 'S';
case Clubs = 'C';

}

class HelloWorld
{

/**
* @param 'a'|'b'|'c' $full
* @param 'a'|'b' $subset
* @param 'a'|'x' $partial
*/
public function variableHaystack(string $full, string $subset, string $partial): void
{
$a = ['a', 'b', 'c'];

assertType('0|1|2', array_search($full, $a, true));
assertType('0|1', array_search($subset, $a, true));
assertType('0|false', array_search($partial, $a, true));
}

/**
* @param 'a'|'b'|'c' $full
* @param 'a'|'b' $subset
* @param 'a'|'x' $partial
*/
public function literalHaystack(string $full, string $subset, string $partial): void
{
assertType('0|1|2', array_search($full, ['a', 'b', 'c'], true));
assertType('0|1|2|false', array_search($full, ['a', 'b', 'c'], false)); // non-strict
assertType('0|1', array_search($subset, ['a', 'b', 'c'], true));
assertType('0|false', array_search($partial, ['a', 'b', 'c'], true));
assertType('false', array_search($subset, ['x', 'y'], true));
}

/**
* @param 1|2 $full
* @param 1 $subset
*/
public function integers(int $full, int $subset): void
{
$a = [1, 2];

assertType('0|1', array_search($full, $a, true));
assertType('0', array_search($subset, $a, true));
assertType('0|1', array_search($full, [1, 2, 3], true));
}

/**
* @param Suit::Hearts|Suit::Spades $subset
*/
public function enums(Suit $subset): void
{
$a = [Suit::Hearts, Suit::Spades, Suit::Clubs];

assertType('0|1', array_search($subset, $a, true));
assertType('0|1', array_search($subset, [Suit::Hearts, Suit::Spades, Suit::Clubs], true));
assertType('0|false', array_search($subset, [Suit::Hearts], true));
}

/**
* Plain objects do not have a finite set of possible values, so array_search()
* must not drop false even when the needle's class matches every haystack value.
*/
public function objects(Article $article, ?Article $a, ?Article $b): void
{
$haystack = [$a, $b];

assertType('0|1|false', array_search($article, $haystack, true));
assertType('0|1|false', array_search($article, [$a, $b], true));
}

/**
* A general (non-constant) array only guarantees a value's presence when it is
* non-empty and all its values share a single finite type.
*
* @param 1|2 $needle
* @param array<int, 1|2> $maybeEmpty
* @param non-empty-array<int, 1|2> $nonEmptyMulti
* @param non-empty-array<int, 1> $nonEmptySingle
*/
public function generalArrays(int $needle, array $maybeEmpty, array $nonEmptyMulti, array $nonEmptySingle): void
{
assertType('int|false', array_search($needle, $maybeEmpty, true));
assertType('int|false', array_search($needle, $nonEmptyMulti, true));
assertType('int', array_search(1, $nonEmptySingle, true));
}

/**
* A known offset value (HasOffsetValueType) guarantees a strict search finds
* the needle, even when the needle is an enum case rather than a scalar.
*
* @param array<string, Suit> $arr
* @param array<string, 1|2> $ints
*/
public function knownOffsetValue(array $arr, array $ints): void
{
if (($arr['x'] ?? null) === Suit::Hearts) {
assertType('string', array_search(Suit::Hearts, $arr, true));
}

if (($ints['x'] ?? null) === 1) {
assertType('string', array_search(1, $ints, true));
assertType('string|false', array_search(2, $ints, true));
}
}

}

class Article
{

}
Original file line number Diff line number Diff line change
Expand Up @@ -1244,6 +1244,29 @@ public function testBug14791(): void
$this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14791.php'], []);
}

public function testBug14877(): void
{
$tipText = '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%</>.';

$this->analyse([__DIR__ . '/data/bug-14877.php'], [
[
'Strict comparison using !== between 0|1|2 and false will always evaluate to true.',
17,
$tipText,
],
[
'Strict comparison using !== between 0|1 and false will always evaluate to true.',
21,
$tipText,
],
[
'Strict comparison using !== between int and false will always evaluate to true.',
50,
$tipText,
],
]);
}

public function testBug14847(): void
{
$this->analyse([__DIR__ . '/data/bug-14847.php'], [
Expand Down
55 changes: 55 additions & 0 deletions tests/PHPStan/Rules/Comparison/data/bug-14877.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php declare(strict_types = 1);

namespace Bug14877Rule;

class HelloWorld
{

/**
* @param 'a'|'b'|'c' $full
* @param 'a'|'b' $subset
* @param 'a'|'x' $partial
*/
public function sayHello(string $full, string $subset, string $partial): void
{
$a = ['a', 'b', 'c'];

if (array_search($full, $a, true) !== false) {
echo 'full';
}

if (array_search($subset, $a, true) !== false) {
echo 'subset';
}

if (array_search($partial, $a, true) !== false) {
echo 'partial';
}
}

/**
* A general (non-constant) array does not guarantee that any particular value
* is present, so a subset needle must not be reported as always-found - even
* when every finite needle value appears in the array's value type.
*
* @param 1|2 $needle
* @param array<int, 1|2> $maybeEmpty
* @param non-empty-array<int, 1|2> $nonEmptyMulti
* @param non-empty-array<int, 1> $nonEmptySingle
*/
public function generalArrays(int $needle, array $maybeEmpty, array $nonEmptyMulti, array $nonEmptySingle): void
{
if (array_search($needle, $maybeEmpty, true) !== false) {
echo 'maybeEmpty';
}

if (array_search($needle, $nonEmptyMulti, true) !== false) {
echo 'nonEmptyMulti';
}

if (array_search(1, $nonEmptySingle, true) !== false) {
echo 'nonEmptySingle';
}
}

}
Loading