From 4b6ba04f80bd0741dd4f260dd5b3e3a19103f623 Mon Sep 17 00:00:00 2001 From: AndrolGenhald Date: Thu, 2 Jun 2022 12:22:04 -0500 Subject: [PATCH 1/4] Improve array-shape list reconcilation (fixes #8046). --- .../Type/SimpleAssertionReconciler.php | 53 ++++++++++++++++--- tests/AssertAnnotationTest.php | 21 ++++++++ 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php index 329d7def1b4..3cfdb0d25f9 100644 --- a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php +++ b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php @@ -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; @@ -358,6 +360,7 @@ public static function reconcile( ) { return self::reconcileList( $assertion, + $codebase, $existing_var_type, $key, $negated, @@ -2000,6 +2003,7 @@ private static function reconcileArray( */ private static function reconcileList( Assertion $assertion, + Codebase $codebase, Union $existing_var_type, ?string $key, bool $negated, @@ -2021,20 +2025,53 @@ 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 + ksort($type->properties); + foreach ($type->properties as $prop_key => $prop_value) { + if (!is_int($prop_key)) { + if ($prop_value->possibly_undefined) { + unset($type->properties[$prop_key]); + } else { + // Can't reconcile, type is removed + continue 2; + } + } elseif ($prop_key === $min_unset_list_key) { + ++$min_unset_list_key; + } + } + + // 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() ) { diff --git a/tests/AssertAnnotationTest.php b/tests/AssertAnnotationTest.php index e32297b4f3d..e07539bfd3c 100644 --- a/tests/AssertAnnotationTest.php +++ b/tests/AssertAnnotationTest.php @@ -2055,6 +2055,27 @@ function makeLowerNonEmpty(string $input): string return $input; }', ], + 'assertListKeepsArrayShape' => [ + 'code' => ' ['$list===' => "array{0: int, 1: bool, 2: string}, mixed>"], + ], ]; } From bcb196747778e0c3d5c09508fd65f98ef567873a Mon Sep 17 00:00:00 2001 From: AndrolGenhald Date: Thu, 2 Jun 2022 14:15:10 -0500 Subject: [PATCH 2/4] Handle optional keys better when reconciling array shape with list. --- .../Internal/Type/SimpleAssertionReconciler.php | 12 ++++++++++++ tests/AssertAnnotationTest.php | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php index 3cfdb0d25f9..a10bb889eb2 100644 --- a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php +++ b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php @@ -2039,11 +2039,13 @@ private static function reconcileList( $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) { 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; @@ -2051,6 +2053,16 @@ private static function reconcileList( } elseif ($prop_key === $min_unset_list_key) { ++$min_unset_list_key; } + 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 diff --git a/tests/AssertAnnotationTest.php b/tests/AssertAnnotationTest.php index e07539bfd3c..860d000730f 100644 --- a/tests/AssertAnnotationTest.php +++ b/tests/AssertAnnotationTest.php @@ -2076,6 +2076,23 @@ function takesString(string $_str): void {} ', 'assertions' => ['$list===' => "array{0: int, 1: bool, 2: string}, mixed>"], ], + 'assertListMarksKeysAsNonOptional' => [ + 'code' => ' ['$list===' => "array{0: int, 5: bool, 6: string, 7: object, 8?: float}, mixed>"], + ], ]; } From b691bf79123d2ff4a4eea72cfa9535a34a069785 Mon Sep 17 00:00:00 2001 From: AndrolGenhald Date: Sat, 4 Jun 2022 11:49:37 -0500 Subject: [PATCH 3/4] Fix array-shape-list and truthy reconciliation to have definite 0 key. --- src/Psalm/Internal/Type/SimpleAssertionReconciler.php | 6 ++++++ tests/AssertAnnotationTest.php | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php index a10bb889eb2..c83aa2ffe52 100644 --- a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php +++ b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php @@ -2493,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(); + } } } diff --git a/tests/AssertAnnotationTest.php b/tests/AssertAnnotationTest.php index 860d000730f..a87a580d337 100644 --- a/tests/AssertAnnotationTest.php +++ b/tests/AssertAnnotationTest.php @@ -2093,6 +2093,17 @@ function is_list($arr): bool ', 'assertions' => ['$list===' => "array{0: int, 5: bool, 6: string, 7: object, 8?: float}, mixed>"], ], + 'truthyArrayShapeListHas0Key' => [ + 'code' => ' Date: Sat, 4 Jun 2022 12:03:45 -0500 Subject: [PATCH 4/4] Remove use of PHP 8.1 function in tests. --- tests/AssertAnnotationTest.php | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/AssertAnnotationTest.php b/tests/AssertAnnotationTest.php index a87a580d337..ba46d173660 100644 --- a/tests/AssertAnnotationTest.php +++ b/tests/AssertAnnotationTest.php @@ -2063,7 +2063,16 @@ function makeLowerNonEmpty(string $input): string */ function is_list($arr): bool { - return is_array($arr) && array_is_list($arr); + 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} */ @@ -2084,7 +2093,16 @@ function takesString(string $_str): void {} */ function is_list($arr): bool { - return is_array($arr) && array_is_list($arr); + 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} */