Skip to content
Merged
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
7 changes: 6 additions & 1 deletion src/Type/Constant/ConstantArrayType.php
Original file line number Diff line number Diff line change
Expand Up @@ -741,7 +741,12 @@ public function unsetOffset(Type $offsetType): Type
$k++;
}

return new self($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, TrinaryLogic::createNo());
$newIsList = TrinaryLogic::createNo();
if (!$this->isList->no() && in_array($i, $this->optionalKeys, true)) {
$newIsList = TrinaryLogic::createMaybe();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not always, right? The new type is a list only if we're removing an optional key and:

  • The key is the last one
  • Or all of the keys after it are also optional

If you're going to implement this logic, add failing tests first 😊

Copy link
Copy Markdown
Contributor

@staabm staabm Feb 22, 2026

Choose a reason for hiding this comment

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

Per phpstan/phpstan#12768 even unsetting the last item makes it no longer a list because of internal pointer and append with [] will create a hole

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yeah but the point is that we're unsetting something that might not be on the array. So it might still be a list.

}

return new self($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList);
}

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

namespace Bug14177;

use function PHPStan\Testing\assertType;

class HelloWorld
{
/**
* @param list{0: string, 1: string, 2?: string, 3?: string} $b
*/
public function testList(array $b): void
{
if (array_key_exists(3, $b)) {
assertType('list{0: string, 1: string, 2?: string, 3: string}', $b);
} else {
assertType('list{0: string, 1: string, 2?: string}', $b);
}
assertType('list{0: string, 1: string, 2?: string, 3?: string}', $b);
}

/**
* @param list{0: string, 1: string, 2?: string, 3?: string} $b
*/
public function testUnset0(array $b): void
{
assertType('true', array_is_list($b));
unset($b[0]);
assertType('false', array_is_list($b));
$b[] = 'foo';
assertType('false', array_is_list($b));
}

/**
* @param list{0: string, 1: string, 2?: string, 3?: string} $b
*/
public function testUnset1(array $b): void
{
assertType('true', array_is_list($b));
unset($b[1]);
assertType('bool', array_is_list($b));
$b[] = 'foo';
assertType('false', array_is_list($b));
}

/**
* @param list{0: string, 1: string, 2?: string, 3?: string} $b
*/
public function testUnset2(array $b): void
{
assertType('true', array_is_list($b));
unset($b[2]);
assertType('bool', array_is_list($b));
$b[] = 'foo';
assertType('false', array_is_list($b));
}

/**
* @param list{0: string, 1: string, 2?: string, 3?: string} $b
*/
public function testUnset3(array $b): void
{
assertType('true', array_is_list($b));
unset($b[3]);
assertType('true', array_is_list($b));
$b[] = 'foo';
assertType('false', array_is_list($b));
}

public function placeholderToEditor(string $html): void
{
$result = preg_replace_callback(
'~\[image\\sid="(\\d+)"(?:\\shref="([^"]*)")?(?:\\sclass="([^"]*)")?\]~',
function (array $matches): string {
$id = (int) $matches[1];

assertType('list{0: non-falsy-string, 1: numeric-string, 2?: string, 3?: string}', $matches);

$replacement = sprintf(
'<img src="%s"%s/>',
$id,
array_key_exists(3, $matches) ? sprintf(' class="%s"', $matches[3]) : '',
);

assertType('list{0: non-falsy-string, 1: numeric-string, 2?: string, 3?: string}', $matches);

return array_key_exists(2, $matches) && $matches[2] !== ''
? sprintf('<a href="%s">%s</a>', $matches[2], $replacement)
: $replacement;
},
$html,
);
}

public function placeholderToEditor2(string $html): void
{
$result = preg_replace_callback(
'~\[image\\sid="(\\d+)?"(?:\\shref="([^"]*)")?(?:\\sclass="([^"]*)")?\]~',
function (array $matches): string {
$id = (int) $matches[0];

assertType('list{0: non-falsy-string, 1?: \'\'|numeric-string, 2?: string, 3?: string}', $matches);

$replacement = sprintf(
'<img src="%s"%s/>',
$id,
array_key_exists(2, $matches) ? sprintf(' class="%s"', $matches[2]) : '',
);

assertType('list{0: non-falsy-string, 1?: \'\'|numeric-string, 2?: string, 3?: string}', $matches);

return array_key_exists(1, $matches) && $matches[1] !== ''
? sprintf('<a href="%s">%s</a>', $matches[1], $replacement)
: $replacement;
},
$html,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1145,4 +1145,10 @@ public function testPr4375(): void
$this->analyse([__DIR__ . '/data/pr-4375.php'], []);
}

public function testBug14177(): void
{
$this->treatPhpDocTypesAsCertain = true;
$this->analyse([__DIR__ . '/data/bug-14177.php'], []);
}

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

namespace Bug14177;

class HelloWorld
{
public function placeholderToEditor(string $html): void
{
$result = preg_replace_callback(
'~\[image\\sid="(\\d+)"(?:\\shref="([^"]*)")?(?:\\sclass="([^"]*)")?]~',
function (array $matches): string {
$id = (int) $matches[1];

$replacement = sprintf(
'<img src="%s"%s/>',
$id,
array_key_exists(3, $matches) ? sprintf(' class="%s"', $matches[3]) : '',
);

return array_key_exists(2, $matches) && $matches[2] !== ''
? sprintf('<a href="%s">%s</a>', $matches[2], $replacement)
: $replacement;
},
$html,
);
}
}
Loading