Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve array-shape list reconcilation (fixes #8046). #8050

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
71 changes: 63 additions & 8 deletions src/Psalm/Internal/Type/SimpleAssertionReconciler.php
Expand Up @@ -78,6 +78,8 @@
use function count;
use function explode;
use function get_class;
use function is_int;
use function ksort;
use function min;
use function strpos;

Expand Down Expand Up @@ -358,6 +360,7 @@ public static function reconcile(
) {
return self::reconcileList(
$assertion,
$codebase,
$existing_var_type,
$key,
$negated,
Expand Down Expand Up @@ -2000,6 +2003,7 @@ private static function reconcileArray(
*/
private static function reconcileList(
Assertion $assertion,
Codebase $codebase,
Union $existing_var_type,
?string $key,
bool $negated,
Expand All @@ -2021,20 +2025,65 @@ private static function reconcileList(
$did_remove_type = false;

foreach ($existing_var_atomic_types as $type) {
if ($type instanceof TList
|| ($type instanceof TKeyedArray && $type->is_list)
) {
if ($is_non_empty && $type instanceof TList && !$type instanceof TNonEmptyList) {
if ($type instanceof TList) {
if ($is_non_empty && !$type instanceof TNonEmptyList) {
$array_types[] = new TNonEmptyList($type->type_param);
$did_remove_type = true;
} else {
$array_types[] = $type;
}
} elseif ($type instanceof TArray || $type instanceof TKeyedArray) {
if ($type instanceof TKeyedArray) {
$type = $type->getGenericArrayType();
}
} elseif ($type instanceof TKeyedArray) {
if ($type->is_list) {
$array_types[] = $type;
} else {
$did_remove_type = true;
$type = clone $type;
$min_unset_list_key = 0; // Minimum list key not explicitly set
$min_non_optional_list_key = 0; // Minimum list key that isn't optional
ksort($type->properties);
foreach ($type->properties as $prop_key => $prop_value) {
orklah marked this conversation as resolved.
Show resolved Hide resolved
if (!is_int($prop_key)) {
if ($prop_value->possibly_undefined) {
unset($type->properties[$prop_key]);
continue;
} else {
// Can't reconcile, type is removed
continue 2;
}
} elseif ($prop_key === $min_unset_list_key) {
++$min_unset_list_key;
AndrolGenhald marked this conversation as resolved.
Show resolved Hide resolved
}
if (!$prop_value->possibly_undefined) {
$min_non_optional_list_key = $prop_key;
}
}

// If there is a non-optional key after an optional key, previous optional keys become non-optional
foreach ($type->properties as $prop_key => $prop_value) {
if (is_int($prop_key) && $prop_key < $min_non_optional_list_key) {
$prop_value->possibly_undefined = false;
}
}

// Update the key type for non-explicit properties
if ($type->previous_key_type === null) {
$type->previous_key_type = Type::getArrayKey();
}
$type->previous_key_type = Type::intersectUnionTypes(
$type->previous_key_type,
new Union([new TIntRange($min_unset_list_key, null)]),
$codebase,
);

// If there's no value type for non-explicit properties it's defaulted to mixed
if ($type->previous_value_type === null) {
$type->previous_value_type = Type::getMixed();
}

$type->is_list = true;
$array_types[] = $type;
}
} elseif ($type instanceof TArray) {
if ($type->type_params[0]->hasArrayKey()
|| $type->type_params[0]->hasInt()
) {
Expand Down Expand Up @@ -2444,6 +2493,12 @@ private static function reconcileTruthyOrNonEmpty(
$array_atomic_type->type_param
)
);
} elseif ($array_atomic_type instanceof TKeyedArray && $array_atomic_type->is_list) {
if (isset($array_atomic_type->properties[0])) {
$array_atomic_type->properties[0]->possibly_undefined = false;
} else {
$array_atomic_type->properties[0] = Type::getMixed();
}
}
}

Expand Down
67 changes: 67 additions & 0 deletions tests/AssertAnnotationTest.php
Expand Up @@ -2055,6 +2055,73 @@ function makeLowerNonEmpty(string $input): string
return $input;
}',
],
'assertListKeepsArrayShape' => [
'code' => '<?php
/**
* @param mixed $arr
* @psalm-assert-if-true list $arr
*/
function is_list($arr): bool
{
if (!is_array($arr)) {
return false;
}
$listKey = -1;
foreach ($arr as $key => $_) {
if ($key !== ++$listKey) {
return false;
}
}
return true;
}

/** @var array{0: int, 1: bool, 2: string} */
$list = [1, true, "string"];
assert(is_list($list));

function takesString(string $_str): void {}

takesString($list[2]);
',
'assertions' => ['$list===' => "array{0: int, 1: bool, 2: string}<int<3, max>, mixed>"],
AndrolGenhald marked this conversation as resolved.
Show resolved Hide resolved
],
'assertListMarksKeysAsNonOptional' => [
'code' => '<?php
/**
* @param mixed $arr
* @psalm-assert-if-true list $arr
*/
function is_list($arr): bool
{
if (!is_array($arr)) {
return false;
}
$listKey = -1;
foreach ($arr as $key => $_) {
if ($key !== ++$listKey) {
return false;
}
}
return true;
}

/** @var array{0: int, 5?: bool, 6?: string, 7: object, 8?: float} */
$list = [];
assert(is_list($list));
',
'assertions' => ['$list===' => "array{0: int, 5: bool, 6: string, 7: object, 8?: float}<int<1, max>, mixed>"],
],
'truthyArrayShapeListHas0Key' => [
'code' => '<?php
$list = random_int(0, 1) ? [] : ["foobar"];

function takesString(string $_str): void {}

if ($list) {
takesString($list[0]);
}
',
],
];
}

Expand Down