diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index af13bc1ee3a..ec45a86ca65 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -309,7 +309,20 @@ public function isSuperTypeOf(Type $otherType): IsSuperTypeOfResult return IsSuperTypeOfResult::createYes(); } - return IsSuperTypeOfResult::createYes()->and(...array_map(static fn (Type $innerType) => $innerType->isSuperTypeOf($otherType), $this->types)); + $result = IsSuperTypeOfResult::createYes()->and(...array_map(static fn (Type $innerType) => $innerType->isSuperTypeOf($otherType), $this->types)); + if ($result->no() && $this->isList()->yes() && $otherType->isArray()->yes() && $otherType->isList()->no()) { + // `[]` is a list, so a list and a possibly-empty array whose keys + // cannot form a list are at worst `maybe`, never `no`. + $emptyArray = new ConstantArrayType([], []); + if ( + IsSuperTypeOfResult::createYes()->and(...array_map(static fn (Type $innerType) => $innerType->isSuperTypeOf($emptyArray), $this->types))->yes() + && $otherType->isSuperTypeOf($emptyArray)->yes() + ) { + return IsSuperTypeOfResult::createMaybe(); + } + } + + return $result; } public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 1c375aae070..dfaf8418f62 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -2374,6 +2374,19 @@ public static function doIntersect(Type ...$types): Type } if ($isSuperTypeA->no()) { + $emptyArray = new ConstantArrayType([], []); + if ( + $types[$i]->isArray()->yes() + && $types[$j]->isArray()->yes() + && ($types[$i]->isList()->no() || $types[$j]->isList()->no()) + && count(array_filter($types, static fn (Type $type): bool => $type instanceof AccessoryArrayListType)) > 0 + && $types[$i]->isSuperTypeOf($emptyArray)->yes() + && $types[$j]->isSuperTypeOf($emptyArray)->yes() + ) { + // `[]` is the only list a possibly-empty array whose keys cannot form a list admits + return self::intersect($emptyArray, ...$types); + } + return new NeverType(reason: $isSuperTypeA->getReasons()[0] ?? null); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14177.php b/tests/PHPStan/Analyser/nsrt/bug-14177.php index 26ef94e5577..2d2bed298b7 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14177.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14177.php @@ -198,7 +198,7 @@ public function testUnsetInt(array $a, array $b, array $c, int $int): void unset($a[$int]); unset($b[$int]); assertType('bool', array_is_list($a)); - assertType('false', array_is_list($b)); + assertType('bool', array_is_list($b)); } /** diff --git a/tests/PHPStan/Analyser/nsrt/bug-15450.php b/tests/PHPStan/Analyser/nsrt/bug-15450.php new file mode 100644 index 00000000000..39c9ce1bbd9 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15450.php @@ -0,0 +1,35 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug15450; + +use function PHPStan\Testing\assertType; + +/** @param array $v */ +function isList(array $v): void +{ + if (array_is_list($v)) { + assertType('array{}', $v); + } else { + assertType('non-empty-array', $v); + } +} + +/** @param array $v */ +function isEmpty(array $v): void +{ + if ($v === []) { + assertType('array{}', $v); + } +} + +/** @param non-empty-array $v */ +function isNonEmptyList(array $v): void +{ + if (array_is_list($v)) { + assertType('*NEVER*', $v); + } else { + assertType('non-empty-array', $v); + } +} diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index efc9757e99b..8b77a2d7014 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -389,11 +389,6 @@ public function testBug2714(): void public function testArrayIsList(): void { $this->analyse([__DIR__ . '/data/array-is-list.php'], [ - [ - 'Call to function array_is_list() with array will always evaluate to false.', - 13, - 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', - ], [ 'Call to function array_is_list() with array{foo: \'bar\', bar: \'baz\'} will always evaluate to false.', 40, @@ -402,6 +397,11 @@ public function testArrayIsList(): void 'Call to function array_is_list() with array{0: \'foo\', foo: \'bar\', bar: \'baz\'} will always evaluate to false.', 44, ], + [ + 'Call to function array_is_list() with non-empty-array will always evaluate to false.', + 54, + 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', + ], ]); } diff --git a/tests/PHPStan/Rules/Comparison/data/array-is-list.php b/tests/PHPStan/Rules/Comparison/data/array-is-list.php index f812dc9a06e..f66b02d5554 100644 --- a/tests/PHPStan/Rules/Comparison/data/array-is-list.php +++ b/tests/PHPStan/Rules/Comparison/data/array-is-list.php @@ -45,4 +45,14 @@ public function doBax() // Fine } } + + /** + * @param non-empty-array $nonEmptyStringKeyedArray + */ + public function doQux(array $nonEmptyStringKeyedArray) + { + if (array_is_list($nonEmptyStringKeyedArray)) { + + } + } } diff --git a/turbo-ext/src/IntersectionType.cpp b/turbo-ext/src/IntersectionType.cpp index bdeb4a1755b..7e54fecd4a2 100644 --- a/turbo-ext/src/IntersectionType.cpp +++ b/turbo-ext/src/IntersectionType.cpp @@ -211,6 +211,15 @@ static zv::Val integerRange(NullableLong min, NullableLong max) * new BooleanType() / new ObjectWithoutClassType() / new ClassStringType() */ static zv::Val constantInteger(zend_long value) { return pt_type_new_constant_integer(value); } +/* new ConstantArrayType([], []) */ +static zv::Val emptyConstantArray() +{ + zval empty, result; + ZVAL_EMPTY_ARRAY(&empty); + if (UNEXPECTED(!pt_constant_array_type_new(&result, &empty, &empty))) return zv::Val(); + return zv::Val::adopt(result); +} + static zv::Val stringType() { return pt_val_of(); @@ -577,7 +586,8 @@ class IntersectionType } /* yes for an equal intersection or a never, else yes and'ed with every - * member's isSuperTypeOf(); UNDEF = pending exception */ + * member's isSuperTypeOf() — at worst maybe for a list and a non-list + * array when both sides admit `[]`; UNDEF = pending exception */ zv::Val isSuperTypeOf(zval *otherType) const { if (zv::Ref(otherType).instanceOf(pt_ce_intersection_type)) { @@ -591,7 +601,41 @@ class IntersectionType if (UNEXPECTED(results.isUndef())) return zv::Val(); zv::Val yes = pt_type_is_super_type_of_result(PT_TRI_YES); if (UNEXPECTED(yes.isUndef())) return zv::Val(); - return pt_is_super_type_of_result_spread(Z_OBJ_P(yes.raw()), true, zv::ArrRef(results.raw()).table()); + zv::Val result = pt_is_super_type_of_result_spread(Z_OBJ_P(yes.raw()), true, zv::ArrRef(results.raw()).table()); + if (UNEXPECTED(result.isUndef())) return zv::Val(); + zend_long value = pt_type_result_trinary(result.raw()); + if (UNEXPECTED(value < 0)) return zv::Val(); + if (value != PT_TRI_NO) return result; + + zend_long isList = thisTrinary(PT_LC("islist"), &IntersectionType::isList); + if (UNEXPECTED(isList < 0)) return zv::Val(); + if (isList != PT_TRI_YES) return result; + zend_long otherIsArray = pt_type_call_trinary(Z_OBJ_P(otherType), PT_LC("isarray"), 0, NULL); + if (UNEXPECTED(otherIsArray < 0)) return zv::Val(); + if (otherIsArray != PT_TRI_YES) return result; + zend_long otherIsList = pt_type_op_trinary(Z_OBJ_P(otherType), PT_OP_IS_LIST, 0, NULL); + if (UNEXPECTED(otherIsList < 0)) return zv::Val(); + if (otherIsList != PT_TRI_NO) return result; + + /* `[]` is a list, so a list and a possibly-empty array whose keys + * cannot form a list are at worst `maybe`, never `no` */ + zv::Val emptyArray = emptyConstantArray(); + if (UNEXPECTED(emptyArray.isUndef())) return zv::Val(); + zv::Val emptyResults = mapCall(PT_LC("issupertypeof"), 1, emptyArray.raw()); + if (UNEXPECTED(emptyResults.isUndef())) return zv::Val(); + zv::Val admitsEmpty = pt_is_super_type_of_result_spread(Z_OBJ_P(yes.raw()), true, zv::ArrRef(emptyResults.raw()).table()); + if (UNEXPECTED(admitsEmpty.isUndef())) return zv::Val(); + zend_long admitsEmptyValue = pt_type_result_trinary(admitsEmpty.raw()); + if (UNEXPECTED(admitsEmptyValue < 0)) return zv::Val(); + if (admitsEmptyValue != PT_TRI_YES) return result; + + zv::Val otherAdmitsEmpty = pt_type_call(Z_OBJ_P(otherType), PT_LC("issupertypeof"), 1, emptyArray.raw()); + if (UNEXPECTED(otherAdmitsEmpty.isUndef())) return zv::Val(); + zend_long otherAdmitsEmptyValue = pt_type_result_trinary(otherAdmitsEmpty.raw()); + if (UNEXPECTED(otherAdmitsEmptyValue < 0)) return zv::Val(); + if (otherAdmitsEmptyValue != PT_TRI_YES) return result; + + return pt_type_is_super_type_of_result(PT_TRI_MAYBE); } /* $otherType->isSuperTypeOf($this) for a compound other type, else diff --git a/turbo-ext/src/TypeCombinator.cpp b/turbo-ext/src/TypeCombinator.cpp index f6c1afd51e3..ee3ca093295 100644 --- a/turbo-ext/src/TypeCombinator.cpp +++ b/turbo-ext/src/TypeCombinator.cpp @@ -394,6 +394,14 @@ static zv::Val oversizedArrayType() return adopted(pt_oversized_array_type_new(&raw), raw); } +/* new ConstantArrayType([], []) */ +static zv::Val emptyConstantArrayType() +{ + zval empty, raw; + ZVAL_EMPTY_ARRAY(&empty); + return adopted(pt_constant_array_type_new(&raw, &empty, &empty), raw); +} + static zv::Val accessoryLowercaseStringType() { zval raw; @@ -3111,6 +3119,46 @@ class TypeCombinator return call(type, PT_LC("issupertypeof"), 1, other); } + /* `[]` is the only list a possibly-empty array whose keys cannot form a + * list admits: new ConstantArrayType([], []) when $a->isArray()->yes() + * && $b->isArray()->yes() && ($a->isList()->no() || $b->isList()->no()) + * && $types holds an AccessoryArrayListType and both $a and $b are super + * types of it, else null; UNDEF = pending exception */ + static zv::Val onlyEmptyListAdmitted(TypeList &types, zval *a, zval *b) + { + zend_long isArray = callTrinary(a, PT_LC("isarray")); + PT_FAIL_IF_NEG(isArray); + if (isArray != PT_TRI_YES) return zv::Val::null(); + isArray = callTrinary(b, PT_LC("isarray")); + PT_FAIL_IF_NEG(isArray); + if (isArray != PT_TRI_YES) return zv::Val::null(); + zend_long isList = callTrinary(a, PT_LC("islist")); + PT_FAIL_IF_NEG(isList); + if (isList != PT_TRI_NO) { + isList = callTrinary(b, PT_LC("islist")); + PT_FAIL_IF_NEG(isList); + if (isList != PT_TRI_NO) return zv::Val::null(); + } + bool hasList = false; + for (zv::Val &type : types) { + if (isInstance(type.raw(), pt_ce_accessory_array_list_type)) { + hasList = true; + break; + } + } + if (!hasList) return zv::Val::null(); + + zv::Val emptyArray = emptyConstantArrayType(); + PT_FAIL_IF_UNDEF(emptyArray); + zend_long admits = callResultTrinary(a, PT_LC("issupertypeof"), 1, emptyArray.raw()); + PT_FAIL_IF_NEG(admits); + if (admits != PT_TRI_YES) return zv::Val::null(); + admits = callResultTrinary(b, PT_LC("issupertypeof"), 1, emptyArray.raw()); + PT_FAIL_IF_NEG(admits); + if (admits != PT_TRI_YES) return zv::Val::null(); + return emptyArray; + } + /* the SubtractableType arm of doIntersect()'s reduction: whether the * subtractable's type without its subtracted type is a super type of * $other; -1 = pending exception */ @@ -3570,6 +3618,10 @@ class TypeCombinator } if (isSuperTypeAValue == PT_TRI_NO) { + zv::Val emptyArray = onlyEmptyListAdmitted(types, types[i].raw(), types[j].raw()); + PT_FAIL_IF_UNDEF(emptyArray); + if (!emptyArray.isNull()) return intersectWith(emptyArray.raw(), types); + zv::Val reasons = call(isSuperTypeA.raw(), PT_LC("getreasons")); PT_FAIL_IF_UNDEF(reasons); zval *first = indexOf(reasons.raw(), 0);