Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -1692,19 +1692,19 @@ parameters:
-
rawMessage: 'Doing instanceof PHPStan\Type\ArrayType is error-prone and deprecated. Use Type::isArray() or Type::getArrays() instead.'
identifier: phpstanApi.instanceofType
count: 5
count: 7
path: src/Type/TypeCombinator.php

-
rawMessage: 'Doing instanceof PHPStan\Type\CallableType is error-prone and deprecated. Use Type::isCallable() and Type::getCallableParametersAcceptors() instead.'
identifier: phpstanApi.instanceofType
count: 1
count: 5
path: src/Type/TypeCombinator.php

-
rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantArrayType is error-prone and deprecated. Use Type::getConstantArrays() instead.'
identifier: phpstanApi.instanceofType
count: 19
count: 21
path: src/Type/TypeCombinator.php

-
Expand Down
2 changes: 1 addition & 1 deletion src/PhpDoc/TypeNodeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ private function resolveIdentifierTypeNode(IdentifierTypeNode $typeNode, NameSco
return new IntersectionType([new ObjectWithoutClassType(), new CallableType()]);

case 'callable-array':
return new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]);
return TypeCombinator::intersect(new ArrayType(new MixedType(), new MixedType()), new CallableType());

case 'never':
case 'noreturn':
Expand Down
23 changes: 0 additions & 23 deletions src/Type/IntersectionType.php
Original file line number Diff line number Diff line change
Expand Up @@ -945,14 +945,6 @@ public function hasOffsetValueType(Type $offsetType): TrinaryLogic

$result = $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasOffsetValueType($offsetType));

if (!$result->yes() && $this->isCallable()->yes() && $this->isArray()->yes()) {
$arrayKeyOffsetType = $offsetType->toArrayKey();
$callableArrayOffsetType = new UnionType([new ConstantIntegerType(0), new ConstantIntegerType(1)]);
if ($callableArrayOffsetType->isSuperTypeOf($arrayKeyOffsetType)->yes()) {
return TrinaryLogic::createYes();
}
}

return $result;
}

Expand All @@ -963,21 +955,6 @@ public function getOffsetValueType(Type $offsetType): Type
return TypeUtils::toBenevolentUnion($result);
}

if ($this->isCallable()->yes() && $this->isArray()->yes()) {
$arrayKeyOffsetType = $offsetType->toArrayKey();
$callableArrayOffsetType = new UnionType([new ConstantIntegerType(0), new ConstantIntegerType(1)]);
if ($callableArrayOffsetType->isSuperTypeOf($arrayKeyOffsetType)->yes()) {
if ((new ConstantIntegerType(0))->isSuperTypeOf($arrayKeyOffsetType)->yes()) {
$narrowedType = new UnionType([new ClassStringType(), new ObjectWithoutClassType()]);
} elseif ((new ConstantIntegerType(1))->isSuperTypeOf($arrayKeyOffsetType)->yes()) {
$narrowedType = new StringType();
} else {
$narrowedType = new UnionType([new StringType(), new ObjectWithoutClassType()]);
}
$result = TypeCombinator::intersect($result, $narrowedType);
}
}

return $result;
}

Expand Down
91 changes: 91 additions & 0 deletions src/Type/TypeCombinator.php
Original file line number Diff line number Diff line change
Expand Up @@ -1683,6 +1683,68 @@ public static function intersect(Type ...$types): Type
continue;
}

