Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,10 @@ When adding or editing PHPDoc comments in this codebase, follow these guidelines
- `symfony/console` - CLI interface
- `hoa/compiler` - Used for regex type parsing

### ConstantArrayType::setOffsetValueType union expansion for finite offsets

When `ConstantArrayType::setOffsetValueType()` receives a union of constant string keys (e.g., `'a'|'b'`) or a finite `IntegerRangeType` (e.g., `int<1,5>`) and at least one key is new (not already in the array), it creates a union of constant arrays — one for each possible key — instead of degrading to a general `ArrayType`. This is controlled by `CHUNK_FINITE_TYPES_LIMIT` (5) to avoid combinatorial explosion in loops. Integer constant unions (e.g., `0|1` from loop fixpoint analysis) are excluded to prevent regression in loop analysis; only string constant unions and `IntegerRangeType` expansions are supported. The `resolveFiniteScalarKeyTypes()` helper method resolves the offset type to individual constant keys.

Comment thread
staabm marked this conversation as resolved.
Outdated
### Ternary expression type narrowing in TypeSpecifier

`TypeSpecifier::specifyTypesInCondition()` handles ternary expressions (`$cond ? $a : $b`) for type narrowing. In a truthy context (e.g., inside `assert()`), the ternary is semantically equivalent to `($cond && $a) || (!$cond && $b)` — meaning if the condition is true, the "if" branch must be truthy, and if false, the "else" branch must be truthy. The fix converts ternary expressions to this `BooleanOr(BooleanAnd(...), BooleanAnd(...))` form so the existing OR/AND narrowing logic handles both branches correctly. This enables `assert($cond ? $x instanceof A : $x instanceof B)` to narrow `$x` to `A|B`. The `AssertFunctionTypeSpecifyingExtension` calls `specifyTypesInCondition` with `TypeSpecifierContext::createTruthy()` context for the assert argument.
92 changes: 92 additions & 0 deletions src/Type/Constant/ConstantArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
use PHPStan\Type\Traits\UndecidedComparisonTypeTrait;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\TypeUtils;
use PHPStan\Type\UnionType;
use PHPStan\Type\VerbosityLevel;
use function array_keys;
Expand All @@ -61,6 +62,7 @@
use function count;
use function implode;
use function in_array;
use function is_int;
use function is_string;
use function min;
use function pow;
Expand Down Expand Up @@ -699,6 +701,35 @@ public function getOffsetValueType(Type $offsetType): Type

public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type
{
if ($offsetType !== null) {
$scalarKeyTypes = $this->resolveFiniteScalarKeyTypes($offsetType);
if ($scalarKeyTypes !== null) {
$hasNewKey = false;
foreach ($scalarKeyTypes as $scalarKeyType) {
$existingKeyFound = false;
foreach ($this->keyTypes as $existingKeyType) {
if ($existingKeyType->getValue() === $scalarKeyType->getValue()) {
$existingKeyFound = true;
break;
}
}
if (!$existingKeyFound) {
$hasNewKey = true;
break;
}
}

if ($hasNewKey) {
$arrayTypes = [];
foreach ($scalarKeyTypes as $scalarKeyType) {
$arrayTypes[] = $this->setOffsetValueType($scalarKeyType, $valueType, $unionValues);
Comment thread
staabm marked this conversation as resolved.
Outdated
}

return TypeCombinator::union(...$arrayTypes);
}
}
}

$builder = ConstantArrayTypeBuilder::createFromConstantArray($this);
$builder->setOffsetValueType($offsetType, $valueType);

Expand All @@ -713,6 +744,67 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T
return $builder->getArray();
}

/**
* @return list<ConstantIntegerType|ConstantStringType>|null
*/
private function resolveFiniteScalarKeyTypes(Type $offsetType): ?array
{
$offsetType = $offsetType->toArrayKey();

// Handle unions of constant string types (e.g. 'a'|'b')
$constantStrings = $offsetType->getConstantStrings();
if (count($constantStrings) >= 2 && count($constantStrings) <= self::CHUNK_FINITE_TYPES_LIMIT) {
$result = [];
foreach ($constantStrings as $constantString) {
$arrayKeyType = $constantString->toArrayKey();
Comment thread
staabm marked this conversation as resolved.
Outdated
$scalarValues = $arrayKeyType->getConstantScalarValues();
if (count($scalarValues) !== 1) {
return null;
}
if (is_int($scalarValues[0])) {
$result[] = new ConstantIntegerType($scalarValues[0]);
} elseif (is_string($scalarValues[0])) {
$result[] = new ConstantStringType($scalarValues[0]);
} else {
return null;
}
}
return $result;
}

// Handle integer range types (e.g. int<1,5>)
$integerRanges = TypeUtils::getIntegerRanges($offsetType);
if (count($integerRanges) > 0) {
$finiteScalarTypes = [];
foreach ($integerRanges as $integerRange) {
$finiteTypes = $integerRange->getFiniteTypes();
if ($finiteTypes === []) {
return null;
}

foreach ($finiteTypes as $finiteType) {
$finiteScalarTypes[] = $finiteType;
}
}

if (count($finiteScalarTypes) < 2 || count($finiteScalarTypes) > self::CHUNK_FINITE_TYPES_LIMIT) {
return null;
}

$result = [];
foreach ($finiteScalarTypes as $scalarType) {
$arrayKeyType = $scalarType->toArrayKey();
Comment thread
staabm marked this conversation as resolved.
Outdated
if (!$arrayKeyType instanceof ConstantIntegerType) {
return null;
}
$result[] = $arrayKeyType;
}
return $result;
}

return null;
}
Comment on lines +749 to +804
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.

