From 6058725256c24841c3d5dad4474696ded73a9968 Mon Sep 17 00:00:00 2001 From: Matthew Brown Date: Sun, 22 Mar 2020 10:44:48 -0400 Subject: [PATCH] Add prototype for conditional return type --- src/Psalm/Internal/Analyzer/TypeAnalyzer.php | 23 +++ src/Psalm/Internal/Type/ParseTree.php | 116 +++++++++---- .../Type/ParseTree/ConditionalTree.php | 19 +++ .../Type/ParseTree/TemplateIsTree.php | 19 +++ src/Psalm/Type.php | 53 +++++- src/Psalm/Type/Atomic/TConditional.php | 161 ++++++++++++++++++ src/Psalm/Type/Union.php | 44 +++++ tests/AnnotationTest.php | 81 ++++++--- tests/TypeParseTest.php | 41 +++++ 9 files changed, 498 insertions(+), 59 deletions(-) create mode 100644 src/Psalm/Internal/Type/ParseTree/ConditionalTree.php create mode 100644 src/Psalm/Internal/Type/ParseTree/TemplateIsTree.php create mode 100644 src/Psalm/Type/Atomic/TConditional.php diff --git a/src/Psalm/Internal/Analyzer/TypeAnalyzer.php b/src/Psalm/Internal/Analyzer/TypeAnalyzer.php index 608809f00..18b9ca6c6 100644 --- a/src/Psalm/Internal/Analyzer/TypeAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/TypeAnalyzer.php @@ -24,6 +24,7 @@ use Psalm\Type\Atomic\TTemplateParam; use Psalm\Type\Atomic\TTemplateParamClass; use Psalm\Type\Atomic\GetClassT; use Psalm\Type\Atomic\GetTypeT; +use Psalm\Type\Atomic\TConditional; use Psalm\Type\Atomic\THtmlEscapedString; use Psalm\Type\Atomic\TInt; use Psalm\Type\Atomic\TIterable; @@ -913,6 +914,28 @@ class TypeAnalyzer return false; } + if ($container_type_part instanceof TConditional) { + $atomic_types = array_merge( + array_values($container_type_part->if_type->getAtomicTypes()), + array_values($container_type_part->else_type->getAtomicTypes()) + ); + + foreach ($atomic_types as $container_as_type_part) { + if (self::isAtomicContainedBy( + $codebase, + $input_type_part, + $container_as_type_part, + $allow_interface_equality, + $allow_float_int_equality, + $atomic_comparison_result + )) { + return true; + } + } + + return false; + } + if ($input_type_part instanceof TTemplateParam) { if ($input_type_part->extra_types) { foreach ($input_type_part->extra_types as $extra_type) { diff --git a/src/Psalm/Internal/Type/ParseTree.php b/src/Psalm/Internal/Type/ParseTree.php index e8d631e6d..a131ac9d2 100644 --- a/src/Psalm/Internal/Type/ParseTree.php +++ b/src/Psalm/Internal/Type/ParseTree.php @@ -317,6 +317,19 @@ class ParseTree break; } + while ($current_parent instanceof ParseTree\UnionTree + && $current_leaf->parent + ) { + $current_leaf = $current_leaf->parent; + $current_parent = $current_leaf->parent; + } + + if ($current_parent && $current_parent instanceof ParseTree\ConditionalTree) { + $current_leaf = $current_parent; + $current_parent = $current_parent->parent; + break; + } + if (!$current_parent) { throw new TypeParseTreeException('Cannot process colon without parent'); } @@ -372,22 +385,44 @@ class ParseTree case '?': if ($next_token === null || $next_token[0] !== ':') { - $new_parent = !$current_leaf instanceof ParseTree\Root ? $current_leaf : null; - - $new_leaf = new ParseTree\NullableTree( - $new_parent - ); - - if ($current_leaf instanceof ParseTree\Root) { - $current_leaf = $parse_tree = $new_leaf; - break; + while (($current_leaf instanceof ParseTree\Value + || $current_leaf instanceof ParseTree\UnionTree) + && $current_leaf->parent + ) { + $current_leaf = $current_leaf->parent; } - if ($new_leaf->parent) { - $new_leaf->parent->children[] = $new_leaf; - } + if ($current_leaf instanceof ParseTree\TemplateIsTree && $current_leaf->parent) { + $current_parent = $current_leaf->parent; - $current_leaf = $new_leaf; + $new_leaf = new ParseTree\ConditionalTree( + $current_leaf, + $current_leaf->parent + ); + + $current_leaf->parent = $new_leaf; + + array_pop($current_parent->children); + $current_parent->children[] = $new_leaf; + $current_leaf = $new_leaf; + } else { + $new_parent = !$current_leaf instanceof ParseTree\Root ? $current_leaf : null; + + $new_leaf = new ParseTree\NullableTree( + $new_parent + ); + + if ($current_leaf instanceof ParseTree\Root) { + $current_leaf = $parse_tree = $new_leaf; + break; + } + + if ($new_leaf->parent) { + $new_leaf->parent->children[] = $new_leaf; + } + + $current_leaf = $new_leaf; + } } break; @@ -423,9 +458,16 @@ class ParseTree $current_parent = $current_leaf->parent; } - $new_parent_leaf = new ParseTree\UnionTree($current_parent); - $new_parent_leaf->children = [$current_leaf]; - $current_leaf->parent = $new_parent_leaf; + if ($current_parent instanceof ParseTree\TemplateIsTree) { + $new_parent_leaf = new ParseTree\UnionTree($current_leaf); + $new_parent_leaf->children = [$current_leaf]; + $new_parent_leaf->parent = $current_parent; + $current_leaf->parent = $new_parent_leaf; + } else { + $new_parent_leaf = new ParseTree\UnionTree($current_parent); + $new_parent_leaf->children = [$current_leaf]; + $current_leaf->parent = $new_parent_leaf; + } if ($current_parent) { array_pop($current_parent->children); @@ -472,32 +514,46 @@ class ParseTree break; + case 'is': case 'as': if ($i > 0) { $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]); + if ($current_parent) { + array_pop($current_parent->children); } - array_pop($current_parent->children); + if ($type_token[0] === 'as') { + if (!$current_leaf instanceof ParseTree\Value + || !$current_parent instanceof ParseTree\GenericTree + || !$next_token + ) { + throw new TypeParseTreeException('Unexpected token ' . $type_token[0]); + } - $current_leaf = new ParseTree\TemplateAsTree( - $current_leaf->value, - $next_token[0], - $current_parent - ); + $current_leaf = new ParseTree\TemplateAsTree( + $current_leaf->value, + $next_token[0], + $current_parent + ); - $current_parent->children[] = $current_leaf; - ++$i; + $current_parent->children[] = $current_leaf; + ++$i; + } elseif ($current_leaf instanceof ParseTree\Value) { + $current_leaf = new ParseTree\TemplateIsTree( + $current_leaf->value, + $current_parent + ); + + if ($current_parent) { + $current_parent->children[] = $current_leaf; + } + } break; } - // falling through for methods named 'as' + // falling through for methods named 'as' or 'is' default: $new_parent = !$current_leaf instanceof ParseTree\Root ? $current_leaf : null; diff --git a/src/Psalm/Internal/Type/ParseTree/ConditionalTree.php b/src/Psalm/Internal/Type/ParseTree/ConditionalTree.php new file mode 100644 index 000000000..820dcda21 --- /dev/null +++ b/src/Psalm/Internal/Type/ParseTree/ConditionalTree.php @@ -0,0 +1,19 @@ +condition = $condition; + $this->parent = $parent; + } +} diff --git a/src/Psalm/Internal/Type/ParseTree/TemplateIsTree.php b/src/Psalm/Internal/Type/ParseTree/TemplateIsTree.php new file mode 100644 index 000000000..dcfb2b86c --- /dev/null +++ b/src/Psalm/Internal/Type/ParseTree/TemplateIsTree.php @@ -0,0 +1,19 @@ +param_name = $param_name; + $this->parent = $parent; + } +} diff --git a/src/Psalm/Type.php b/src/Psalm/Type.php index 555dcd1ea..09ae4277a 100644 --- a/src/Psalm/Type.php +++ b/src/Psalm/Type.php @@ -735,6 +735,53 @@ abstract class Type ); } + if ($parse_tree instanceof ParseTree\ConditionalTree) { + $template_param_name = $parse_tree->condition->param_name; + + if (isset($template_type_map[$template_param_name])) { + $first_class = array_keys($template_type_map[$template_param_name])[0]; + + $conditional_type = self::getTypeFromTree( + $parse_tree->condition->children[0], + null, + $template_type_map + ); + + $if_type = self::getTypeFromTree( + $parse_tree->children[0], + null, + $template_type_map + ); + + $else_type = self::getTypeFromTree( + $parse_tree->children[1], + null, + $template_type_map + ); + + if ($conditional_type instanceof Type\Atomic) { + $conditional_type = new Type\Union([$conditional_type]); + } + + if ($if_type instanceof Type\Atomic) { + $if_type = new Type\Union([$if_type]); + } + + if ($else_type instanceof Type\Atomic) { + $else_type = new Type\Union([$else_type]); + } + + return new Atomic\TConditional( + $template_param_name, + $first_class, + $template_type_map[$template_param_name][$first_class][0], + $conditional_type, + $if_type, + $else_type + ); + } + } + if (!$parse_tree instanceof ParseTree\Value) { throw new \InvalidArgumentException('Unrecognised parse tree type ' . get_class($parse_tree)); } @@ -905,11 +952,11 @@ abstract class Type $type_tokens[++$rtc] = [' ', $i - 1]; $type_tokens[++$rtc] = ['', $i]; } elseif ($was_space - && $char === 'a' + && ($char === 'a' || $char === 'i') && ($chars[$i + 1] ?? null) === 's' && ($chars[$i + 2] ?? null) === ' ' ) { - $type_tokens[++$rtc] = ['as', $i - 1]; + $type_tokens[++$rtc] = [$char . 's', $i - 1]; $type_tokens[++$rtc] = ['', ++$i]; continue; } elseif ($was_char) { @@ -1079,7 +1126,7 @@ abstract class Type if (in_array( $string_type_token[0], [ - '<', '>', '|', '?', ',', '{', '}', ':', '::', '[', ']', '(', ')', '&', '=', '...', 'as', + '<', '>', '|', '?', ',', '{', '}', ':', '::', '[', ']', '(', ')', '&', '=', '...', 'as', 'is', ], true )) { diff --git a/src/Psalm/Type/Atomic/TConditional.php b/src/Psalm/Type/Atomic/TConditional.php new file mode 100644 index 000000000..d82395a48 --- /dev/null +++ b/src/Psalm/Type/Atomic/TConditional.php @@ -0,0 +1,161 @@ +param_name = $param_name; + $this->defining_class = $defining_class; + $this->as_type = $as_type; + $this->conditional_type = $conditional_type; + $this->if_type = $if_type; + $this->else_type = $else_type; + } + + public function __toString() + { + return '(' + . $this->param_name + . ' is ' . $this->conditional_type + . ' ? ' . $this->if_type + . ' : ' . $this->else_type + . ')'; + } + + /** + * @return string + */ + public function getKey(bool $include_extra = true) + { + return $this->__toString(); + } + + /** + * @return string + */ + public function getAssertionString() + { + return ''; + } + + public function getId(bool $nested = false) + { + return '(' + . $this->param_name . ':' . $this->defining_class + . ' is ' . $this->conditional_type->getId() + . ' ? ' . $this->if_type->getId() + . ' : ' . $this->else_type->getId() + . ')'; + } + + /** + * @param string|null $namespace + * @param array $aliased_classes + * @param string|null $this_class + * @param int $php_major_version + * @param int $php_minor_version + * + * @return null + */ + public function toPhpString( + $namespace, + array $aliased_classes, + $this_class, + $php_major_version, + $php_minor_version + ) { + return null; + } + + /** + * @param string|null $namespace + * @param array $aliased_classes + * @param string|null $this_class + * @param bool $use_phpdoc_format + * + * @return string + */ + public function toNamespacedString( + ?string $namespace, + array $aliased_classes, + ?string $this_class, + bool $use_phpdoc_format + ) { + return ''; + } + + public function getChildNodes() : array + { + return [$this->conditional_type, $this->if_type, $this->else_type]; + } + + /** + * @return bool + */ + public function canBeFullyExpressedInPhp() + { + return false; + } + + /** + * @param array> $template_types + * + * @return void + */ + public function replaceTemplateTypesWithArgTypes(array $template_types, ?Codebase $codebase) + { + $this->conditional_type->replaceTemplateTypesWithArgTypes($template_types, $codebase); + $this->if_type->replaceTemplateTypesWithArgTypes($template_types, $codebase); + $this->else_type->replaceTemplateTypesWithArgTypes($template_types, $codebase); + } +} diff --git a/src/Psalm/Type/Union.php b/src/Psalm/Type/Union.php index 94834baf1..b8497c4e6 100644 --- a/src/Psalm/Type/Union.php +++ b/src/Psalm/Type/Union.php @@ -1267,6 +1267,50 @@ class Union implements TypeNode } else { $new_types[$key] = new Type\Atomic\TMixed(); } + } elseif ($atomic_type instanceof Type\Atomic\TConditional + && $codebase + ) { + $template_type = isset($template_types[$atomic_type->param_name][$atomic_type->defining_class]) + ? clone $template_types[$atomic_type->param_name][$atomic_type->defining_class][0] + : null; + + $class_template_type = null; + + if ($template_type) { + if (TypeAnalyzer::isContainedBy( + $codebase, + $template_type, + $atomic_type->conditional_type + )) { + $class_template_type = clone $atomic_type->if_type; + } elseif (TypeAnalyzer::isContainedBy( + $codebase, + $template_type, + $atomic_type->as_type + ) + && !TypeAnalyzer::isContainedBy( + $codebase, + $atomic_type->as_type, + $template_type + ) + ) { + $class_template_type = clone $atomic_type->else_type; + } + } + + if (!$class_template_type) { + $class_template_type = Type::combineUnionTypes( + $atomic_type->if_type, + $atomic_type->else_type, + $codebase + ); + } + + $keys_to_unset[] = $key; + + foreach ($class_template_type->getAtomicTypes() as $class_template_atomic_type) { + $new_types[$class_template_atomic_type->getKey()] = $class_template_atomic_type; + } } } diff --git a/tests/AnnotationTest.php b/tests/AnnotationTest.php index bbe34dc1c..cfa165ee1 100644 --- a/tests/AnnotationTest.php +++ b/tests/AnnotationTest.php @@ -71,16 +71,15 @@ class AnnotationTest extends TestCase 'bar; - } + /** + * @return \stdClass[]|\ArrayObject + */ + public function getBar(): \ArrayObject { + return $this->bar; + } }' ); @@ -565,12 +564,11 @@ class AnnotationTest extends TestCase ], 'builtInClassInAShape' => [ ' [ ' [ ' [ + ' */ + private array $itemAttr = []; + + /** + * @template T as ?string + * @param T $name + * @return string|string[] + * @psalm-return (T is string ? string : array) + */ + public function getAttribute(?string $name, string $default = "") + { + if (null === $name) { + return $this->itemAttr; + } + return isset($this->itemAttr[$name]) ? $this->itemAttr[$name] : $default; + } + } + + $a = (new A)->getAttribute("colour", "red"); // typed as string + $b = (new A)->getAttribute(null); // typed as array + /** @psalm-suppress MixedArgument */ + $c = (new A)->getAttribute($_GET["foo"]); // typed as string|array', + [ + '$a' => 'string', + '$b' => 'array', + '$c' => 'array|string' + ] + ], ]; } @@ -1233,7 +1262,7 @@ class AnnotationTest extends TestCase * @return \?string */ function foo() { - return rand(0, 1) ? "hello" : null; + return rand(0, 1) ? "hello" : null; }', 'error_message' => 'InvalidDocblock', ], @@ -1336,7 +1365,7 @@ class AnnotationTest extends TestCase * @psalm-suppress MismatchingDocblockReturnType */ function foo(): B { - return new A; + return new A; }', 'error_message' => 'UndefinedClass', ], diff --git a/tests/TypeParseTest.php b/tests/TypeParseTest.php index d8f0547d5..055113bd9 100644 --- a/tests/TypeParseTest.php +++ b/tests/TypeParseTest.php @@ -551,6 +551,47 @@ class TypeParseTest extends TestCase ); } + /** + * @return void + */ + public function testConditionalTypeWithSpaces() + { + $this->assertSame( + '(T is string ? string : int)', + (string) Type::parseString('(T is string ? string : int)', null, ['T' => ['' => [Type::getArray()]]]) + ); + } + + /** + * @return void + */ + public function testConditionalTypeWithUnion() + { + $this->assertSame( + '(T is string|true ? int|string : int)', + (string) Type::parseString('(T is "hello"|true ? string|int : int)', null, ['T' => ['' => [Type::getArray()]]]) + ); + } + + /** + * @return void + */ + public function testConditionalTypeWithoutSpaces() + { + $this->assertSame( + '(T is string ? string : int)', + (string) Type::parseString('(T is string?string:int)', null, ['T' => ['' => [Type::getArray()]]]) + ); + } + + public function testConditionalTypeWithGenerics() : void + { + $this->assertSame( + '(T is string ? string : array)', + (string) Type::parseString('(T is string ? string : array)', null, ['T' => ['' => [Type::getArray()]]]) + ); + } + /** * @return void */