if (
$types[$i] instanceof ArrayType
&& get_class($types[$i]) === ArrayType::class
&& $types[$j] instanceof CallableType
) {
$existingValueType = $types[$i]->getItemType();
$offset0ValueType = self::intersect($existingValueType, new UnionType([new ClassStringType(), new ObjectWithoutClassType()]));
$offset1ValueType = self::intersect($existingValueType, new StringType());
if ($offset0ValueType instanceof NeverType || $offset1ValueType instanceof NeverType) {
return new NeverType();
}
$types[$i] = new ConstantArrayType(
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
[$offset0ValueType, $offset1ValueType],
[2],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we really know at this point that $types[$i] did not have more offsets than 0/1?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed. Here's what was done to address staabm's review:

The concern: For the ArrayType + CallableType narrowing, does the code correctly account for all type information when replacing an ArrayType with a 2-element ConstantArrayType?

The answer: A plain ArrayType doesn't have specific offsets (that's ConstantArrayType), but it does carry a key type. Since callable arrays always require integer keys 0 and 1, the original code wasn't checking whether the key type was compatible with integers. For example, array<string, mixed> & callable should be never because string keys are incompatible with the integer keys a callable array requires.

Changes:

  1. Extracted narrowArrayTypeWithCallable() — moved the inline narrowing logic into a dedicated method (mirroring narrowConstantArrayWithCallable) for clarity
  2. Added key type check — verifies $arrayType->getKeyType()->isSuperTypeOf(new IntegerType()) before narrowing; returns NeverType if the key type can't include integers
  3. Added testarray<string, mixed> + is_callable() now correctly resolves to *NEVER*

All 11,942 tests pass and make phpstan reports no errors.

isList: TrinaryLogic::createYes(),
);
continue;
}

if (
$types[$j] instanceof ArrayType
&& get_class($types[$j]) === ArrayType::class
&& $types[$i] instanceof CallableType
) {
$existingValueType = $types[$j]->getItemType();
$offset0ValueType = self::intersect($existingValueType, new UnionType([new ClassStringType(), new ObjectWithoutClassType()]));
$offset1ValueType = self::intersect($existingValueType, new StringType());
if ($offset0ValueType instanceof NeverType || $offset1ValueType instanceof NeverType) {
return new NeverType();
}
$types[$j] = new ConstantArrayType(
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
[$offset0ValueType, $offset1ValueType],
[2],
isList: TrinaryLogic::createYes(),
);
continue;
}

if (
$types[$i] instanceof ConstantArrayType
&& $types[$j] instanceof CallableType
) {
$types[$i] = self::narrowConstantArrayWithCallable($types[$i]);
if ($types[$i] instanceof NeverType) {
return new NeverType();
}
continue;
}

if (
$types[$j] instanceof ConstantArrayType
&& $types[$i] instanceof CallableType
) {
$types[$j] = self::narrowConstantArrayWithCallable($types[$j]);
if ($types[$j] instanceof NeverType) {
return new NeverType();
}
continue;
}

continue;
}

Expand Down Expand Up @@ -1779,4 +1841,33 @@ public static function removeTruthy(Type $type): Type
return self::remove($type, StaticTypeFactory::truthy());
}

private static function narrowConstantArrayWithCallable(ConstantArrayType $constantArray): Type
{
$keyTypes = $constantArray->getKeyTypes();
$valueTypes = $constantArray->getValueTypes();
$newValueTypes = $valueTypes;

foreach ($keyTypes as $k => $keyType) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we might want to immediately return NeverType when we have less than 2 key-types, so we don't create new ConstantArrayType for "too small/empty" constant arrays

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The change adds an early NeverType return in narrowConstantArrayWithCallable when the constant array has fewer than 2 keys or doesn't contain both offsets 0 and 1 — avoiding unnecessary ConstantArrayType construction for arrays that can't be callable. All tests pass and phpstan reports no errors.

if ((new ConstantIntegerType(0))->isSuperTypeOf($keyType)->yes()) {
$newValueTypes[$k] = self::intersect($valueTypes[$k], new UnionType([new ClassStringType(), new ObjectWithoutClassType()]));
if ($newValueTypes[$k] instanceof NeverType) {
return new NeverType();
}
} elseif ((new ConstantIntegerType(1))->isSuperTypeOf($keyType)->yes()) {
$newValueTypes[$k] = self::intersect($valueTypes[$k], new StringType());
if ($newValueTypes[$k] instanceof NeverType) {
return new NeverType();
}
}
}

return new ConstantArrayType(
$keyTypes,
$newValueTypes,
$constantArray->getNextAutoIndexes(),
$constantArray->getOptionalKeys(),
$constantArray->isList(),
);
}

}
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/bug-12393.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ class CallableArray {

public function doFoo(callable $foo): void {
$this->foo = $foo;
assertType('array', $this->foo); // could be non-empty-array
assertType('array<mixed>', $this->foo); // could be non-empty-array
}
}