Suggested change
/**
* @return list<ConstantIntegerType|ConstantStringType>|null
*/
private function resolveFiniteScalarKeyTypes(Type $offsetType): ?array
{
$offsetType = $offsetType->toArrayKey();
// Handle unions of constant string types (e.g. 'a'|'b')
$constantStrings = $offsetType->getConstantStrings();
if (count($constantStrings) >= 2 && count($constantStrings) <= self::CHUNK_FINITE_TYPES_LIMIT) {
$result = [];
foreach ($constantStrings as $constantString) {
$scalarValues = $constantString->getConstantScalarValues();
if (count($scalarValues) !== 1) {
return null;
}
if (is_int($scalarValues[0])) {
$result[] = new ConstantIntegerType($scalarValues[0]);
} elseif (is_string($scalarValues[0])) {
$result[] = new ConstantStringType($scalarValues[0]);
} else {
return null;
}
}
return $result;
}
// Handle integer range types (e.g. int<1,5>)
$integerRanges = TypeUtils::getIntegerRanges($offsetType);
if (count($integerRanges) > 0) {
$finiteScalarTypes = [];
$seen = [];
foreach ($integerRanges as $integerRange) {
$finiteTypes = $integerRange->getFiniteTypes();
if ($finiteTypes === []) {
return null;
}
foreach ($finiteTypes as $finiteType) {
if (isset($seen[$finiteType->getValue()])) {
continue;
}
$seen[$finiteType->getValue()] = true;
$finiteScalarTypes[] = $finiteType;
}
}
if (count($finiteScalarTypes) < 2 || count($finiteScalarTypes) > self::CHUNK_FINITE_TYPES_LIMIT) {
return null;
}
return $finiteScalarTypes;
}
return null;
}
/** @return array<ConstantIntegerType|ConstantStringType>|null */
private function resolveFiniteScalarKeyTypes(Type $offsetType): ?array
{
$result = [];
$offsetType = $offsetType->toArrayKey();
$constantStrings = $offsetType->getConstantStrings();
if (count($constantStrings) > 0) {
foreach ($constantStrings as $constantString) {
$scalarValues = $constantString->getConstantScalarValues();
if (count($scalarValues) !== 1) {
return null;
}
if (is_int($scalarValues[0])) {
$result[] = new ConstantIntegerType($scalarValues[0]);
} elseif (is_string($scalarValues[0])) {
$result[] = new ConstantStringType($scalarValues[0]);
} else {
return null;
}
}
} else {
$integerRanges = TypeUtils::getIntegerRanges($offsetType);
foreach ($integerRanges as $integerRange) {
$finiteTypes = $integerRange->getFiniteTypes();
if ($finiteTypes === []) {
return null;
}
foreach ($finiteTypes as $finiteType) {
$result[$finiteType->getValue()] = $finiteType;
}
}
}
if (count($result) >= 2 && count($result) <= self::CHUNK_FINITE_TYPES_LIMIT) {
return $result;
}
return null;
}


