From 068afa09d36fc95cedff3638e72229b63b930f27 Mon Sep 17 00:00:00 2001 From: Matthew Brown Date: Fri, 27 Dec 2019 12:49:28 -0500 Subject: [PATCH] Add very basic implementation for class-string-map Fixes #1969 --- .../Assignment/ArrayAssignmentAnalyzer.php | 71 ++++- .../Assignment/PropertyAssignmentAnalyzer.php | 10 +- .../Expression/Fetch/ArrayFetchAnalyzer.php | 104 ++++++- src/Psalm/Internal/Analyzer/TypeAnalyzer.php | 30 +- src/Psalm/Internal/Type/ParseTree.php | 23 ++ .../Type/ParseTree/TemplateAsTree.php | 25 ++ src/Psalm/Internal/Type/TypeCombination.php | 46 ++- src/Psalm/Type.php | 108 +++++-- src/Psalm/Type/Atomic.php | 1 + src/Psalm/Type/Atomic/TClassStringMap.php | 287 ++++++++++++++++++ src/Psalm/Type/Reconciler.php | 5 + src/Psalm/Type/Union.php | 21 ++ tests/Template/ClassStringMapTest.php | 116 +++++++ tests/TypeParseTest.php | 8 + 14 files changed, 818 insertions(+), 37 deletions(-) create mode 100644 src/Psalm/Internal/Type/ParseTree/TemplateAsTree.php create mode 100644 src/Psalm/Type/Atomic/TClassStringMap.php create mode 100644 tests/Template/ClassStringMapTest.php diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/ArrayAssignmentAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/ArrayAssignmentAnalyzer.php index 99676255694..22748c6e711 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/ArrayAssignmentAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/ArrayAssignmentAnalyzer.php @@ -295,6 +295,12 @@ public static function updateArrayType( } if (!$child_stmts) { + // we need this slight hack as the type we're putting it has to be + // different from the type we're getting out + if ($array_type->isSingle() && $array_type->hasClassStringMap()) { + $assignment_type = $child_stmt_type; + } + $child_stmt_type = $assignment_type; $statements_analyzer->node_data->setType($child_stmt, $assignment_type); } @@ -535,11 +541,61 @@ public static function updateArrayType( && $child_stmt && $parent_var_id && ($parent_type = $context->vars_in_scope[$parent_var_id] ?? null) - && $parent_type->hasList() + ) { - $array_atomic_type = new TNonEmptyList( - $current_type - ); + if ($parent_type->hasList()) { + $array_atomic_type = new TNonEmptyList( + $current_type + ); + } elseif ($parent_type->hasClassStringMap() + && $current_dim_type + && $current_dim_type->isTemplatedClassString() + ) { + /** + * @var Type\Atomic\TClassStringMap + * @psalm-suppress PossiblyUndefinedStringArrayOffset + */ + $class_string_map = $parent_type->getTypes()['array']; + /** + * @var Type\Atomic\TTemplateParamClass + */ + $offset_type_part = \array_values($current_dim_type->getTypes())[0]; + + $template_result = new \Psalm\Internal\Type\TemplateResult( + [], + [ + $offset_type_part->param_name => [ + ($offset_type_part->defining_class ?? '') => [ + new Type\Union([ + new Type\Atomic\TTemplateParam( + $class_string_map->param_name, + $offset_type_part->as_type + ? new Type\Union([$offset_type_part->as_type]) + : Type::getObject(), + 'class-string-map' + ) + ]) + ] + ] + ] + ); + + $current_type->replaceTemplateTypesWithArgTypes( + $template_result->generic_params, + $codebase + ); + + $array_atomic_type = new Type\Atomic\TClassStringMap( + $class_string_map->param_name, + $class_string_map->as_type, + $current_type + ); + } else { + $array_atomic_type = new TNonEmptyArray([ + $array_atomic_key_type, + $current_type, + ]); + } } else { $array_atomic_type = new TNonEmptyArray([ $array_atomic_key_type, @@ -558,7 +614,12 @@ public static function updateArrayType( $atomic_root_types = $root_type->getTypes(); if (isset($atomic_root_types['array'])) { - if ($atomic_root_types['array'] instanceof TNonEmptyArray + if ($array_atomic_type instanceof Type\Atomic\TClassStringMap) { + $array_atomic_type = new TNonEmptyArray([ + $array_atomic_type->getStandinKeyParam(), + $array_atomic_type->value_param + ]); + } elseif ($atomic_root_types['array'] instanceof TNonEmptyArray || $atomic_root_types['array'] instanceof TNonEmptyList ) { $array_atomic_type->count = $atomic_root_types['array']->count; diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/PropertyAssignmentAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/PropertyAssignmentAnalyzer.php index 2c7c1af8bc5..5b7e3402387 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/PropertyAssignmentAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/PropertyAssignmentAnalyzer.php @@ -1289,8 +1289,9 @@ public static function analyzeStatic( if (TypeAnalyzer::canBeContainedBy($codebase, $assignment_value_type, $class_property_type)) { if (IssueBuffer::accepts( new PossiblyInvalidPropertyAssignmentValue( - $var_id . ' with declared type \'' . $class_property_type . '\' cannot be assigned type \'' . - $assignment_value_type . '\'', + $var_id . ' with declared type \'' + . $class_property_type->getId() . '\' cannot be assigned type \'' + . $assignment_value_type->getId() . '\'', new CodeLocation( $statements_analyzer->getSource(), $assignment_value ?: $stmt @@ -1304,8 +1305,9 @@ public static function analyzeStatic( } else { if (IssueBuffer::accepts( new InvalidPropertyAssignmentValue( - $var_id . ' with declared type \'' . $class_property_type . '\' cannot be assigned type \'' . - $assignment_value_type . '\'', + $var_id . ' with declared type \'' . $class_property_type->getId() + . '\' cannot be assigned type \'' + . $assignment_value_type->getId() . '\'', new CodeLocation( $statements_analyzer->getSource(), $assignment_value ?: $stmt diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ArrayFetchAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ArrayFetchAnalyzer.php index a8303243b01..898b304f397 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ArrayFetchAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ArrayFetchAnalyzer.php @@ -32,6 +32,7 @@ use Psalm\Type\Atomic\ObjectLike; use Psalm\Type\Atomic\TArray; use Psalm\Type\Atomic\TArrayKey; +use Psalm\Type\Atomic\TClassStringMap; use Psalm\Type\Atomic\TEmpty; use Psalm\Type\Atomic\TLiteralInt; use Psalm\Type\Atomic\TLiteralString; @@ -56,6 +57,7 @@ use function is_int; use function preg_match; use Psalm\Internal\Taint\Source; +use Psalm\Internal\Type\TemplateResult; /** * @internal @@ -481,7 +483,11 @@ public static function getArrayAccessTypeGivenOffset( continue; } - if ($type instanceof TArray || $type instanceof ObjectLike || $type instanceof TList) { + if ($type instanceof TArray + || $type instanceof ObjectLike + || $type instanceof TList + || $type instanceof TClassStringMap + ) { $has_array_access = true; if ($in_assignment @@ -719,6 +725,102 @@ public static function getArrayAccessTypeGivenOffset( $type->type_param ); } + } elseif ($type instanceof TClassStringMap) { + $offset_type_parts = array_values($offset_type->getTypes()); + + foreach ($offset_type_parts as $offset_type_part) { + if ($offset_type_part instanceof Type\Atomic\TClassString) { + if ($offset_type_part instanceof Type\Atomic\TTemplateParamClass) { + $template_result_get = new TemplateResult( + [], + [ + $type->param_name => [ + 'class-string-map' => [ + new Type\Union([ + new TTemplateParam( + $offset_type_part->param_name, + $offset_type_part->as_type + ? new Type\Union([$offset_type_part->as_type]) + : Type::getObject(), + $offset_type_part->defining_class + ) + ]) + ] + ] + ] + ); + + $template_result_set = new TemplateResult( + [], + [ + $offset_type_part->param_name => [ + ($offset_type_part->defining_class ?: '') => [ + new Type\Union([ + new TTemplateParam( + $type->param_name, + $type->as_type + ? new Type\Union([$type->as_type]) + : Type::getObject(), + 'class-string-map' + ) + ]) + ] + ] + ] + ); + } else { + $template_result_get = new TemplateResult( + [], + [ + $type->param_name => [ + 'class-string-map' => [ + new Type\Union([ + $offset_type_part->as_type + ?: new Type\Atomic\TObject() + ]) + ] + ] + ] + ); + $template_result_set = new TemplateResult( + [], + [] + ); + } + + $expected_value_param_get = clone $type->value_param; + + $expected_value_param_get->replaceTemplateTypesWithArgTypes( + $template_result_get->generic_params, + $codebase + ); + + if ($replacement_type) { + $expected_value_param_set = clone $type->value_param; + + $replacement_type->replaceTemplateTypesWithArgTypes( + $template_result_set->generic_params, + $codebase + ); + + $type->value_param = Type::combineUnionTypes( + $replacement_type, + $expected_value_param_set, + $codebase + ); + } + + if (!$array_access_type) { + $array_access_type = $expected_value_param_get; + } else { + $array_access_type = Type::combineUnionTypes( + $array_access_type, + $expected_value_param_get, + $codebase + ); + } + } + } } else { $generic_key_type = $type->getGenericKeyType(); diff --git a/src/Psalm/Internal/Analyzer/TypeAnalyzer.php b/src/Psalm/Internal/Analyzer/TypeAnalyzer.php index 61e6fce4358..375d5666c8b 100644 --- a/src/Psalm/Internal/Analyzer/TypeAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/TypeAnalyzer.php @@ -12,6 +12,7 @@ use Psalm\Type\Atomic\TArrayKey; use Psalm\Type\Atomic\TBool; use Psalm\Type\Atomic\TClassString; +use Psalm\Type\Atomic\TClassStringMap; use Psalm\Type\Atomic\TCallable; use Psalm\Type\Atomic\TCallableString; use Psalm\Type\Atomic\TEmptyMixed; @@ -2079,6 +2080,12 @@ private static function isMatchingTypeContainedBy( } } + if ($container_type_part instanceof TList + && $input_type_part instanceof TClassStringMap + ) { + return false; + } + if ($container_type_part instanceof TList && $input_type_part instanceof TArray && $input_type_part->type_params[1]->isEmpty() @@ -2107,10 +2114,12 @@ private static function isMatchingTypeContainedBy( if (($input_type_part instanceof TArray || $input_type_part instanceof ObjectLike - || $input_type_part instanceof TList) + || $input_type_part instanceof TList + || $input_type_part instanceof TClassStringMap) && ($container_type_part instanceof TArray || $container_type_part instanceof ObjectLike - || $container_type_part instanceof TList) + || $container_type_part instanceof TList + || $container_type_part instanceof TClassStringMap) ) { if ($container_type_part instanceof ObjectLike) { $generic_container_type_part = $container_type_part->getGenericArrayType(); @@ -2128,8 +2137,7 @@ function ($carry, Type\Union $item) { false ); - if (!$input_type_part instanceof ObjectLike - && !$input_type_part instanceof TList + if ($input_type_part instanceof TArray && !$input_type_part->type_params[0]->hasMixed() && !($input_type_part->type_params[1]->isEmpty() && $container_params_can_be_undefined) @@ -2145,6 +2153,20 @@ function ($carry, Type\Union $item) { $input_type_part = $input_type_part->getGenericArrayType(); } + if ($input_type_part instanceof TClassStringMap) { + $input_type_part = new TArray([ + $input_type_part->getStandinKeyParam(), + clone $input_type_part->value_param + ]); + } + + if ($container_type_part instanceof TClassStringMap) { + $container_type_part = new TArray([ + $container_type_part->getStandinKeyParam(), + clone $container_type_part->value_param + ]); + } + if ($container_type_part instanceof TList) { $all_types_contain = false; $atomic_comparison_result->type_coerced = true; diff --git a/src/Psalm/Internal/Type/ParseTree.php b/src/Psalm/Internal/Type/ParseTree.php index 9f3851e1350..6030bd6a64b 100644 --- a/src/Psalm/Internal/Type/ParseTree.php +++ b/src/Psalm/Internal/Type/ParseTree.php @@ -472,6 +472,29 @@ public static function createFromTokens(array $type_tokens) break; + case 'as': + $current_parent = $current_leaf->parent; + + if (!$current_leaf instanceof ParseTree\Value + || !$current_parent instanceof ParseTree\GenericTree + || !$next_token + ) { + throw new TypeParseTreeException('Unexpected token ' . $type_token[0]); + } + + array_pop($current_parent->children); + + $current_leaf = new ParseTree\TemplateAsTree( + $current_leaf->value, + $next_token[0], + $current_parent + ); + + $current_parent->children[] = $current_leaf; + ++$i; + + break; + default: $new_parent = !$current_leaf instanceof ParseTree\Root ? $current_leaf : null; diff --git a/src/Psalm/Internal/Type/ParseTree/TemplateAsTree.php b/src/Psalm/Internal/Type/ParseTree/TemplateAsTree.php new file mode 100644 index 00000000000..211f0961110 --- /dev/null +++ b/src/Psalm/Internal/Type/ParseTree/TemplateAsTree.php @@ -0,0 +1,25 @@ +param_name = $param_name; + $this->as = $as; + $this->parent = $parent; + } +} diff --git a/src/Psalm/Internal/Type/TypeCombination.php b/src/Psalm/Internal/Type/TypeCombination.php index b6f8cb89df7..e77ae38bd02 100644 --- a/src/Psalm/Internal/Type/TypeCombination.php +++ b/src/Psalm/Internal/Type/TypeCombination.php @@ -122,6 +122,15 @@ class TypeCombination /** @var ?bool */ private $all_arrays_lists; + /** @var ?bool */ + private $all_arrays_class_string_maps; + + /** @var array */ + private $class_string_map_names = []; + + /** @var array */ + private $class_string_map_as_types = []; + /** * Combines types together * - so `int + string = int|string` @@ -437,7 +446,16 @@ function (Type\Union $type) : bool { } } } else { - if ($combination->all_arrays_lists) { + if ($combination->all_arrays_class_string_maps + && count($combination->class_string_map_as_types) === 1 + && count($combination->class_string_map_names) === 1 + ) { + $array_type = new Type\Atomic\TClassStringMap( + array_keys($combination->class_string_map_names)[0], + array_values($combination->class_string_map_as_types)[0], + $generic_type_params[1] + ); + } elseif ($combination->all_arrays_lists) { $array_type = new TList($generic_type_params[1]); } else { $array_type = new TArray($generic_type_params); @@ -725,6 +743,7 @@ private static function scrapeTypeProperties( if (!$type->type_params[1]->isEmpty()) { $combination->all_arrays_lists = false; + $combination->all_arrays_class_string_maps = false; } } elseif ($type instanceof TList) { foreach ([Type::getInt(), $type->type_param] as $i => $type_param) { @@ -757,6 +776,29 @@ private static function scrapeTypeProperties( if ($combination->all_arrays_lists !== false) { $combination->all_arrays_lists = true; } + + $combination->all_arrays_class_string_maps = false; + } elseif ($type instanceof Atomic\TClassStringMap) { + foreach ([$type->getStandinKeyParam(), $type->value_param] as $i => $type_param) { + if (isset($combination->array_type_params[$i])) { + $combination->array_type_params[$i] = Type::combineUnionTypes( + $combination->array_type_params[$i], + $type_param, + $codebase, + $overwrite_empty_array + ); + } else { + $combination->array_type_params[$i] = $type_param; + } + } + + $combination->array_always_filled = false; + + if ($combination->all_arrays_class_string_maps !== false) { + $combination->all_arrays_class_string_maps = true; + $combination->class_string_map_names[$type->param_name] = true; + $combination->class_string_map_as_types[(string) $type->as_type] = $type->as_type; + } } elseif (($type instanceof TGenericObject && ($type->value === 'Traversable' || $type->value === 'Generator')) || ($type instanceof TIterable && $type->has_docblock_params) || ($type instanceof TArray && $type_key === 'iterable') @@ -854,6 +896,8 @@ private static function scrapeTypeProperties( } elseif ($combination->all_arrays_lists !== false) { $combination->all_arrays_lists = true; } + + $combination->all_arrays_class_string_maps = false; } else { if ($type instanceof TObject) { $combination->named_object_types = null; diff --git a/src/Psalm/Type.php b/src/Psalm/Type.php index 193580ac268..1267a44abcb 100644 --- a/src/Psalm/Type.php +++ b/src/Psalm/Type.php @@ -30,6 +30,7 @@ use Psalm\Type\Atomic\TBool; use Psalm\Type\Atomic\TCallable; use Psalm\Type\Atomic\TClassString; +use Psalm\Type\Atomic\TClassStringMap; use Psalm\Type\Atomic\TEmpty; use Psalm\Type\Atomic\TFalse; use Psalm\Type\Atomic\TFloat; @@ -110,6 +111,7 @@ abstract class Type 'non-empty-countable' => true, 'list' => true, 'non-empty-list' => true, + 'class-string-map' => true, ]; /** @@ -230,7 +232,7 @@ private static function fixScalarTerms( * @param array{int,int}|null $php_version * @param array> $template_type_map * - * @return Atomic|TArray|TGenericObject|ObjectLike|Union + * @return Atomic|Union */ public static function getTypeFromTree( ParseTree $parse_tree, @@ -240,17 +242,23 @@ public static function getTypeFromTree( if ($parse_tree instanceof ParseTree\GenericTree) { $generic_type = $parse_tree->value; - $generic_params = array_map( - /** - * @return Union - */ - function (ParseTree $child_tree) use ($template_type_map) { - $tree_type = self::getTypeFromTree($child_tree, null, $template_type_map); + $generic_params = []; - return $tree_type instanceof Union ? $tree_type : new Union([$tree_type]); - }, - $parse_tree->children - ); + foreach ($parse_tree->children as $i => $child_tree) { + $tree_type = self::getTypeFromTree($child_tree, null, $template_type_map); + + if ($generic_type === 'class-string-map' + && $i === 0 + ) { + if ($tree_type instanceof TTemplateParam) { + $template_type_map[$tree_type->param_name] = ['class-string-map' => [$tree_type->as]]; + } elseif ($tree_type instanceof TNamedObject) { + $template_type_map[$tree_type->value] = ['class-string-map' => [self::getObject()]]; + } + } + + $generic_params[] = $tree_type instanceof Union ? $tree_type : new Union([$tree_type]); + } $generic_type_value = self::fixScalarTerms($generic_type); @@ -322,6 +330,44 @@ function (ParseTree $child_tree) use ($template_type_map) { return new TClassString($class_name, $param_union_types[0]); } + if ($generic_type_value === 'class-string-map') { + if (count($generic_params) !== 2) { + throw new TypeParseTreeException( + 'There should only be two params for class-string-map, ' + . count($generic_params) . ' provided' + ); + } + + $template_marker_parts = array_values($generic_params[0]->getTypes()); + + $template_marker = $template_marker_parts[0]; + + $template_as_type = null; + + if ($template_marker instanceof TNamedObject) { + $template_param_name = $template_marker->value; + } elseif ($template_marker instanceof Atomic\TTemplateParam) { + $template_param_name = $template_marker->param_name; + $template_as_type = array_values($template_marker->as->getTypes())[0]; + + if (!$template_as_type instanceof TNamedObject) { + throw new TypeParseTreeException( + 'Unrecognised as type' + ); + } + } else { + throw new TypeParseTreeException( + 'Unrecognised class-string-map templated param' + ); + } + + return new TClassStringMap( + $template_param_name, + $template_as_type, + $generic_params[1] + ); + } + if ($generic_type_value === 'key-of') { $param_name = (string) $generic_params[0]; @@ -599,16 +645,10 @@ function (ParseTree $child_tree) use ($template_type_map) { return $non_nullable_type; } - if ($non_nullable_type instanceof Atomic) { - return TypeCombination::combineTypes([ - new TNull, - $non_nullable_type, - ]); - } - - throw new \UnexpectedValueException( - 'Was expecting an atomic or union type, got ' . get_class($non_nullable_type) - ); + return TypeCombination::combineTypes([ + new TNull, + $non_nullable_type, + ]); } if ($parse_tree instanceof ParseTree\MethodTree @@ -661,6 +701,14 @@ function (ParseTree $child_tree) use ($template_type_map) { ); } + if ($parse_tree instanceof ParseTree\TemplateAsTree) { + return new Atomic\TTemplateParam( + $parse_tree->param_name, + new Union([new TNamedObject($parse_tree->as)]), + 'class-string-map' + ); + } + if (!$parse_tree instanceof ParseTree\Value) { throw new \InvalidArgumentException('Unrecognised parse tree type ' . get_class($parse_tree)); } @@ -814,6 +862,14 @@ public static function tokenize($string_type, $ignore_space = true) ) { $type_tokens[++$rtc] = [' ', $i - 1]; $type_tokens[++$rtc] = ['', $i]; + } elseif ($was_space + && $char === 'a' + && ($chars[$i + 1] ?? null) === 's' + && ($chars[$i + 2] ?? null) === ' ' + ) { + $type_tokens[++$rtc] = ['as', $i - 1]; + $type_tokens[++$rtc] = ['', ++$i]; + continue; } elseif ($was_char) { $type_tokens[++$rtc] = ['', $i]; } @@ -980,7 +1036,7 @@ public static function fixUpLocalType( if (in_array( $string_type_token[0], [ - '<', '>', '|', '?', ',', '{', '}', ':', '::', '[', ']', '(', ')', '&', '=', '...', + '<', '>', '|', '?', ',', '{', '}', ':', '::', '[', ']', '(', ')', '&', '=', '...', 'as', ], true )) { @@ -1035,6 +1091,14 @@ public static function fixUpLocalType( continue; } + if ($i > 1 + && ($type_tokens[$i - 2][0] === 'class-string-map') + && ($type_tokens[$i - 1][0] === '<') + ) { + $template_type_map[$string_type_token[0]] = true; + continue; + } + if (isset($type_tokens[$i + 1])) { $next_char = $type_tokens[$i + 1][0]; if ($next_char === ':') { diff --git a/src/Psalm/Type/Atomic.php b/src/Psalm/Type/Atomic.php index e0ffdab6527..4702ad4e637 100644 --- a/src/Psalm/Type/Atomic.php +++ b/src/Psalm/Type/Atomic.php @@ -368,6 +368,7 @@ public function isArrayAccessibleWithStringKey(Codebase $codebase) return $this instanceof TArray || $this instanceof ObjectLike || $this instanceof TList + || $this instanceof Atomic\TClassStringMap || $this->hasArrayAccessInterface($codebase) || ($this instanceof TNamedObject && $this->value === 'SimpleXMLElement'); } diff --git a/src/Psalm/Type/Atomic/TClassStringMap.php b/src/Psalm/Type/Atomic/TClassStringMap.php new file mode 100644 index 00000000000..64f39c9ec35 --- /dev/null +++ b/src/Psalm/Type/Atomic/TClassStringMap.php @@ -0,0 +1,287 @@ +value_param = $value_param; + $this->param_name = $param_name; + $this->as_type = $as_type; + } + + public function __toString() + { + /** @psalm-suppress MixedOperand */ + return static::KEY + . '<' + . $this->param_name + . ' as ' + . ($this->as_type ? (string) $this->as_type : 'object') + . ', ' + . ((string) $this->value_param) + . '>'; + } + + public function getId() + { + /** @psalm-suppress MixedOperand */ + return static::KEY + . '<' + . $this->param_name + . ' as ' + . ($this->as_type ? (string) $this->as_type : 'object') + . ', ' + . $this->value_param->getId() + . '>'; + } + + public function __clone() + { + $this->value_param = clone $this->value_param; + } + + /** + * @param array $aliased_classes + * + * @return string + */ + public function toNamespacedString( + ?string $namespace, + array $aliased_classes, + ?string $this_class, + bool $use_phpdoc_format + ) { + if ($use_phpdoc_format) { + return (new TArray([Type::getString(), $this->value_param])) + ->toNamespacedString( + $namespace, + $aliased_classes, + $this_class, + $use_phpdoc_format + ); + } + + /** @psalm-suppress MixedOperand */ + return static::KEY + . '<' + . $this->param_name + . ($this->as_type ? ' as ' . $this->as_type : '') + . ', ' + . $this->value_param->toNamespacedString( + $namespace, + $aliased_classes, + $this_class, + $use_phpdoc_format + ) + . '>'; + } + + /** + * @param string|null $namespace + * @param array $aliased_classes + * @param string|null $this_class + * @param int $php_major_version + * @param int $php_minor_version + * + * @return string + */ + public function toPhpString($namespace, array $aliased_classes, $this_class, $php_major_version, $php_minor_version) + { + return 'array'; + } + + public function canBeFullyExpressedInPhp() + { + return false; + } + + /** + * @return string + */ + public function getKey() + { + return 'array'; + } + + public function setFromDocblock() + { + $this->from_docblock = true; + $this->value_param->from_docblock = true; + } + + public function replaceTemplateTypesWithStandins( + TemplateResult $template_result, + Codebase $codebase = null, + Atomic $input_type = null, + ?string $calling_class = null, + bool $replace = true, + bool $add_upper_bound = false, + int $depth = 0 + ) : Atomic { + $map = clone $this; + + foreach ([Type::getString(), $map->value_param] as $offset => $type_param) { + $input_type_param = null; + + if (($input_type instanceof Atomic\TGenericObject + || $input_type instanceof Atomic\TIterable + || $input_type instanceof Atomic\TArray) + && + isset($input_type->type_params[$offset]) + ) { + $input_type_param = clone $input_type->type_params[$offset]; + } elseif ($input_type instanceof Atomic\ObjectLike) { + if ($offset === 0) { + $input_type_param = $input_type->getGenericKeyType(); + } else { + $input_type_param = $input_type->getGenericValueType(); + } + } elseif ($input_type instanceof Atomic\TList) { + if ($offset === 0) { + continue; + } + + $input_type_param = clone $input_type->type_param; + } + + $value_param = UnionTemplateHandler::replaceTemplateTypesWithStandins( + $type_param, + $template_result, + $codebase, + $input_type_param, + $calling_class, + $replace, + $add_upper_bound, + $depth + 1 + ); + + if ($offset === 1) { + $map->value_param = $value_param; + } + } + + return $map; + } + + /** + * @param array> $template_types + * + * @return void + */ + public function replaceTemplateTypesWithArgTypes(array $template_types, ?Codebase $codebase) + { + $this->value_param->replaceTemplateTypesWithArgTypes($template_types); + } + + /** + * @return list + */ + public function getTemplateTypes() : array + { + return $this->value_param->getTemplateTypes(); + } + + /** + * @return bool + */ + public function equals(Atomic $other_type) + { + if (get_class($other_type) !== static::class) { + return false; + } + + if (!$this->value_param->equals($other_type->value_param)) { + return false; + } + + return true; + } + + /** + * @return string + */ + public function getAssertionString() + { + return $this->getKey(); + } + + /** + * @param StatementsSource $source + * @param CodeLocation $code_location + * @param array $suppressed_issues + * @param array $phantom_classes + * @param bool $inferred + * + * @return void + */ + public function check( + StatementsSource $source, + CodeLocation $code_location, + array $suppressed_issues, + array $phantom_classes = [], + bool $inferred = true, + bool $prevent_template_covariance = false + ) { + if ($this->checked) { + return; + } + + $this->value_param->check( + $source, + $code_location, + $suppressed_issues, + $phantom_classes, + $inferred, + $prevent_template_covariance + ); + + $this->checked = true; + } + + public function getStandinKeyParam() : Type\Union + { + return new Type\Union([ + new TTemplateParamClass( + $this->param_name, + $this->as_type ? $this->as_type->value : 'object', + $this->as_type, + 'class-string-map' + ) + ]); + } +} diff --git a/src/Psalm/Type/Reconciler.php b/src/Psalm/Type/Reconciler.php index c3ee1a6bf8a..a9bdfe3d7b7 100644 --- a/src/Psalm/Type/Reconciler.php +++ b/src/Psalm/Type/Reconciler.php @@ -537,6 +537,8 @@ private static function getValueForKey( } } elseif ($existing_key_type_part instanceof Type\Atomic\TNull) { $new_base_type_candidate = Type::getNull(); + } elseif ($existing_key_type_part instanceof Type\Atomic\TClassStringMap) { + return Type::getMixed(); } elseif (!$existing_key_type_part instanceof Type\Atomic\ObjectLike) { return Type::getMixed(); } elseif ($array_key[0] === '$' || ($array_key[0] !== '\'' && !\is_numeric($array_key[0]))) { @@ -778,6 +780,7 @@ private static function adjustObjectLikeType( || ($base_atomic_type instanceof Type\Atomic\TArray && !$base_atomic_type->type_params[1]->isEmpty()) || $base_atomic_type instanceof Type\Atomic\TList + || $base_atomic_type instanceof Type\Atomic\TClassStringMap ) { $new_base_type = clone $existing_types[$base_key]; @@ -811,6 +814,8 @@ private static function adjustObjectLikeType( $base_atomic_type->previous_key_type = $previous_key_type; $base_atomic_type->previous_value_type = $previous_value_type; + } elseif ($base_atomic_type instanceof Type\Atomic\TClassStringMap) { + // do nothing } else { $base_atomic_type = clone $base_atomic_type; $base_atomic_type->properties[$array_key_offset] = clone $result_type; diff --git a/src/Psalm/Type/Union.php b/src/Psalm/Type/Union.php index 90a950e39ee..747e0af09a8 100644 --- a/src/Psalm/Type/Union.php +++ b/src/Psalm/Type/Union.php @@ -626,6 +626,27 @@ public function hasList() return isset($this->types['array']) && $this->types['array'] instanceof Atomic\TList; } + /** + * @return bool + */ + public function hasClassStringMap() + { + return isset($this->types['array']) && $this->types['array'] instanceof Atomic\TClassStringMap; + } + + public function isTemplatedClassString() : bool + { + return $this->isSingle() + && count( + array_filter( + $this->types, + function ($type) { + return $type instanceof Atomic\TTemplateParamClass; + } + ) + ) === 1; + } + /** * @return bool */ diff --git a/tests/Template/ClassStringMapTest.php b/tests/Template/ClassStringMapTest.php new file mode 100644 index 00000000000..2a3a1fc3160 --- /dev/null +++ b/tests/Template/ClassStringMapTest.php @@ -0,0 +1,116 @@ +,error_levels?:string[]}> + */ + public function providerValidCodeParse() + { + return [ + 'basicClassStringMap' => [ + ' */ + public static array $map = []; + + /** + * @template T as Foo + * @param class-string $class + * @return T + */ + public function get(string $class) : Foo { + if (isset(self::$map[$class])) { + return self::$map[$class]; + } + + self::$map[$class] = new $class(); + return self::$map[$class]; + } + }', + ], + 'basicClassStringMapDifferentTemplateName' => [ + ' */ + public static array $map = []; + + /** + * @template U as Foo + * @param class-string $class + * @return U + */ + public function get(string $class) : Foo { + if (isset(self::$map[$class])) { + return self::$map[$class]; + } + + self::$map[$class] = new $class(); + return self::$map[$class]; + } + }', + ], + ]; + } + + /** + * @return iterable + */ + public function providerInvalidCodeParse() + { + return [ + 'assignInvalidClass' => [ + ' */ + public static array $map = []; + + /** + * @template T + * @param class-string $class + */ + public function get(string $class) : void { + self::$map[$class] = 5; + } + }', + 'error_message' => 'InvalidPropertyAssignmentValue' + ], + 'assignInvalidClassDifferentTemplateName' => [ + ' */ + public static array $map = []; + + /** + * @template U + * @param class-string $class + */ + public function get(string $class) : void { + self::$map[$class] = 5; + } + }', + 'error_message' => 'InvalidPropertyAssignmentValue' + ], + ]; + } +} diff --git a/tests/TypeParseTest.php b/tests/TypeParseTest.php index 8da5cf3c9d0..0782a71a20d 100644 --- a/tests/TypeParseTest.php +++ b/tests/TypeParseTest.php @@ -801,6 +801,14 @@ public function testValueOfClassConstant() ); } + public function testClassStringMap() : void + { + $this->assertSame( + 'class-string-map', + (string)Type::parseString('class-string-map') + ); + } + /** * @return void */