diff --git a/UPGRADING.md b/UPGRADING.md index fcbe714b195..01f1a67ddda 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -7,6 +7,8 @@ - [BC] The only optional boolean parameter of `TKeyedArray::getGenericArrayType` was removed, and was replaced with a string parameter with a different meaning. +- [BC] The `TDependentListKey` type was removed and replaced with an optional property of the `TIntRange` type. + # Upgrading from Psalm 4 to Psalm 5 ## Changed diff --git a/psalm-baseline.xml b/psalm-baseline.xml index c8dee018001..227420cf7b7 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + $comment_block->tags['variablesfrom'][0] @@ -182,6 +182,9 @@ + + $properties[0] + $stmt_type $stmt_type @@ -231,6 +234,11 @@ $check_type_string + + + $options['tcp'] ?? null + + $identifier_name @@ -390,9 +398,6 @@ $class_strings ?: null - - $is_replace - @@ -461,17 +466,11 @@ - - $chars[$i - 1] - - - $type_tokens[$i - 1] - $type_tokens[$i - 1] + $type_tokens[$i - 1] $type_tokens[$i - 1] $type_tokens[$i - 1] $type_tokens[$i - 1] - $type_tokens[$i - 2] diff --git a/src/Psalm/Internal/Analyzer/Statements/Block/ForAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Block/ForAnalyzer.php index 598f0a305da..4c0dd4ee16f 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Block/ForAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Block/ForAnalyzer.php @@ -106,14 +106,15 @@ public static function analyze( if (count($stmt->init) === 1 && count($stmt->cond) === 1 && $cond instanceof PhpParser\Node\Expr\BinaryOp - && $cond->right instanceof PhpParser\Node\Scalar\LNumber + && ($cond_value = $statements_analyzer->node_data->getType($cond->right)) + && ($cond_value->isSingleIntLiteral() || $cond_value->isSingleStringLiteral()) && $cond->left instanceof PhpParser\Node\Expr\Variable && is_string($cond->left->name) && isset($init_var_types[$cond->left->name]) && $init_var_types[$cond->left->name]->isSingleIntLiteral() ) { - $init_value = $init_var_types[$cond->left->name]->getSingleIntLiteral()->value; - $cond_value = $cond->right->value; + $init_value = $init_var_types[$cond->left->name]->getSingleLiteral()->value; + $cond_value = $cond_value->getSingleLiteral()->value; if ($cond instanceof PhpParser\Node\Expr\BinaryOp\Smaller && $init_value < $cond_value) { $always_enters_loop = true; diff --git a/src/Psalm/Internal/Analyzer/Statements/Block/WhileAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Block/WhileAnalyzer.php index e3a52f67e76..a1fd1707891 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Block/WhileAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Block/WhileAnalyzer.php @@ -27,7 +27,8 @@ public static function analyze( Context $context ): ?bool { $while_true = ($stmt->cond instanceof PhpParser\Node\Expr\ConstFetch && $stmt->cond->name->parts === ['true']) - || ($stmt->cond instanceof PhpParser\Node\Scalar\LNumber && $stmt->cond->value > 0); + || (($t = $statements_analyzer->node_data->getType($stmt->cond)) + && $t->isAlwaysTruthy()); $pre_context = null; diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/ArrayAssignmentAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/ArrayAssignmentAnalyzer.php index 1b64ce3805f..4f11f44c7ab 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/ArrayAssignmentAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/ArrayAssignmentAnalyzer.php @@ -649,17 +649,16 @@ private static function updateArrayAssignmentChildType( ]); } else { assert($array_atomic_type_list !== null); + $array_atomic_type = array_fill( + $atomic_root_type_array->getMinCount(), + count($atomic_root_type_array->properties)-1, + $array_atomic_type_list, + ); + assert(count($array_atomic_type) > 0); $array_atomic_type = new TKeyedArray( - array_fill( - 0, - count($atomic_root_type_array->properties), - $array_atomic_type_list, - ), + $array_atomic_type, + null, null, - [ - Type::getListKey(), - $array_atomic_type_list, - ], true, ); } diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ArrayFetchAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ArrayFetchAnalyzer.php index 835fb722ac2..b252981f5b1 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ArrayFetchAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ArrayFetchAnalyzer.php @@ -1533,6 +1533,29 @@ private static function handleArrayAccessOnKeyedArray( $properties[$key_value->value] ?? null, $replacement_type, ); + if (is_int($key_value->value) + && !$stmt->dim + && $type->is_list + && $type->properties[$key_value->value-1]->possibly_undefined + ) { + $first = true; + for ($x = 0; $x < $key_value->value; $x++) { + if (!$properties[$x]->possibly_undefined) { + continue; + } + $properties[$x] = Type::combineUnionTypes( + $properties[$x], + $replacement_type, + ); + if ($first) { + $first = false; + $properties[$x] = $properties[$x]->setPossiblyUndefined(false); + } + } + $properties[$key_value->value] = $properties[$key_value->value]-> + setPossiblyUndefined(true) + ; + } } $array_access_type = Type::combineUnionTypes( diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/IncludeAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/IncludeAnalyzer.php index 2cae6ff6af9..0a7af6d594f 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/IncludeAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/IncludeAnalyzer.php @@ -334,7 +334,16 @@ public static function getPathTo( if ($stmt->getArgs()[1]->value instanceof PhpParser\Node\Scalar\LNumber) { $dir_level = $stmt->getArgs()[1]->value->value; } else { - return null; + if ($statements_analyzer) { + $t = $statements_analyzer->node_data->getType($stmt->getArgs()[1]->value); + if ($t && $t->isSingleIntLiteral()) { + $dir_level = $t->getSingleIntLiteral()->value; + } else { + return null; + } + } else { + return null; + } } } diff --git a/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayColumnReturnTypeProvider.php b/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayColumnReturnTypeProvider.php index fa3dd6e62ae..dbb3f3dfb36 100644 --- a/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayColumnReturnTypeProvider.php +++ b/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayColumnReturnTypeProvider.php @@ -89,9 +89,13 @@ public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $ev $properties = []; $ok = true; $last_custom_key = -1; - $is_list = $input_array->is_list || $key_column_name !== null; + $is_list = true; $had_possibly_undefined = false; - foreach ($input_array->properties as $key => $property) { + + // This incorrectly assumes that the array is sorted, may be problematic + // Will be fixed when order is enforced + $key = -1; + foreach ($input_array->properties as $property) { $row_shape = self::getRowShape( $property, $statements_source, @@ -142,6 +146,9 @@ public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $ev $ok = false; break; } + } else { + /** @psalm-suppress StringIncrement Actually always an int in this branch */ + ++$key; } $properties[$key] = $result_element_type->setPossiblyUndefined( diff --git a/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayMergeReturnTypeProvider.php b/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayMergeReturnTypeProvider.php index d9873f0925d..394988baee1 100644 --- a/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayMergeReturnTypeProvider.php +++ b/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayMergeReturnTypeProvider.php @@ -149,7 +149,6 @@ public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $ev if (!isset($generic_properties[$key]) || ( !$type->possibly_undefined && !$unpacking_possibly_empty - && $is_replace )) { if ($unpacking_possibly_empty) { $type = $type->setPossiblyUndefined(true); diff --git a/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayRandReturnTypeProvider.php b/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayRandReturnTypeProvider.php index 56b478edf35..f4a6689051a 100644 --- a/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayRandReturnTypeProvider.php +++ b/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayRandReturnTypeProvider.php @@ -2,7 +2,6 @@ namespace Psalm\Internal\Provider\ReturnTypeProvider; -use PhpParser; use Psalm\Internal\Analyzer\StatementsAnalyzer; use Psalm\Plugin\EventHandler\Event\FunctionReturnTypeProviderEvent; use Psalm\Plugin\EventHandler\FunctionReturnTypeProviderInterface; @@ -54,15 +53,21 @@ public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $ev $key_type = $first_arg_array->getGenericKeyType(); } - if (!$second_arg - || ($second_arg instanceof PhpParser\Node\Scalar\LNumber && $second_arg->value === 1) + if (!$second_arg) { + return $key_type; + } + + $second_arg_type = $statements_source->node_data->getType($second_arg); + if ($second_arg_type + && $second_arg_type->isSingleIntLiteral() + && $second_arg_type->getSingleIntLiteral()->value === 1 ) { return $key_type; } $arr_type = Type::getList($key_type); - if ($second_arg instanceof PhpParser\Node\Scalar\LNumber) { + if ($second_arg_type && $second_arg_type->isSingleIntLiteral()) { return $arr_type; } diff --git a/src/Psalm/Internal/Provider/ReturnTypeProvider/ExplodeReturnTypeProvider.php b/src/Psalm/Internal/Provider/ReturnTypeProvider/ExplodeReturnTypeProvider.php index 15512b6382a..f1d3363fd11 100644 --- a/src/Psalm/Internal/Provider/ReturnTypeProvider/ExplodeReturnTypeProvider.php +++ b/src/Psalm/Internal/Provider/ReturnTypeProvider/ExplodeReturnTypeProvider.php @@ -47,8 +47,9 @@ public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $ev $can_return_empty = isset($call_args[2]) && ( - !$call_args[2]->value instanceof PhpParser\Node\Scalar\LNumber - || $call_args[2]->value->value < 0 + !($third_arg_type = $statements_source->node_data->getType($call_args[2]->value)) + || !$third_arg_type->isSingleIntLiteral() + || $third_arg_type->getSingleIntLiteral()->value < 0 ); if ($call_args[0]->value instanceof PhpParser\Node\Scalar\String_) { diff --git a/src/Psalm/Internal/Type/TypeCombination.php b/src/Psalm/Internal/Type/TypeCombination.php index 8531377860d..863ae77f932 100644 --- a/src/Psalm/Internal/Type/TypeCombination.php +++ b/src/Psalm/Internal/Type/TypeCombination.php @@ -3,15 +3,22 @@ namespace Psalm\Internal\Type; use Psalm\Type\Atomic; +use Psalm\Type\Atomic\TArrayKey; +use Psalm\Type\Atomic\TInt; +use Psalm\Type\Atomic\TIntRange; use Psalm\Type\Atomic\TIterable; use Psalm\Type\Atomic\TLiteralFloat; use Psalm\Type\Atomic\TLiteralInt; use Psalm\Type\Atomic\TLiteralString; use Psalm\Type\Atomic\TNamedObject; use Psalm\Type\Atomic\TObject; +use Psalm\Type\Atomic\TString; use Psalm\Type\Atomic\TTemplateParam; use Psalm\Type\Union; +use function is_int; +use function is_string; + /** * @internal */ @@ -88,4 +95,34 @@ class TypeCombination /** @var array */ public array $class_string_map_as_types = []; + + /** + * @psalm-assert-if-true !null $this->objectlike_key_type + * @psalm-assert-if-true !null $this->objectlike_value_type + * @param array-key $k + */ + public function fallbackKeyContains($k): bool + { + if (!$this->objectlike_key_type) { + return false; + } + foreach ($this->objectlike_key_type->getAtomicTypes() as $t) { + if ($t instanceof TArrayKey) { + return true; + } elseif ($t instanceof TLiteralInt || $t instanceof TLiteralString) { + if ($t->value === $k) { + return true; + } + } elseif ($t instanceof TIntRange) { + if (is_int($k) && $t->contains($k)) { + return true; + } + } elseif ($t instanceof TString && is_string($k)) { + return true; + } elseif ($t instanceof TInt && is_int($k)) { + return true; + } + } + return false; + } } diff --git a/src/Psalm/Internal/Type/TypeCombiner.php b/src/Psalm/Internal/Type/TypeCombiner.php index fe2a8f34dce..89abc13b52c 100644 --- a/src/Psalm/Internal/Type/TypeCombiner.php +++ b/src/Psalm/Internal/Type/TypeCombiner.php @@ -649,26 +649,12 @@ private static function scrapeTypeProperties( $combination->objectlike_sealed = $combination->objectlike_sealed && $type->fallback_params === null; - if ($type->fallback_params) { - $combination->objectlike_key_type = Type::combineUnionTypes( - $type->fallback_params[0], - $combination->objectlike_key_type, - $codebase, - $overwrite_empty_array, - ); - $combination->objectlike_value_type = Type::combineUnionTypes( - $type->fallback_params[1], - $combination->objectlike_value_type, - $codebase, - $overwrite_empty_array, - ); - } - $has_defined_keys = false; foreach ($type->properties as $candidate_property_name => $candidate_property_type) { $value_type = $combination->objectlike_entries[$candidate_property_name] ?? null; + if (!$value_type) { $combination->objectlike_entries[$candidate_property_name] = $candidate_property_type ->setPossiblyUndefined($existing_objectlike_entries @@ -692,9 +678,35 @@ private static function scrapeTypeProperties( $has_defined_keys = true; } + if (($candidate_property_type->possibly_undefined || ($value_type->possibly_undefined ?? true)) + && $combination->fallbackKeyContains($candidate_property_name) + ) { + $combination->objectlike_entries[$candidate_property_name] = Type::combineUnionTypes( + $combination->objectlike_entries[$candidate_property_name], + $combination->objectlike_value_type, + $codebase, + $overwrite_empty_array, + ); + } + unset($missing_entries[$candidate_property_name]); } + if ($type->fallback_params) { + $combination->objectlike_key_type = Type::combineUnionTypes( + $type->fallback_params[0], + $combination->objectlike_key_type, + $codebase, + $overwrite_empty_array, + ); + $combination->objectlike_value_type = Type::combineUnionTypes( + $type->fallback_params[1], + $combination->objectlike_value_type, + $codebase, + $overwrite_empty_array, + ); + } + if (!$has_defined_keys) { $combination->array_always_filled = false; } @@ -718,6 +730,20 @@ private static function scrapeTypeProperties( ->setPossiblyUndefined(true); } + if ($combination->objectlike_value_type) { + foreach ($missing_entries as $k => $_) { + if (!$combination->fallbackKeyContains($k)) { + continue; + } + $combination->objectlike_entries[$k] = Type::combineUnionTypes( + $combination->objectlike_entries[$k], + $combination->objectlike_value_type, + $codebase, + $overwrite_empty_array, + ); + } + } + if (!$type->is_list) { $combination->all_arrays_lists = false; } elseif ($combination->all_arrays_lists !== false) { diff --git a/src/Psalm/Type/Atomic/TKeyedArray.php b/src/Psalm/Type/Atomic/TKeyedArray.php index df48a09e3f6..b78fa8654da 100644 --- a/src/Psalm/Type/Atomic/TKeyedArray.php +++ b/src/Psalm/Type/Atomic/TKeyedArray.php @@ -25,6 +25,7 @@ use function implode; use function is_int; use function is_string; +use function ksort; use function preg_match; use function sort; use function str_replace; @@ -85,6 +86,21 @@ public function __construct( $this->fallback_params = $fallback_params; $this->is_list = $is_list; $this->from_docblock = $from_docblock; + if ($this->is_list) { + $last_k = -1; + $had_possibly_undefined = false; + ksort($this->properties); + foreach ($this->properties as $k => $v) { + if (is_string($k) || $last_k !== ($k-1) || ($had_possibly_undefined && !$v->possibly_undefined)) { + $this->is_list = false; + break; + } + if ($v->possibly_undefined) { + $had_possibly_undefined = true; + } + $last_k = $k; + } + } } /** @@ -98,6 +114,21 @@ public function setProperties(array $properties): self } $cloned = clone $this; $cloned->properties = $properties; + if ($cloned->is_list) { + $last_k = -1; + $had_possibly_undefined = false; + ksort($cloned->properties); + foreach ($cloned->properties as $k => $v) { + if (is_string($k) || $last_k !== ($k-1) || ($had_possibly_undefined && !$v->possibly_undefined)) { + $cloned->is_list = false; + break; + } + if ($v->possibly_undefined) { + $had_possibly_undefined = true; + } + $last_k = $k; + } + } return $cloned; } diff --git a/tests/ArrayAccessTest.php b/tests/ArrayAccessTest.php index 2ea42045234..113458d2da8 100644 --- a/tests/ArrayAccessTest.php +++ b/tests/ArrayAccessTest.php @@ -433,6 +433,59 @@ function foo(array $a, int $b): void { public function providerValidCodeParse(): iterable { return [ + 'testBuildList' => [ + 'code' => ' [ + '$pre===' => 'list{0?: 0|1, 1?: 1}', + '$a===' => 'list{0: 0|1|2, 1?: 1|2, 2?: 2}', + ], + ], + 'testBuildListOther' => [ + 'code' => ' [ + '$list===' => "list{0: 'A'|'B'|'C', 1?: 'C'}", + ], + ], + 'testBuildList3' => [ + 'code' => ' [ + '$a===' => "list{0: 0, 1: 1|2|3, 2?: 2|3, 3?: 3}", + ], + ], 'instanceOfStringOffset' => [ 'code' => ' [], 'php_version' => '8.0', ], + 'arrayMergeOverWrite' => [ + 'code' => ' "a1"]; + $a2 = ["a" => "a2"]; + + $result = array_merge($a1, $a2); + ', + 'assertions' => [ + '$result===' => "array{a: 'a2'}", + ], + ], 'arrayMergeListOfShapes' => [ 'code' => ' 'array{x: string, y: string}', '$c' => 'string', '$e' => 'list', - '$f' => 'list|string', + '$f' => 'list', ], ], 'arrayKeysNoEmpty' => [ @@ -1600,6 +1611,8 @@ function makeKeyedArray(): array { return []; } /** @var array{a: array{v: "a", k: 0}, b: array{v: "b", k: 1}, c?: array{v: "c", k: 2}} */ $aa = []; $k = array_column($aa, null, "k"); + + $l = array_column(["test" => ["v" => "a"], "test2" => ["v" => "b"]], "v"); ', 'assertions' => [ '$a===' => "list{'a', 'b', 'c', 'd'}", @@ -1610,9 +1623,10 @@ function makeKeyedArray(): array { return []; } '$f===' => "array{0: 'd', 1: 'c', 2: 'b', 3: 'a'}", '$g===' => "list{array{k: 0, v: 'a'}, array{k: 1, v: 'b'}, array{k: 2, v: 'c'}, array{k: 3, v: 'd'}}", '$h===' => "list{array{k: 0}, array{k: 1}, array{k: 2}}", - '$i===' => "array{a: 0, b?: 1}", + '$i===' => "list{0: 0, 1?: 1}", '$j===' => "array{0: array{k: 0, v: 'a'}, 1?: array{k: 1, v: 'b'}, 2: array{k: 2, v: 'c'}}", '$k===' => "list{0: array{k: 0, v: 'a'}, 1: array{k: 1, v: 'b'}, 2?: array{k: 2, v: 'c'}}", + '$l===' => "list{'a', 'b'}", ], ], 'splatArrayIntersect' => [ diff --git a/tests/ReturnTypeTest.php b/tests/ReturnTypeTest.php index e34770750f2..8097202ea73 100644 --- a/tests/ReturnTypeTest.php +++ b/tests/ReturnTypeTest.php @@ -15,6 +15,57 @@ class ReturnTypeTest extends TestCase public function providerValidCodeParse(): iterable { return [ + 'arrayCombine' => [ + 'code' => ' + */ + function ret() { + return [new a, new a, new a]; + } + + $result = ret(); + ', + 'assertions' => [ + '$result===' => 'list{0?: 0|a, 1?: 0|a, ..., a>}', + ], + ], + 'arrayCombineInv' => [ + 'code' => '|list{0, 0} + */ + function ret() { + return [new a, new a, new a]; + } + + $result = ret(); + ', + 'assertions' => [ + '$result===' => 'list{0?: 0|a, 1?: 0|a, ..., a>}', + ], + ], + 'arrayCombine2' => [ + 'code' => ' + */ + function ret() { + return [new a, new a, new a]; + } + + $result = ret(); + ', + 'assertions' => [ + '$result===' => 'array{0?: a, test1?: 0, test2?: 0, ..., a>}', + ], + ], 'returnTypeAfterUselessNullCheck' => [ 'code' => 'getId(), ); + + $this->assertSame( + $expected, + TypeCombiner::combine(array_reverse($converted_types))->getId(), + ); } public function providerValidCodeParse(): iterable @@ -90,6 +97,20 @@ function expectsTraversableOrArray($_a): void public function providerTestValidTypeCombination(): array { return [ + 'complexArrayFallback1' => [ + 'array{other_references: list|null, taint_trace: list>|null, ...}', + [ + 'array{other_references: list|null, taint_trace: null}&array', + 'array{other_references: list|null, taint_trace: list>}&array', + ], + ], + 'complexArrayFallback2' => [ + 'list{0?: 0|a, 1?: 0|a, ..., a>}', + [ + 'list', + 'list{0, 0}', + ], + ], 'intOrString' => [ 'int|string', [