public function unsetOffset(Type $offsetType): Type
{
$offsetType = $offsetType->toArrayKey();
Expand Down
13 changes: 13 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-13000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php declare(strict_types = 1);

namespace Bug13000;

use function PHPStan\Testing\assertType;

function (): void {
$r = [];
foreach (['a' => '1', 'b' => '2'] as $key => $val) {
$r[$key] = $val;
}
assertType("array{a?: '1'|'2', b?: '1'|'2'}", $r);
};
32 changes: 32 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-13759.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php declare(strict_types = 1); // lint >= 8.0

namespace Bug13759;

use function PHPStan\Testing\assertType;

class Test
{
public function scenario(): void
{
$ints = [];
foreach (['a', 'b'] as $key) {
$ints[$key] = 1;
}
$ints['c'] = 1;

assertType("array{a?: 1, b?: 1, c: 1}", $ints);

foreach (['a'] as $key) {
$ints[$key] = $this->intToSomething($ints[$key]);
}

assertType("array{a: float|string, b?: 1, c: 1}", $ints);
}

/**
* @return string|float
*/
protected function intToSomething(int $int): string|float {
return mt_rand(1, 2) ? (string)$int : (float)$int;
}
}
15 changes: 15 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-2294.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php declare(strict_types = 1);

namespace Bug2294;

use function PHPStan\Testing\assertType;

function (): void {
$entries = ['A' => null, 'B' => null];

$entries2 = [];
foreach($entries as $key => $value) {
$entries2[$key] = ['a' => 1, 'b' => 2];
}
assertType("array{A?: array{a: 1, b: 2}, B?: array{a: 1, b: 2}}", $entries2);
};
24 changes: 24 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-7978.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php declare(strict_types = 1);

namespace Bug7978;

use function PHPStan\Testing\assertType;

class Test {

const FIELD_SETS = [
'basic' => ['username', 'password'],
'headers' => ['app_id', 'app_key'],
];

public function doSomething(): void
{
foreach (self::FIELD_SETS as $type => $fields) {
$credentials = [];
foreach ($fields as $field) {
$credentials[$field] = 'fake';
}
assertType("array{app_id?: 'fake', app_key?: 'fake', password?: 'fake', username?: 'fake'}", $credentials);
}
}
}
20 changes: 20 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-9907.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php declare(strict_types = 1);

namespace Bug9907;

use function PHPStan\Testing\assertType;

class HelloWorld
{
/**
* @param 'foo'|'bar' $key
*/
public function sayHello(string $key): void
{
$a = [];
$a['id'] = null;
$a[$key] = 'string';

assertType("array{id: null, bar: 'string'}|array{id: null, foo: 'string'}", $a);
}
}
2 changes: 1 addition & 1 deletion tests/PHPStan/Analyser/nsrt/constant-array-type-set.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ public function doBar5(int $offset): void
{
$a = [false, false, false];
$a[$offset] = true;
assertType('non-empty-array<int<0, 4>, bool>', $a);
assertType("array{0: false, 1: false, 2: false, 4: true}|array{false, false, false, true}|array{false, false, true}|array{false, true, false}|array{true, false, false}", $a);
}

public function doBar6(bool $offset): void
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

namespace SetConstantUnionOffsetOnConstantArray;

use function PHPStan\Testing\assertType;

class Foo
{

/**
* @param array{foo: int} $a
*/
public function doFoo(array $a): void
{
$k = rand(0, 1) ? 'a' : 'b';
$a[$k] = 256;
assertType('array{foo: int, a: 256}|array{foo: int, b: 256}', $a);
}

/**
* @param array{foo: int} $a
* @param int<1,5> $intRange
*/
public function doBar(array $a, $intRange): void
{
$a[$intRange] = 256;
assertType('array{foo: int, 1: 256}|array{foo: int, 2: 256}|array{foo: int, 3: 256}|array{foo: int, 4: 256}|array{foo: int, 5: 256}', $a);
}

/**
* @param array{foo: int} $a
* @param int<0, max> $intRange
*/
public function doInfiniteRange(array $a, $intRange): void
{
$a[$intRange] = 256;
assertType('non-empty-array<\'foo\'|int<0, max>, int>', $a);
}
Comment thread
staabm marked this conversation as resolved.

/**
* @param array{0: 'a', 1: 'b'} $a
* @param int<0,1> $intRange
*/
public function doExistingKeys(array $a, $intRange): void
{
$a[$intRange] = 'c';
assertType("array{'a'|'c', 'b'|'c'}", $a);
}

}
5 changes: 5 additions & 0 deletions tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1310,4 +1310,9 @@ public function testBug9669(): void
$this->analyse([__DIR__ . '/data/bug-9669.php'], []);
}

public function testBug9907(): void
{
$this->analyse([__DIR__ . '/data/bug-9907.php'], []);
}

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

namespace Bug9907Rule;

class Demo
{
/**
* @phpstan-param array{street: string, city: string} $address1
* @phpstan-param array{street: string, city: string} $address2
*
* @phpstan-return array{
* street?: array{change_to: string},
* city?: array{change_to: string},
* variation_count?: int<1, max>
* }
*/
public function diffAddresses(array $address1, array $address2): array
{
$addressDifference = array_diff_assoc($address1, $address2);
$differenceDetails = [];

foreach ($addressDifference as $name => $differenceValue) {
$differenceDetails[$name] = [
'change_to' => $differenceValue,
];
}

if (!empty(count($differenceDetails))) {
$differenceDetails['variation_count'] = count($differenceDetails);
}

return $differenceDetails;
}
}
Loading