From def0489b98f78e395b62912723a9c93d9aac8c24 Mon Sep 17 00:00:00 2001 From: Bruce Weirdan Date: Sun, 11 Feb 2024 01:47:08 +0100 Subject: [PATCH 1/9] Process `@psalm-this-out` on `__construct()` as well Fixes vimeo/psalm#9649 --- .../Expression/Call/NewAnalyzer.php | 48 ++++++++++++++++++- tests/ThisOutTest.php | 20 ++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php index 1dbf404200e..db767a30339 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php @@ -11,6 +11,7 @@ use Psalm\Internal\Analyzer\ClassLikeAnalyzer; use Psalm\Internal\Analyzer\FunctionLikeAnalyzer; use Psalm\Internal\Analyzer\NamespaceAnalyzer; +use Psalm\Internal\Analyzer\Statements\Expression\Call\Method\MethodCallReturnTypeFetcher; use Psalm\Internal\Analyzer\Statements\Expression\Call\Method\MethodVisibilityAnalyzer; use Psalm\Internal\Analyzer\Statements\Expression\CallAnalyzer; use Psalm\Internal\Analyzer\Statements\ExpressionAnalyzer; @@ -21,6 +22,7 @@ use Psalm\Internal\MethodIdentifier; use Psalm\Internal\Type\TemplateResult; use Psalm\Internal\Type\TemplateStandinTypeReplacer; +use Psalm\Internal\Type\TypeExpander; use Psalm\Issue\AbstractInstantiation; use Psalm\Issue\DeprecatedClass; use Psalm\Issue\ImpureMethodCall; @@ -58,6 +60,7 @@ use function array_map; use function array_values; +use function count; use function in_array; use function md5; use function preg_match; @@ -429,6 +432,8 @@ private static function analyzeNamedConstructor( $declaring_method_id = $codebase->methods->getDeclaringMethodId($method_id); + $method_storage = null; + if ($declaring_method_id) { $method_storage = $codebase->methods->getStorage($declaring_method_id); @@ -500,6 +505,7 @@ private static function analyzeNamedConstructor( } $generic_param_types = null; + $self_out_candidate = null; if ($storage->template_types) { foreach ($storage->template_types as $template_name => $base_type) { @@ -537,9 +543,49 @@ private static function analyzeNamedConstructor( 'had_template' => true, ]); } + + if ($method_storage && $method_storage->self_out_type) { + $self_out_candidate = $method_storage->self_out_type; + + if ($template_result->lower_bounds) { + $self_out_candidate = TypeExpander::expandUnion( + $codebase, + $self_out_candidate, + $fq_class_name, + null, + $storage->parent_class, + true, + false, + false, + true, + ); + } + + $self_out_candidate = MethodCallReturnTypeFetcher::replaceTemplateTypes( + $self_out_candidate, + $template_result, + $method_id, + count($stmt->getArgs()), + $codebase, + ); + + $self_out_candidate = TypeExpander::expandUnion( + $codebase, + $self_out_candidate, + $fq_class_name, + $fq_class_name, + $storage->parent_class, + true, + false, + false, + true, + ); + $statements_analyzer->node_data->setType($stmt, $self_out_candidate); + } } - if ($generic_param_types) { + // XXX: what if we need both? + if ($generic_param_types && !$self_out_candidate) { $result_atomic_type = new TGenericObject( $fq_class_name, $generic_param_types, diff --git a/tests/ThisOutTest.php b/tests/ThisOutTest.php index a537996aeae..1deb8334285 100644 --- a/tests/ThisOutTest.php +++ b/tests/ThisOutTest.php @@ -84,6 +84,26 @@ public function getData(): array { return $this->data; } '$data3===' => 'list<2|3>', ], ], + 'provideDefaultTypeToTypeArguments' => [ + 'code' => <<<'PHP' + */ + public function __construct() {} + + /** + * @psalm-if-this-is self<'idle'> + * @psalm-this-out self<'running'> + */ + public function start(): void {} + } + $app = new App(); + PHP, + 'assertions' => [ + '$app===' => "App<'idle'>", + ], + ], ]; } } From d10e384338abc1513efb7ce70050f02b0bb0fa89 Mon Sep 17 00:00:00 2001 From: Bruce Weirdan Date: Sun, 11 Feb 2024 02:24:27 +0100 Subject: [PATCH 2/9] Report first class callables generated for unknown static methods Fixes vimeo/psalm#10170 --- .../StaticMethod/AtomicStaticCallAnalyzer.php | 14 +++++++++++++- tests/MethodCallTest.php | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php index ad9fd724f4e..0b1b187b8d7 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php @@ -30,6 +30,7 @@ use Psalm\Issue\InvalidStringClass; use Psalm\Issue\MixedMethodCall; use Psalm\Issue\UndefinedClass; +use Psalm\Issue\UndefinedMethod; use Psalm\IssueBuffer; use Psalm\Node\Expr\VirtualArray; use Psalm\Node\Expr\VirtualArrayItem; @@ -490,6 +491,7 @@ private static function handleNamedCall( $method_name_lc, ); + if ($stmt->isFirstClassCallable()) { if ($found_method_and_class_storage) { [ $method_storage ] = $found_method_and_class_storage; @@ -516,7 +518,17 @@ private static function handleNamedCall( $codebase->methods->getStorage($declaring_method_id)->pure, )]); } else { - // FIXME: perhaps Psalm should complain about nonexisting method here, or throw a logic exception? + if (IssueBuffer::accepts( + new UndefinedMethod( + 'Method ' . $method_id . ' does not exist', + new CodeLocation($statements_analyzer, $stmt), + (string) $method_id, + ), + $statements_analyzer->getSuppressedIssues(), + )) { + return false; + } + $return_type_candidate = Type::getClosure(); } } diff --git a/tests/MethodCallTest.php b/tests/MethodCallTest.php index 49eebbf7546..def613cc537 100644 --- a/tests/MethodCallTest.php +++ b/tests/MethodCallTest.php @@ -1805,6 +1805,22 @@ public function foo(callable $_a = "strlen"): void {} PHP, 'error_message' => 'TooManyArguments', ], + 'firstClassCallableWithUnknownStaticMethod' => [ + 'code' => <<<'PHP' + 'UndefinedMethod', + ], + 'firstClassCallableWithUnknownInstanceMethod' => [ + 'code' => <<<'PHP' + foo(...); + PHP, + 'error_message' => 'UndefinedMethod', + ], ]; } } From a8c093aea29552d4f34f720efaaab6876b9120ee Mon Sep 17 00:00:00 2001 From: Bruce Weirdan Date: Sun, 11 Feb 2024 02:45:24 +0100 Subject: [PATCH 3/9] Handle taking references to unspecified magic methods --- .../Call/StaticMethod/AtomicStaticCallAnalyzer.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php index 0b1b187b8d7..8e187ab391f 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php @@ -517,6 +517,19 @@ private static function handleNamedCall( $codebase->getMethodReturnType($method_id, $fq_class_name), $codebase->methods->getStorage($declaring_method_id)->pure, )]); + } elseif ($codebase->methodExists( + $call_static_method_id = new MethodIdentifier($method_id->fq_class_name, '__callstatic'), + new CodeLocation($statements_analyzer, $stmt), + null, + null, + false, + )) { + $return_type_candidate = new Union([new TClosure( + 'Closure', + null, + $codebase->getMethodReturnType($call_static_method_id, $fq_class_name), + $codebase->methods->getStorage($call_static_method_id)->pure, + )]); } else { if (IssueBuffer::accepts( new UndefinedMethod( From da6980382309f4a41756ef1e36468b46135ae723 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 12 Feb 2024 10:45:05 +0100 Subject: [PATCH 4/9] Report invalid number of arguments for psalm-taint-* --- .../PhpVisitor/Reflector/FunctionLikeDocblockParser.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php index e2a2cc5a14b..923313ec34b 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php @@ -238,6 +238,8 @@ public static function parse( if (count($param_parts) >= 2) { $info->taint_sink_params[] = ['name' => $param_parts[1], 'taint' => $param_parts[0]]; + } else { + throw new IncorrectDocblockException('@psalm-taint-sink expects 2 arguments'); } } } @@ -279,6 +281,8 @@ public static function parse( if ($param_parts[0]) { $info->taint_source_types[] = $param_parts[0]; + } else { + throw new IncorrectDocblockException('@psalm-taint-source expects 1 argument'); } } } elseif (isset($parsed_docblock->tags['return-taint'])) { From c3526b44639d2b7561dca010da1e6f4d456658dc Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 12 Feb 2024 14:04:50 +0100 Subject: [PATCH 5/9] fix test --- tests/Template/ConditionalReturnTypeTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Template/ConditionalReturnTypeTest.php b/tests/Template/ConditionalReturnTypeTest.php index 18669470af7..0dd32eac1cf 100644 --- a/tests/Template/ConditionalReturnTypeTest.php +++ b/tests/Template/ConditionalReturnTypeTest.php @@ -759,7 +759,7 @@ private function getBody() : string { * @template TSource as self::SOURCE_* * @param TSource $source * @return (TSource is "BODY" ? object|list : array) - * @psalm-taint-source + * @psalm-taint-source html */ public function getParams( string $source = self::SOURCE_GET From f5fb9498de11c06e639d31d6fa7006f01dcdbac4 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 13 Feb 2024 09:11:11 +0100 Subject: [PATCH 6/9] Use IssueBuffer::maybeAdd() instead of throwing --- .../Reflector/FunctionLikeDocblockParser.php | 14 ++++++++++++-- tests/Template/ConditionalReturnTypeTest.php | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php index 923313ec34b..7bedbf9e27f 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php @@ -239,7 +239,12 @@ public static function parse( if (count($param_parts) >= 2) { $info->taint_sink_params[] = ['name' => $param_parts[1], 'taint' => $param_parts[0]]; } else { - throw new IncorrectDocblockException('@psalm-taint-sink expects 2 arguments'); + IssueBuffer::maybeAdd( + new InvalidDocblock( + '@psalm-taint-sink expects 2 arguments', + $code_location, + ), + ); } } } @@ -282,7 +287,12 @@ public static function parse( if ($param_parts[0]) { $info->taint_source_types[] = $param_parts[0]; } else { - throw new IncorrectDocblockException('@psalm-taint-source expects 1 argument'); + IssueBuffer::maybeAdd( + new InvalidDocblock( + '@psalm-taint-source expects 1 argument', + $code_location, + ), + ); } } } elseif (isset($parsed_docblock->tags['return-taint'])) { diff --git a/tests/Template/ConditionalReturnTypeTest.php b/tests/Template/ConditionalReturnTypeTest.php index 0dd32eac1cf..cbdce780a2b 100644 --- a/tests/Template/ConditionalReturnTypeTest.php +++ b/tests/Template/ConditionalReturnTypeTest.php @@ -759,7 +759,7 @@ private function getBody() : string { * @template TSource as self::SOURCE_* * @param TSource $source * @return (TSource is "BODY" ? object|list : array) - * @psalm-taint-source html + * @psalm-taint-source input */ public function getParams( string $source = self::SOURCE_GET From b238bc7dc9ebe52c7d1f2a21cbeeaaf7abe1728e Mon Sep 17 00:00:00 2001 From: Daniil Gentili Date: Thu, 15 Feb 2024 11:51:41 +0100 Subject: [PATCH 7/9] Improve randomizer stubs --- stubs/extensions/random.phpstub | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/stubs/extensions/random.phpstub b/stubs/extensions/random.phpstub index 01cc8fb8028..27135afc376 100644 --- a/stubs/extensions/random.phpstub +++ b/stubs/extensions/random.phpstub @@ -87,10 +87,20 @@ namespace Random */ public function getBytes(int $length): string {} + /** + * @template TValue + * @param array $array + * @return list + */ public function shuffleArray(array $array): array {} public function shuffleBytes(string $bytes): string {} + /** + * @template TKey as array-key + * @param array $array + * @return list + */ public function pickArrayKeys(array $array, int $num): array {} public function __serialize(): array {} From 893b60ed0d123a19d600d5132b94adbce3957a37 Mon Sep 17 00:00:00 2001 From: Daniil Gentili Date: Thu, 15 Feb 2024 13:34:28 +0100 Subject: [PATCH 8/9] Improve parsing of psalm-type --- .../Reflector/ClassLikeNodeScanner.php | 6 ++--- tests/AnnotationTest.php | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php index 999f95df554..f43fc71f6ce 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php @@ -78,6 +78,7 @@ use function implode; use function is_int; use function is_string; +use function ltrim; use function preg_match; use function preg_replace; use function preg_split; @@ -1948,11 +1949,8 @@ private static function getTypeAliasesFromCommentLines( continue; } - if ($var_line_parts[0] === ' ') { - array_shift($var_line_parts); - } - $type_string = implode('', $var_line_parts); + $type_string = ltrim($type_string, "* \n\r"); try { $type_string = CommentAnalyzer::splitDocLine($type_string)[0]; } catch (DocblockParseException $e) { diff --git a/tests/AnnotationTest.php b/tests/AnnotationTest.php index 64ebf674b4e..4252e60b84e 100644 --- a/tests/AnnotationTest.php +++ b/tests/AnnotationTest.php @@ -513,6 +513,28 @@ function example(string $_x) : void {}', */ class A {}', ], + 'multipeLineGenericArray2' => [ + 'code' => ' + */ + class A { + /** @return TRelAlternate */ + public function ret(): array { return []; } + } + + $_ = (new A)->ret(); + ', + 'assertions' => [ + '$_===' => 'list', + ], + ], 'builtInClassInAShape' => [ 'code' => ' Date: Thu, 15 Feb 2024 23:23:40 +0100 Subject: [PATCH 9/9] Allow multiple spaces between type name and type definition --- .../Reflector/ClassLikeNodeScanner.php | 6 +++++- tests/TypeAnnotationTest.php | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php index f43fc71f6ce..828012fa790 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php @@ -1933,7 +1933,7 @@ private static function getTypeAliasesFromCommentLines( continue; } - if ($var_line_parts[0] === ' ') { + while (isset($var_line_parts[0]) && $var_line_parts[0] === ' ') { array_shift($var_line_parts); } @@ -1949,6 +1949,10 @@ private static function getTypeAliasesFromCommentLines( continue; } + while (isset($var_line_parts[0]) && $var_line_parts[0] === ' ') { + array_shift($var_line_parts); + } + $type_string = implode('', $var_line_parts); $type_string = ltrim($type_string, "* \n\r"); try { diff --git a/tests/TypeAnnotationTest.php b/tests/TypeAnnotationTest.php index 0e101137c84..89cc4f999d2 100644 --- a/tests/TypeAnnotationTest.php +++ b/tests/TypeAnnotationTest.php @@ -916,6 +916,22 @@ public function bar(array $foo): void {} } PHP, ], + 'typeWithMultipleSpaces' => [ + 'code' => <<<'PHP' +