diff --git a/src/Psalm/Internal/Type/ParseTree/AnonymousFunctionGenericTree.php b/src/Psalm/Internal/Type/ParseTree/AnonymousFunctionGenericTree.php new file mode 100644 index 00000000000..13963058607 --- /dev/null +++ b/src/Psalm/Internal/Type/ParseTree/AnonymousFunctionGenericTree.php @@ -0,0 +1,10 @@ +param_name = $param_name; - $this->as = $as; $this->parent = $parent; } } diff --git a/src/Psalm/Internal/Type/ParseTreeCreator.php b/src/Psalm/Internal/Type/ParseTreeCreator.php index 9fdd3fcc482..026d47f71c3 100644 --- a/src/Psalm/Internal/Type/ParseTreeCreator.php +++ b/src/Psalm/Internal/Type/ParseTreeCreator.php @@ -3,6 +3,7 @@ namespace Psalm\Internal\Type; use Psalm\Exception\TypeParseTreeException; +use Psalm\Internal\Type\ParseTree\AnonymousFunctionGenericTree; use Psalm\Internal\Type\ParseTree\CallableParamTree; use Psalm\Internal\Type\ParseTree\CallableTree; use Psalm\Internal\Type\ParseTree\CallableWithReturnTypeTree; @@ -95,6 +96,22 @@ public function create(): ParseTree $this->current_leaf->terminated = true; + if ($this->current_leaf instanceof AnonymousFunctionGenericTree && + $this->current_leaf->parent instanceof CallableTree + ) { + $next_token = $this->t + 1 < $this->type_token_count + ? $this->type_tokens[$this->t + 1][0] ?? null + : null; + + if ($next_token !== '(') { + throw new TypeParseTreeException("Unexpected token '$next_token'. Expected '('."); + } + + $this->t++; + + $this->current_leaf = $this->current_leaf->parent; + } + break; case '}': @@ -141,7 +158,8 @@ public function create(): ParseTree case 'is': case 'as': - $this->handleIsOrAs($type_token); + case 'of': + $this->handleIsOrAsOrOf($type_token); break; default: @@ -715,7 +733,7 @@ private function handleAmpersand(): void } /** @param array{0: string, 1: int, 2?: string} $type_token */ - private function handleIsOrAs(array $type_token): void + private function handleIsOrAsOrOf(array $type_token): void { if ($this->t === 0) { $this->handleValue($type_token); @@ -726,24 +744,19 @@ private function handleIsOrAs(array $type_token): void array_pop($current_parent->children); } - if ($type_token[0] === 'as') { - $next_token = $this->t + 1 < $this->type_token_count ? $this->type_tokens[$this->t + 1] : null; - + if ($type_token[0] === 'as' || $type_token[0] === 'of') { if (!$this->current_leaf instanceof Value || !$current_parent instanceof GenericTree - || !$next_token ) { throw new TypeParseTreeException('Unexpected token ' . $type_token[0]); } $this->current_leaf = new TemplateAsTree( $this->current_leaf->value, - $next_token[0], $current_parent, ); $current_parent->children[] = $this->current_leaf; - ++$this->t; } elseif ($this->current_leaf instanceof Value) { $this->current_leaf = new TemplateIsTree( $this->current_leaf->value, @@ -771,6 +784,31 @@ private function handleValue(array $type_token): void switch ($next_token[0] ?? null) { case '<': + if (in_array( + $type_token[0], + ['callable', 'pure-callable', 'Closure', '\Closure', 'pure-Closure'], + true, + )) { + $callable_parent = new CallableTree( + $type_token[0], + $new_parent, + ); + + if ($this->parse_tree instanceof Root) { + $this->parse_tree = $callable_parent; + $this->current_leaf = $callable_parent; + } + + $new_leaf = new AnonymousFunctionGenericTree( + 'anonymous-function-generics', + $callable_parent, + ); + + ++$this->t; + + break; + } + $new_leaf = new GenericTree( $type_token[0], $new_parent, diff --git a/src/Psalm/Internal/Type/TypeParser.php b/src/Psalm/Internal/Type/TypeParser.php index 35ca41aa280..0f579ee508f 100644 --- a/src/Psalm/Internal/Type/TypeParser.php +++ b/src/Psalm/Internal/Type/TypeParser.php @@ -7,6 +7,7 @@ use Psalm\Codebase; use Psalm\Exception\TypeParseTreeException; use Psalm\Internal\Analyzer\ProjectAnalyzer; +use Psalm\Internal\Type\ParseTree\AnonymousFunctionGenericTree; use Psalm\Internal\Type\ParseTree\CallableParamTree; use Psalm\Internal\Type\ParseTree\CallableTree; use Psalm\Internal\Type\ParseTree\CallableWithReturnTypeTree; @@ -74,6 +75,7 @@ use function array_merge; use function array_pop; use function array_shift; +use function array_slice; use function array_unique; use function array_unshift; use function array_values; @@ -235,11 +237,19 @@ public static function getTypeFromTree( throw new TypeParseTreeException('Invalid return type'); } + $anonymous_template_type_map = []; + + foreach ($callable_type->templates ?? [] as $template_param) { + $anonymous_template_type_map[$template_param->param_name] = [ + 'anonymous-fn' => $template_param->as, + ]; + } + $return_type = self::getTypeFromTree( $parse_tree->children[1], $codebase, null, - $template_type_map, + array_merge($template_type_map, $anonymous_template_type_map), $type_aliases, $from_docblock, ); @@ -318,14 +328,26 @@ public static function getTypeFromTree( } if ($parse_tree instanceof TemplateAsTree) { - $result = new TTemplateParam( + if (!isset($parse_tree->children[0])) { + throw new TypeParseTreeException('TemplateAsTree does not have a child'); + } + + $tree_type = self::getTypeFromTree( + $parse_tree->children[0], + $codebase, + null, + $template_type_map, + $type_aliases, + $from_docblock, + ); + + return new TTemplateParam( $parse_tree->param_name, - new Union([new TNamedObject($parse_tree->as)]), + $tree_type instanceof Union ? $tree_type : new Union([$tree_type]), 'class-string-map', [], $from_docblock, ); - return $result; } if ($parse_tree instanceof ConditionalTree) { @@ -1212,8 +1234,58 @@ private static function getTypeFromCallableTree( bool $from_docblock ) { $params = []; + $templates = []; - foreach ($parse_tree->children as $child_tree) { + $generic_tree = null; + + if (isset($parse_tree->children[0]) && $parse_tree->children[0] instanceof AnonymousFunctionGenericTree) { + $generic_tree = $parse_tree->children[0]; + } + + foreach (null !== $generic_tree ? $generic_tree->children : [] as $child_tree) { + if ($child_tree instanceof TemplateAsTree) { + if (!isset($child_tree->children[0])) { + throw new TypeParseTreeException('TemplateAsTree does not have a child'); + } + + $tree_type = self::getTypeFromTree( + $child_tree->children[0], + $codebase, + null, + $template_type_map, + $type_aliases, + $from_docblock, + ); + + $templates[] = new TTemplateParam( + $child_tree->param_name, + $tree_type instanceof Union ? $tree_type : new Union([$tree_type]), + 'anonymous-fn', + ); + } elseif ($child_tree instanceof Value) { + $templates[] = new TTemplateParam( + $child_tree->value, + Type::getMixed(), + 'anonymous-fn', + ); + } else { + throw new TypeParseTreeException('Unable to parse generics of anonymous function'); + } + } + + $anonymous_template_type_map = []; + + foreach ($templates as $template_param) { + $anonymous_template_type_map[$template_param->param_name] = [ + 'anonymous-fn' => $template_param->as, + ]; + } + + $children = null !== $generic_tree + ? array_slice($parse_tree->children, 1) + : $parse_tree->children; + + foreach ($children as $child_tree) { $is_variadic = false; $is_optional = false; @@ -1223,7 +1295,7 @@ private static function getTypeFromCallableTree( $child_tree->children[0], $codebase, null, - $template_type_map, + array_merge($template_type_map, $anonymous_template_type_map), $type_aliases, $from_docblock, ); @@ -1242,7 +1314,7 @@ private static function getTypeFromCallableTree( $child_tree, $codebase, null, - $template_type_map, + array_merge($template_type_map, $anonymous_template_type_map), $type_aliases, $from_docblock, ); @@ -1265,11 +1337,13 @@ private static function getTypeFromCallableTree( $pure = strpos($parse_tree->value, 'pure-') === 0 ? true : null; + $function_templates = $templates === [] ? null : $templates; + if (in_array(strtolower($parse_tree->value), ['closure', '\closure', 'pure-closure'], true)) { - return new TClosure('Closure', $params, null, $pure, [], [], $from_docblock); + return new TClosure('Closure', $params, null, $pure, [], [], $from_docblock, $function_templates); } - return new TCallable('callable', $params, null, $pure, $from_docblock); + return new TCallable('callable', $params, null, $pure, $from_docblock, $function_templates); } /** diff --git a/src/Psalm/Internal/Type/TypeTokenizer.php b/src/Psalm/Internal/Type/TypeTokenizer.php index 66e463e4ccd..67a92452ce4 100644 --- a/src/Psalm/Internal/Type/TypeTokenizer.php +++ b/src/Psalm/Internal/Type/TypeTokenizer.php @@ -152,6 +152,15 @@ public static function tokenize(string $string_type, bool $ignore_space = true): $type_tokens[++$rtc] = ['', ++$i]; $was_char = false; continue; + } elseif ($was_space + && ($char === 'o') + && ($chars[$i + 1] ?? null) === 'f' + && ($chars[$i + 2] ?? null) === ' ' + ) { + $type_tokens[++$rtc] = [$char . 'f', $i - 1]; + $type_tokens[++$rtc] = ['', ++$i]; + $was_char = false; + continue; } elseif ($was_char) { $type_tokens[++$rtc] = ['', $i]; } diff --git a/src/Psalm/Internal/TypeVisitor/TypeChecker.php b/src/Psalm/Internal/TypeVisitor/TypeChecker.php index 4c2e52c916b..517cc284bd1 100644 --- a/src/Psalm/Internal/TypeVisitor/TypeChecker.php +++ b/src/Psalm/Internal/TypeVisitor/TypeChecker.php @@ -349,6 +349,7 @@ public function checkTemplateParam(TTemplateParam $atomic): void if ($this->prevent_template_covariance && strpos($atomic->defining_class, 'fn-') !== 0 && $atomic->defining_class !== 'class-string-map' + && $atomic->defining_class !== 'anonymous-fn' ) { $codebase = $this->source->getCodebase(); diff --git a/src/Psalm/Type/Atomic/CallableTrait.php b/src/Psalm/Type/Atomic/CallableTrait.php index 4ca12639b5e..03a585c7ddb 100644 --- a/src/Psalm/Type/Atomic/CallableTrait.php +++ b/src/Psalm/Type/Atomic/CallableTrait.php @@ -34,6 +34,11 @@ trait CallableTrait */ public $is_pure; + /** + * @var ?non-empty-array + */ + public $templates = null; + /** * Constructs a new instance of a generic type * @@ -98,6 +103,25 @@ public function getParamString(): string return $param_string; } + public function getTemplatesString(): ?string + { + $templates_string = ''; + if ($this->templates !== null) { + $templates_string .= '<'; + foreach ($this->templates as $i => $template) { + if ($i) { + $templates_string .= ', '; + } + + $templates_string .= $template->getId(); + } + + $templates_string .= '>'; + } + + return $templates_string; + } + public function getReturnTypeString(): string { $return_type_string = ''; @@ -113,11 +137,12 @@ public function getReturnTypeString(): string public function getKey(bool $include_extra = true): string { + $templates_string = $this->getTemplatesString(); $param_string = $this->getParamString(); $return_type_string = $this->getReturnTypeString(); return ($this->is_pure ? 'pure-' : ($this->is_pure === null ? '' : 'impure-')) - . $this->value . $param_string . $return_type_string; + . $this->value . $templates_string . $param_string . $return_type_string; } /** @@ -193,6 +218,7 @@ public function toPhpString( public function getId(bool $exact = true, bool $nested = false): string { + $templates_string = ''; $param_string = ''; $return_type_string = ''; @@ -209,6 +235,19 @@ public function getId(bool $exact = true, bool $nested = false): string $param_string .= ')'; } + if ($this->templates !== null) { + $templates_string .= '<'; + foreach ($this->templates as $i => $template) { + if ($i) { + $templates_string .= ', '; + } + + $templates_string .= $template->getId(); + } + + $templates_string .= '>'; + } + if ($this->return_type !== null) { $return_type_multiple = count($this->return_type->getAtomicTypes()) > 1; $return_type_string = ':' . ($return_type_multiple ? '(' : '') @@ -216,7 +255,7 @@ public function getId(bool $exact = true, bool $nested = false): string } return ($this->is_pure ? 'pure-' : ($this->is_pure === null ? '' : 'impure-')) - . $this->value . $param_string . $return_type_string; + . $this->value . $templates_string . $param_string . $return_type_string; } /** diff --git a/src/Psalm/Type/Atomic/TCallable.php b/src/Psalm/Type/Atomic/TCallable.php index 7ef847d4ab1..3670b3427a4 100644 --- a/src/Psalm/Type/Atomic/TCallable.php +++ b/src/Psalm/Type/Atomic/TCallable.php @@ -27,18 +27,21 @@ final class TCallable extends Atomic * Constructs a new instance of a generic type * * @param list $params + * @param ?non-empty-list $templates */ public function __construct( string $value = 'callable', ?array $params = null, ?Union $return_type = null, ?bool $is_pure = null, - bool $from_docblock = false + bool $from_docblock = false, + array $templates = null ) { $this->value = $value; $this->params = $params; $this->return_type = $return_type; $this->is_pure = $is_pure; + $this->templates = $templates; parent::__construct($from_docblock); } diff --git a/src/Psalm/Type/Atomic/TClosure.php b/src/Psalm/Type/Atomic/TClosure.php index 97949adc967..ff60d9de33b 100644 --- a/src/Psalm/Type/Atomic/TClosure.php +++ b/src/Psalm/Type/Atomic/TClosure.php @@ -27,6 +27,7 @@ final class TClosure extends TNamedObject * @param list $params * @param array $byref_uses * @param array $extra_types + * @param ?non-empty-list $templates */ public function __construct( string $value = 'callable', @@ -35,12 +36,14 @@ public function __construct( ?bool $is_pure = null, array $byref_uses = [], array $extra_types = [], - bool $from_docblock = false + bool $from_docblock = false, + array $templates = null ) { $this->params = $params; $this->return_type = $return_type; $this->is_pure = $is_pure; $this->byref_uses = $byref_uses; + $this->templates = $templates; parent::__construct( $value, false, diff --git a/tests/TypeParseTest.php b/tests/TypeParseTest.php index b65e63d2410..a9121b4321d 100644 --- a/tests/TypeParseTest.php +++ b/tests/TypeParseTest.php @@ -20,6 +20,7 @@ use ReflectionFunction; use function function_exists; +use function implode; use function mb_substr; use function print_r; use function stripos; @@ -550,6 +551,73 @@ public function testSimpleCallable(): void ); } + public function testGenericClosure(): void + { + $type = 'Closure(A, B): array{A, B}'; + + $id = implode('', [ + 'Closure', + '(A:anonymous-fn as mixed, B:anonymous-fn as mixed):', + 'list{A:anonymous-fn as mixed, B:anonymous-fn as mixed}', + ]); + + $this->assertSame($id, Type::parseString($type)->getId()); + } + + public function testGenericClosureWithConstraints(): void + { + $type = 'Closure(A, B): array{A, B}'; + + $id = implode('', [ + 'Closure', + '(A:anonymous-fn as numeric, B:anonymous-fn as mixed):', + 'list{A:anonymous-fn as numeric, B:anonymous-fn as mixed}', + ]); + + $this->assertSame($id, Type::parseString($type)->getId()); + } + + public function testGenericClosureWithGenericConstraints(): void + { + $type = 'Closure, B>(iterable, iterable): iterable'; + + $id = implode('', [ + 'Closure, B:anonymous-fn as mixed>', + '(iterable>, iterable):', + 'iterable, B:anonymous-fn as mixed}>', + ]); + + $this->assertSame($id, Type::parseString($type)->getId()); + } + + public function testNestedGenericClosure(): void + { + $type = 'callable(A): (callable(B): list{A, B})'; + + $id = implode('', [ + 'callable', + '(A:anonymous-fn as string):', + 'callable', + '(B:anonymous-fn as A:anonymous-fn as string):', + 'list{A:anonymous-fn as string, B:anonymous-fn as A:anonymous-fn as string}', + ]); + + $this->assertSame($id, Type::parseString($type)->getId()); + } + + public function testGenericClosureWithGenericParams(): void + { + $type = 'Closure(iterable, iterable): iterable'; + + $id = implode('', [ + 'Closure', + '(iterable, iterable):', + 'iterable', + ]); + + $this->assertSame($id, Type::parseString($type)->getId()); + } + public function testCallableWithoutClosingBracket(): void { $this->expectException(TypeParseTreeException::class);