Expand Down
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/bug-12393b.php
Original file line number Diff line number Diff line change
Expand Up @@ -669,7 +669,7 @@ class CallableArray {

public function doFoo(callable $foo): void {
$this->foo = $foo;
assertType('array', $this->foo); // could be non-empty-array
assertType('array<mixed>', $this->foo); // could be non-empty-array
}
}

Expand Down
62 changes: 62 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14549.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php declare(strict_types = 1);

namespace Bug14549;

use function PHPStan\Testing\assertType;

class Foo
{
public function foo(array $task): void
{
if (\is_callable($task)) {
assertType('list{class-string|object, string}&callable(): mixed', $task);
assertType('class-string|object', $task[0]);
assertType('string', $task[1]);

foreach ($task as $key => $value) {
assertType('object|string', $value);
assertType('0|1', $key);
}
}
}

public function testCallableArrayIterableTypes(callable $value): void
{
if (is_array($value)) {
assertType('list{class-string|object, string}&callable(): mixed', $value);

foreach ($value as $key => $val) {
assertType('0|1', $key);
assertType('object|string', $val);
}
}
}

/** @param array{string, string} $task */
public function testConstantArrayNarrowing(array $task): void
{
if (\is_callable($task)) {
assertType('list{class-string, string}&callable(): mixed', $task);
assertType('class-string', $task[0]);
assertType('string', $task[1]);
}
}

/** @param array<string> $task */
public function testTypedArrayNarrowing(array $task): void
{
if (\is_callable($task)) {
// When value type is string, intersect with class-string|object gives class-string
// and intersect with string gives string
assertType('list{class-string, string}&callable(): mixed', $task);
}
}

/** @param callable-array $value */
public function testCallableArrayPhpDoc(array $value): void
{
assertType('list{class-string|object, string}&callable(): mixed', $value);
assertType('class-string|object', $value[0]);
assertType('string', $value[1]);
}
}
4 changes: 2 additions & 2 deletions tests/PHPStan/Analyser/nsrt/bug-3842.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ public function callback(): void

function testIsArrayOnCallable(callable $value): void {
if (is_array($value)) {
assertType('array<mixed, mixed>&callable(): mixed', $value);
assertType('list{class-string|object, string}&callable(): mixed', $value);
assertType('class-string|object', $value[0]);
assertType('string', $value[1]);
}
}

/** @param callable-array $value */
function testCallableArrayPhpDoc(array $value): void {
assertType('array&callable(): mixed', $value);
assertType('list{class-string|object, string}&callable(): mixed', $value);
assertType('class-string|object', $value[0]);
assertType('string', $value[1]);
}
Expand Down
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/more-types.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public function doFoo(
): void
{
assertType('pure-callable(): mixed', $pureCallable);
assertType('array&callable(): mixed', $callableArray);
assertType('list{class-string|object, string}&callable(): mixed', $callableArray);
assertType('resource', $closedResource);
assertType('resource', $openResource);
assertType('class-string<UnitEnum>', $enumString);
Expand Down
13 changes: 13 additions & 0 deletions tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4017,4 +4017,17 @@ public function testBug13272(): void
$this->analyse([__DIR__ . '/data/bug-13272.php'], []);
}

public function testBug14549(): void
{
$this->checkThisOnly = false;
$this->checkNullables = true;
$this->checkUnionTypes = true;
$this->analyse([__DIR__ . '/data/bug-14549.php'], [
[
'Parameter #1 $task of method Bug14549Rule\Foo::call() expects array<int>, callable&list<object|string> given.',
10,
],
]);
}

}
20 changes: 20 additions & 0 deletions tests/PHPStan/Rules/Methods/data/bug-14549.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php declare(strict_types = 1);

namespace Bug14549Rule;

class Foo
{
public function foo(array $task): void
{
if (\is_callable($task)) {
$this->call($task);
}
}

/**
* @param array<int> $task
*/
public function call(array $task): void
{
}
}
Loading