Skip to content

Commit b5379a5

Browse files
ondrejmirtesVincentLanglet
authored andcommitted
Fix array_key_exists false positive on list types with optional trailing keys
When `ConstantArrayType::unsetOffset()` removed an optional trailing key from a list type, it unconditionally set `isList` to `No`. This caused `TypeCombinator::remove()` to produce `NeverType` when re-intersecting with `AccessoryArrayListType`, since the list accessor rejects non-list arrays. The `NeverType` falsy branch then collapsed scope merging after conditionals like ternaries, propagating the narrowed (truthy) type beyond the conditional and triggering false "always true" reports on subsequent `array_key_exists()` calls. The fix preserves the original `isList` value when unsetting an optional key whose removal leaves the remaining keys as consecutive integers starting from 0. Fixes phpstan/phpstan#14177
1 parent f018b6e commit b5379a5

File tree

4 files changed

+69
-1
lines changed

4 files changed

+69
-1
lines changed

src/Type/Constant/ConstantArrayType.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -741,7 +741,21 @@ public function unsetOffset(Type $offsetType): Type
741741
$k++;
742742
}
743743

744-
return new self($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, TrinaryLogic::createNo());
744+
$newIsList = TrinaryLogic::createNo();
745+
if (!$this->isList->no() && in_array($i, $this->optionalKeys, true)) {
746+
$preserveIsList = true;
747+
foreach ($newKeyTypes as $k2 => $newKeyType2) {
748+
if (!$newKeyType2 instanceof ConstantIntegerType || $newKeyType2->getValue() !== $k2) {
749+
$preserveIsList = false;
750+
break;
751+
}
752+
}
753+
if ($preserveIsList) {
754+
$newIsList = $this->isList;
755+
}
756+
}
757+
758+
return new self($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList);
745759
}
746760

747761
return $this;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug14177;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class HelloWorld
8+
{
9+
/**
10+
* @param list{0: string, 1: string, 2?: string, 3?: string} $b
11+
*/
12+
public function testList(array $b): void
13+
{
14+
if (array_key_exists(3, $b)) {
15+
assertType('list{0: string, 1: string, 2?: string, 3: string}', $b);
16+
} else {
17+
assertType('list{0: string, 1: string, 2?: string}', $b);
18+
}
19+
assertType('list{0: string, 1: string, 2?: string, 3?: string}', $b);
20+
}
21+
}

tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1145,4 +1145,10 @@ public function testPr4375(): void
11451145
$this->analyse([__DIR__ . '/data/pr-4375.php'], []);
11461146
}
11471147

1148+
public function testBug14177(): void
1149+
{
1150+
$this->treatPhpDocTypesAsCertain = true;
1151+
$this->analyse([__DIR__ . '/data/bug-14177.php'], []);
1152+
}
1153+
11481154
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug14177;
4+
5+
class HelloWorld
6+
{
7+
public function placeholderToEditor(string $html): void
8+
{
9+
$result = preg_replace_callback(
10+
'~\[image\\sid="(\\d+)"(?:\\shref="([^"]*)")?(?:\\sclass="([^"]*)")?]~',
11+
function (array $matches): string {
12+
$id = (int) $matches[1];
13+
14+
$replacement = sprintf(
15+
'<img src="%s"%s/>',
16+
$id,
17+
array_key_exists(3, $matches) ? sprintf(' class="%s"', $matches[3]) : '',
18+
);
19+
20+
return array_key_exists(2, $matches) && $matches[2] !== ''
21+
? sprintf('<a href="%s">%s</a>', $matches[2], $replacement)
22+
: $replacement;
23+
},
24+
$html,
25+
);
26+
}
27+
}

0 commit comments

Comments
 (0)