diff --git a/config.xsd b/config.xsd index e03e0b62369..1a29b16b27c 100644 --- a/config.xsd +++ b/config.xsd @@ -81,6 +81,7 @@ + diff --git a/dictionaries/CallMap.php b/dictionaries/CallMap.php index da4d8579020..5ae235ec2cc 100644 --- a/dictionaries/CallMap.php +++ b/dictionaries/CallMap.php @@ -10054,7 +10054,7 @@ 'Phar::delMetadata' => ['bool'], 'Phar::extractTo' => ['bool', 'pathto'=>'string', 'files='=>'string|array|null', 'overwrite='=>'bool'], 'Phar::getAlias' => ['string'], -'Phar::getMetadata' => ['mixed'], +'Phar::getMetadata' => ['mixed', 'unserializeOptions='=>'array'], 'Phar::getModified' => ['bool'], 'Phar::getPath' => ['string'], 'Phar::getSignature' => ['array{hash:string, hash_type:string}'], @@ -10122,7 +10122,7 @@ 'PharFileInfo::getCompressedSize' => ['int'], 'PharFileInfo::getContent' => ['string'], 'PharFileInfo::getCRC32' => ['int'], -'PharFileInfo::getMetadata' => ['mixed'], +'PharFileInfo::getMetadata' => ['mixed', 'unserializeOptions='=>'array'], 'PharFileInfo::getPharFlags' => ['int'], 'PharFileInfo::hasMetadata' => ['bool'], 'PharFileInfo::isCompressed' => ['bool', 'compression_type='=>'int'], diff --git a/dictionaries/CallMap_80_delta.php b/dictionaries/CallMap_80_delta.php index f561a75ea15..5453c3585b6 100644 --- a/dictionaries/CallMap_80_delta.php +++ b/dictionaries/CallMap_80_delta.php @@ -93,6 +93,14 @@ 'old' => ['bool', 'mode'=>'int'], 'new' => ['bool', 'mode'=>'int', '...args='=>'mixed'], ], + 'Phar::getMetadata' => [ + 'old' => ['mixed'], + 'new' => ['mixed', 'unserializeOptions='=>'array'], + ], + 'PharFileInfo::getMetadata' => [ + 'old' => ['mixed'], + 'new' => ['mixed', 'unserializeOptions='=>'array'], + ], 'ReflectionClass::getConstants' => [ 'old' => ['array'], 'new' => ['array', 'filter='=>'?int'], diff --git a/docs/running_psalm/configuration.md b/docs/running_psalm/configuration.md index 198ab3b974f..26861e4ada4 100644 --- a/docs/running_psalm/configuration.md +++ b/docs/running_psalm/configuration.md @@ -143,6 +143,15 @@ Setting this to `false` means that any function calls will cause Psalm to forget ``` When `true`, strings can be used as classes, meaning `$some_string::someMethod()` is allowed. If `false`, only class constant strings (of the form `Foo\Bar::class`) can stand in for classes, otherwise an `InvalidStringClass` issue is emitted. Defaults to `false`. +#### disableSuppressAll + +```xml + +``` +When `true`, disables wildcard suppression of all issues with `@psalm-suppress all`. Defaults to `false`. + #### memoizeMethodCallResults ```xml diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 555cf65851b..614c782fb9e 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -41,6 +41,10 @@ + + $storage->template_extended_count + $storage->template_extended_count + $comments[0] $stmt->props[0] @@ -279,7 +283,11 @@ - + + $storage->template_extended_count + + + $imported_type_data[3] $l[4] $r[4] $var_line_parts[0] diff --git a/src/Psalm/Config.php b/src/Psalm/Config.php index 8272c03b2a3..d99f30d3db3 100644 --- a/src/Psalm/Config.php +++ b/src/Psalm/Config.php @@ -327,6 +327,11 @@ class Config */ public $allow_string_standin_for_class = false; + /** + * @var bool + */ + public $disable_suppress_all = false; + /** * @var bool */ @@ -913,6 +918,7 @@ private static function fromXmlAndPaths( 'strictBinaryOperands' => 'strict_binary_operands', 'rememberPropertyAssignmentsAfterCall' => 'remember_property_assignments_after_call', 'allowStringToStandInForClass' => 'allow_string_standin_for_class', + 'disableSuppressAll' => 'disable_suppress_all', 'usePhpDocMethodsWithoutMagicCall' => 'use_phpdoc_method_without_magic_or_parent', 'usePhpDocPropertiesWithoutMagicCall' => 'use_phpdoc_property_without_magic_or_parent', 'memoizeMethodCallResults' => 'memoize_method_calls', diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/MethodCallReturnTypeFetcher.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/MethodCallReturnTypeFetcher.php index 2ef9cd6fbba..b94c6f4fa8b 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/MethodCallReturnTypeFetcher.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/MethodCallReturnTypeFetcher.php @@ -2,6 +2,8 @@ namespace Psalm\Internal\Analyzer\Statements\Expression\Call\Method; +use Exception; +use PDOException; use PhpParser; use Psalm\CodeLocation; use Psalm\Codebase; @@ -27,6 +29,7 @@ use Psalm\Type\Atomic\TNamedObject; use Psalm\Type\Atomic\TTemplateParam; use Psalm\Type\Union; +use Throwable; use UnexpectedValueException; use function array_filter; @@ -95,6 +98,16 @@ public static function fetch( } } + if ($premixin_method_id->method_name === 'getcode' + && $premixin_method_id->fq_class_name !== Exception::class + && in_array(Throwable::class, $class_storage->class_implements)) { + if ($premixin_method_id->fq_class_name === PDOException::class) { + return Type::getString(); + } else { + return Type::getInt(true); // TODO: Remove the flag in Psalm 5 + } + } + if ($declaring_method_id && $declaring_method_id !== $method_id) { $declaring_fq_class_name = $declaring_method_id->fq_class_name; $declaring_method_name = $declaring_method_id->method_name; 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 32bff04c9d2..561363d24b3 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php @@ -489,37 +489,16 @@ private static function handleNamedCall( $class_storage->final ); - $old_data_provider = $statements_analyzer->node_data; - - $statements_analyzer->node_data = clone $statements_analyzer->node_data; - $context->vars_in_scope['$tmp_mixin_var'] = $new_lhs_type; - $fake_method_call_expr = new VirtualMethodCall( - new VirtualVariable( - 'tmp_mixin_var', - $stmt->class->getAttributes() - ), + return self::forwardCallToInstanceMethod( + $statements_analyzer, + $stmt, $stmt_name, - $stmt->getArgs(), - $stmt->getAttributes() + $context, + 'tmp_mixin_var', + true ); - - if (MethodCallAnalyzer::analyze( - $statements_analyzer, - $fake_method_call_expr, - $context - ) === false) { - return false; - } - - $fake_method_call_type = $statements_analyzer->node_data->getType($fake_method_call_expr); - - $statements_analyzer->node_data = $old_data_provider; - - $statements_analyzer->node_data->setType($stmt, $fake_method_call_type ?? Type::getMixed()); - - return true; } } } @@ -713,6 +692,44 @@ private static function handleNamedCall( if ($pseudo_method_storage->return_type) { return true; } + } elseif ($stmt->class instanceof PhpParser\Node\Name && $stmt->class->parts[0] === 'parent' + && !$codebase->methodExists($method_id) + && !$statements_analyzer->isStatic() + ) { + // In case of parent::xxx() call on instance method context (i.e. not static context) + // with nonexistent method, we try to forward to instance method call for resolve pseudo method. + + // Use parent type as static type for the method call + $context->vars_in_scope['$tmp_parent_var'] = new Union([$lhs_type_part]); + + if (self::forwardCallToInstanceMethod( + $statements_analyzer, + $stmt, + $stmt_name, + $context, + 'tmp_parent_var' + ) === false) { + return false; + } + + // Resolve actual static return type according to caller (i.e. $this) static type + if (isset($context->vars_in_scope['$this']) + && $method_call_type = $statements_analyzer->node_data->getType($stmt) + ) { + $method_call_type = clone $method_call_type; + + foreach ($method_call_type->getAtomicTypes() as $name => $type) { + if ($type instanceof TNamedObject && $type->is_static && $type->value === $fq_class_name) { + // Replace parent&static type to actual static type + $method_call_type->removeType($name); + $method_call_type->addType($context->vars_in_scope['$this']->getSingleAtomic()); + } + } + + $statements_analyzer->node_data->setType($stmt, $method_call_type); + } + + return true; } if (!$context->check_methods) { @@ -823,37 +840,12 @@ private static function handleNamedCall( } if ($is_dynamic_this_method) { - $old_data_provider = $statements_analyzer->node_data; - - $statements_analyzer->node_data = clone $statements_analyzer->node_data; - - $fake_method_call_expr = new VirtualMethodCall( - new VirtualVariable( - 'this', - $stmt->class->getAttributes() - ), - $stmt_name, - $stmt->getArgs(), - $stmt->getAttributes() - ); - - if (MethodCallAnalyzer::analyze( + return self::forwardCallToInstanceMethod( $statements_analyzer, - $fake_method_call_expr, + $stmt, + $stmt_name, $context - ) === false) { - return false; - } - - $fake_method_call_type = $statements_analyzer->node_data->getType($fake_method_call_expr); - - $statements_analyzer->node_data = $old_data_provider; - - if ($fake_method_call_type) { - $statements_analyzer->node_data->setType($stmt, $fake_method_call_type); - } - - return true; + ); } } @@ -1089,4 +1081,59 @@ private static function findPseudoMethodAndClassStorages( return null; } + + /** + * Forward static call to instance call, using `VirtualMethodCall` and `MethodCallAnalyzer::analyze()` + * The resolved method return type will be set as type of the $stmt node. + * + * @param StatementsAnalyzer $statements_analyzer + * @param PhpParser\Node\Expr\StaticCall $stmt + * @param PhpParser\Node\Identifier $stmt_name + * @param Context $context + * @param string $virtual_var_name Temporary var name to use for create the fake MethodCall statement. + * @param bool $always_set_node_type If true, when the method has no declared typed, mixed will be set on node. + * + * @return bool Result of analysis. False if the call is invalid. + * + * @see MethodCallAnalyzer::analyze() + */ + private static function forwardCallToInstanceMethod( + StatementsAnalyzer $statements_analyzer, + PhpParser\Node\Expr\StaticCall $stmt, + PhpParser\Node\Identifier $stmt_name, + Context $context, + string $virtual_var_name = 'this', + bool $always_set_node_type = false + ): bool { + $old_data_provider = $statements_analyzer->node_data; + + $statements_analyzer->node_data = clone $statements_analyzer->node_data; + + $fake_method_call_expr = new VirtualMethodCall( + new VirtualVariable($virtual_var_name, $stmt->class->getAttributes()), + $stmt_name, + $stmt->getArgs(), + $stmt->getAttributes() + ); + + if (MethodCallAnalyzer::analyze( + $statements_analyzer, + $fake_method_call_expr, + $context + ) === false) { + return false; + } + + $fake_method_call_type = $statements_analyzer->node_data->getType($fake_method_call_expr); + + $statements_analyzer->node_data = $old_data_provider; + + if ($fake_method_call_type) { + $statements_analyzer->node_data->setType($stmt, $fake_method_call_type); + } elseif ($always_set_node_type) { + $statements_analyzer->node_data->setType($stmt, Type::getMixed()); + } + + return true; + } } diff --git a/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayMergeReturnTypeProvider.php b/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayMergeReturnTypeProvider.php index a5d40f32bd9..1697d50c389 100644 --- a/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayMergeReturnTypeProvider.php +++ b/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayMergeReturnTypeProvider.php @@ -23,6 +23,7 @@ use function count; use function is_string; use function max; +use function mb_strcut; /** * @internal @@ -47,6 +48,8 @@ public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $ev return Type::getMixed(); } + $is_replace = mb_strcut($event->getFunctionId(), 6, 7) === 'replace'; + $inner_value_types = []; $inner_key_types = []; @@ -107,7 +110,11 @@ public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $ev foreach ($unpacked_type_part->properties as $key => $type) { if (!is_string($key)) { - $generic_properties[] = $type; + if ($is_replace) { + $generic_properties[$key] = $type; + } else { + $generic_properties[] = $type; + } continue; } diff --git a/src/Psalm/IssueBuffer.php b/src/Psalm/IssueBuffer.php index 22246cad900..8377ff11db2 100644 --- a/src/Psalm/IssueBuffer.php +++ b/src/Psalm/IssueBuffer.php @@ -211,7 +211,9 @@ public static function isSuppressed(CodeIssue $e, array $suppressed_issues = []) } } - $suppress_all_position = array_search('all', $suppressed_issues); + $suppress_all_position = $config->disable_suppress_all + ? false + : array_search('all', $suppressed_issues); if ($suppress_all_position !== false) { if (is_int($suppress_all_position)) { diff --git a/src/Psalm/Storage/ClassLikeStorage.php b/src/Psalm/Storage/ClassLikeStorage.php index d8dcdf0c617..810662e0395 100644 --- a/src/Psalm/Storage/ClassLikeStorage.php +++ b/src/Psalm/Storage/ClassLikeStorage.php @@ -361,6 +361,7 @@ class ClassLikeStorage public $template_extended_params; /** + * @deprecated Will be replaced with $template_type_extends_count in Psalm v5 * @var ?int */ public $template_extended_count; diff --git a/tests/ArrayFunctionCallTest.php b/tests/ArrayFunctionCallTest.php index 9e79fc07457..6096411a73d 100644 --- a/tests/ArrayFunctionCallTest.php +++ b/tests/ArrayFunctionCallTest.php @@ -210,9 +210,9 @@ function getInts(): array{ return []; } ], 'arrayMergeIntArrays' => [ 'code' => ' [ - '$d' => 'array{0: string, 1: string, 2: string, 3: int, 4: int, 5: int}', + '$d' => 'array{0: string, 1: string, 2: string, 3: string, 4: int, 5: int, 6: int}', ], ], 'arrayMergePossiblyUndefined' => [ @@ -266,9 +266,9 @@ public function merge($a, $b): array ], 'arrayReplaceIntArrays' => [ 'code' => ' [ - '$d' => 'array{0: string, 1: string, 2: string, 3: int, 4: int, 5: int}', + '$d' => 'array{0: int, 1: int, 2: int, 3: string}', ], ], 'arrayReplacePossiblyUndefined' => [ diff --git a/tests/MagicMethodAnnotationTest.php b/tests/MagicMethodAnnotationTest.php index d024c46a752..813cc3a7dc7 100644 --- a/tests/MagicMethodAnnotationTest.php +++ b/tests/MagicMethodAnnotationTest.php @@ -819,6 +819,40 @@ function consumeInt(int $i): void {} consumeInt(B::bar());' ], + 'callUsingParent' => [ + 'code' => 'create([])); + + $d = new BlahModel(); + consumeBlah($d->create([]));' + ], 'returnThisShouldKeepGenerics' => [ 'code' => ' [], 'php_version' => '8.0' ], + 'parentMagicMethodCall' => [ + 'code' => 'create([]);', + 'assertions' => [ + '$n' => 'BlahModel', + ] + ], ]; } @@ -1537,6 +1565,16 @@ function fooOrNull(): ?Foo { 'ignored_issues' => [], 'php_version' => '8.0' ], + 'undefinedMethodOnParentCallWithMethodExistsOnSelf' => [ + 'code' => ' 'UndefinedMethod', + ], ]; } } diff --git a/tests/ReturnTypeProvider/ExceptionCodeTest.php b/tests/ReturnTypeProvider/ExceptionCodeTest.php new file mode 100644 index 00000000000..e5ada6a1c3d --- /dev/null +++ b/tests/ReturnTypeProvider/ExceptionCodeTest.php @@ -0,0 +1,56 @@ +,ignored_issues?:list,php_version?:string}> + */ + public function providerValidCodeParse(): iterable + { + yield 'RuntimeException' => [ + 'code' => 'getCode(); + } + ', + 'assertions' => [], + ]; + yield 'LogicException' => [ + 'code' => 'getCode(); + } + ', + 'assertions' => [], + ]; + yield 'PDOException' => [ + 'code' => 'getCode(); + } + ', + 'assertions' => [], + ]; + yield 'Exception' => [ + 'code' => 'getCode(); + ', + 'assertions' => ['$code' => 'int|string'], + ]; + yield 'Throwable' => [ + 'code' => 'getCode(); + ', + 'assertions' => ['$code' => 'int|string'], + ]; + } +}