diff --git a/config.xsd b/config.xsd index 42ad433c2..ad48f587e 100644 --- a/config.xsd +++ b/config.xsd @@ -292,6 +292,7 @@ + diff --git a/dictionaries/CallMap.php b/dictionaries/CallMap.php index 39ca59e43..02baede99 100644 --- a/dictionaries/CallMap.php +++ b/dictionaries/CallMap.php @@ -7304,7 +7304,7 @@ return [ 'mb_regex_set_options' => ['string', 'options='=>'string|null'], 'mb_scrub' => ['string', 'string'=>'string', 'encoding='=>'string|null'], 'mb_send_mail' => ['bool', 'to'=>'string', 'subject'=>'string', 'message'=>'string', 'additional_headers='=>'string|array', 'additional_params='=>'string|null'], -'mb_split' => ['list', 'pattern'=>'string', 'string'=>'string', 'limit='=>'int'], +'mb_split' => ['list|false', 'pattern'=>'string', 'string'=>'string', 'limit='=>'int'], 'mb_str_split' => ['list', 'string'=>'string', 'length='=>'positive-int', 'encoding='=>'string|null'], 'mb_strcut' => ['string', 'string'=>'string', 'start'=>'int', 'length='=>'?int', 'encoding='=>'string|null'], 'mb_strimwidth' => ['string', 'string'=>'string', 'start'=>'int', 'width'=>'int', 'trim_marker='=>'string', 'encoding='=>'string|null'], @@ -10291,7 +10291,7 @@ return [ 'proc_close' => ['int', 'process'=>'resource'], 'proc_get_status' => ['array{command: string, pid: int, running: bool, signaled: bool, stopped: bool, exitcode: int, termsig: int, stopsig: int}', 'process'=>'resource'], 'proc_nice' => ['bool', 'priority'=>'int'], -'proc_open' => ['resource|false', 'cmd'=>'string|array', 'descriptorspec'=>'array', '&w_pipes'=>'resource[]', 'cwd='=>'?string', 'env='=>'?array', 'other_options='=>'array'], +'proc_open' => ['resource|false', 'command'=>'string|array', 'descriptor_spec'=>'array', '&pipes'=>'resource[]', 'cwd='=>'?string', 'env_vars='=>'?array', 'options='=>'?array'], 'proc_terminate' => ['bool', 'process'=>'resource', 'signal='=>'int'], 'projectionObj::__construct' => ['void', 'projectionString'=>'string'], 'projectionObj::getUnits' => ['int'], @@ -10567,7 +10567,7 @@ return [ 'rd_kafka_offset_tail' => ['int', 'cnt'=>'int'], 'RdKafka::addBrokers' => ['int', 'broker_list'=>'string'], 'RdKafka::flush' => ['int', 'timeout_ms'=>'int'], -'RdKafka::getMetadata' => ['RdKafka\Metadata', 'all_topics'=>'bool', 'only_topic='=>'RdKafka\Topic', 'timeout_ms'=>'int'], +'RdKafka::getMetadata' => ['RdKafka\Metadata', 'all_topics'=>'bool', 'only_topic='=>'?RdKafka\Topic', 'timeout_ms'=>'int'], 'RdKafka::getOutQLen' => ['int'], 'RdKafka::newQueue' => ['RdKafka\Queue'], 'RdKafka::newTopic' => ['RdKafka\Topic', 'topic_name'=>'string', 'topic_conf='=>'?RdKafka\TopicConf'], @@ -10582,7 +10582,7 @@ return [ 'RdKafka\Conf::setStatsCb' => ['void', 'callback'=>'callable'], 'RdKafka\Consumer::__construct' => ['void', 'conf='=>'?RdKafka\Conf'], 'RdKafka\Consumer::addBrokers' => ['int', 'broker_list'=>'string'], -'RdKafka\Consumer::getMetadata' => ['RdKafka\Metadata', 'all_topics'=>'bool', 'only_topic='=>'RdKafka\Topic', 'timeout_ms'=>'int'], +'RdKafka\Consumer::getMetadata' => ['RdKafka\Metadata', 'all_topics'=>'bool', 'only_topic='=>'?RdKafka\Topic', 'timeout_ms'=>'int'], 'RdKafka\Consumer::getOutQLen' => ['int'], 'RdKafka\Consumer::newQueue' => ['RdKafka\Queue'], 'RdKafka\Consumer::newTopic' => ['RdKafka\ConsumerTopic', 'topic_name'=>'string', 'topic_conf='=>'?RdKafka\TopicConf'], @@ -10601,7 +10601,7 @@ return [ 'RdKafka\KafkaConsumer::commitAsync' => ['void', 'message_or_offsets='=>'RdKafka\Message|RdKafka\TopicPartition[]|null'], 'RdKafka\KafkaConsumer::consume' => ['RdKafka\Message', 'timeout_ms'=>'int'], 'RdKafka\KafkaConsumer::getAssignment' => ['RdKafka\TopicPartition[]'], -'RdKafka\KafkaConsumer::getMetadata' => ['RdKafka\Metadata', 'all_topics'=>'bool', 'only_topic='=>'RdKafka\KafkaConsumerTopic', 'timeout_ms'=>'int'], +'RdKafka\KafkaConsumer::getMetadata' => ['RdKafka\Metadata', 'all_topics'=>'bool', 'only_topic='=>'?RdKafka\KafkaConsumerTopic', 'timeout_ms'=>'int'], 'RdKafka\KafkaConsumer::getSubscription' => ['array'], 'RdKafka\KafkaConsumer::subscribe' => ['void', 'topics'=>'array'], 'RdKafka\KafkaConsumer::unsubscribe' => ['void'], @@ -10629,7 +10629,7 @@ return [ 'RdKafka\Metadata\Topic::getTopic' => ['string'], 'RdKafka\Producer::__construct' => ['void', 'conf='=>'?RdKafka\Conf'], 'RdKafka\Producer::addBrokers' => ['int', 'broker_list'=>'string'], -'RdKafka\Producer::getMetadata' => ['RdKafka\Metadata', 'all_topics'=>'bool', 'only_topic='=>'RdKafka\Topic', 'timeout_ms'=>'int'], +'RdKafka\Producer::getMetadata' => ['RdKafka\Metadata', 'all_topics'=>'bool', 'only_topic='=>'?RdKafka\Topic', 'timeout_ms'=>'int'], 'RdKafka\Producer::getOutQLen' => ['int'], 'RdKafka\Producer::newQueue' => ['RdKafka\Queue'], 'RdKafka\Producer::newTopic' => ['RdKafka\ProducerTopic', 'topic_name'=>'string', 'topic_conf='=>'?RdKafka\TopicConf'], @@ -12069,7 +12069,7 @@ return [ 'session_reset' => ['bool'], 'session_save_path' => ['string', 'path='=>'string'], 'session_set_cookie_params' => ['bool', 'lifetime'=>'int', 'path='=>'?string', 'domain='=>'?string', 'secure='=>'?bool', 'httponly='=>'?bool'], -'session_set_cookie_params\'1' => ['bool', 'options'=>'array{lifetime?:int,path?:string,domain?:?string,secure?:bool,httponly?:bool,samesite?:string}'], +'session_set_cookie_params\'1' => ['bool', 'options'=>'array{lifetime?:?int,path?:?string,domain?:?string,secure?:?bool,httponly?:?bool,samesite?:?string}'], 'session_set_save_handler' => ['bool', 'open'=>'callable(string,string):bool', 'close'=>'callable():bool', 'read'=>'callable(string):string', 'write'=>'callable(string,string):bool', 'destroy'=>'callable(string):bool', 'gc'=>'callable(string):bool', 'create_sid='=>'callable():string', 'validate_sid='=>'callable(string):bool', 'update_timestamp='=>'callable(string):bool'], 'session_set_save_handler\'1' => ['bool', 'open'=>'SessionHandlerInterface', 'close='=>'bool'], 'session_start' => ['bool', 'options='=>'array'], diff --git a/dictionaries/CallMap_73_delta.php b/dictionaries/CallMap_73_delta.php index 65d5420b5..7b1d9e508 100644 --- a/dictionaries/CallMap_73_delta.php +++ b/dictionaries/CallMap_73_delta.php @@ -7,7 +7,7 @@ * The 'added' section contains function/method names from FunctionSignatureMap (And alternates, if applicable) that do not exist in php 7.2 * The 'removed' section contains the signatures that were removed in php 7.3. * The 'changed' section contains functions for which the signature has changed for php 7.3. - * Each function in the 'changed' section has an 'old' and a 'new' section, + * Each function in the 'changed' section has an 'old' and a 'new' section, * representing the function as it was in PHP 7.2 and in PHP 7.3, respectively * * @see CallMap.php @@ -42,7 +42,7 @@ return [ 'is_countable' => ['bool', 'value'=>'mixed'], 'net_get_interfaces' => ['array>|false'], 'openssl_pkey_derive' => ['string|false', 'public_key'=>'mixed', 'private_key'=>'mixed', 'key_length='=>'?int'], - 'session_set_cookie_params\'1' => ['bool', 'options'=>'array{lifetime?:int,path?:string,domain?:?string,secure?:bool,httponly?:bool,samesite?:string}'], + 'session_set_cookie_params\'1' => ['bool', 'options'=>'array{lifetime?:?int,path?:?string,domain?:?string,secure?:?bool,httponly?:?bool,samesite?:?string}'], 'setcookie\'1' => ['bool', 'name'=>'string', 'value='=>'string', 'options='=>'array'], 'setrawcookie\'1' => ['bool', 'name'=>'string', 'value='=>'string', 'options='=>'array'], 'socket_wsaprotocol_info_export' => ['string|false', 'socket'=>'resource', 'process_id'=>'int'], diff --git a/dictionaries/CallMap_74_delta.php b/dictionaries/CallMap_74_delta.php index c4d7a9e9e..83b9108bd 100644 --- a/dictionaries/CallMap_74_delta.php +++ b/dictionaries/CallMap_74_delta.php @@ -33,8 +33,8 @@ return [ 'new' => ['bool', 'hash'=>'string', 'algo'=>'int|string|null', 'options='=>'array'], ], 'proc_open' => [ - 'old' => ['resource|false', 'command'=>'string', 'descriptorspec'=>'array', '&w_pipes'=>'resource[]', 'cwd='=>'?string', 'env='=>'?array', 'other_options='=>'array'], - 'new' => ['resource|false', 'cmd'=>'string|array', 'descriptorspec'=>'array', '&w_pipes'=>'resource[]', 'cwd='=>'?string', 'env='=>'?array', 'other_options='=>'array'], + 'old' => ['resource|false', 'command'=>'string', 'descriptor_spec'=>'array', '&pipes'=>'resource[]', 'cwd='=>'?string', 'env_vars='=>'?array', 'options='=>'?array'], + 'new' => ['resource|false', 'command'=>'string|array', 'descriptor_spec'=>'array', '&pipes'=>'resource[]', 'cwd='=>'?string', 'env_vars='=>'?array', 'options='=>'?array'], ], ], 'removed' => [ diff --git a/dictionaries/CallMap_historical.php b/dictionaries/CallMap_historical.php index b8d0b83fb..9b1589575 100644 --- a/dictionaries/CallMap_historical.php +++ b/dictionaries/CallMap_historical.php @@ -5198,7 +5198,7 @@ return [ 'RarException::setUsingExceptions' => ['RarEntry', 'using_exceptions'=>'bool'], 'RdKafka::addBrokers' => ['int', 'broker_list'=>'string'], 'RdKafka::flush' => ['int', 'timeout_ms'=>'int'], - 'RdKafka::getMetadata' => ['RdKafka\Metadata', 'all_topics'=>'bool', 'only_topic='=>'RdKafka\Topic', 'timeout_ms'=>'int'], + 'RdKafka::getMetadata' => ['RdKafka\Metadata', 'all_topics'=>'bool', 'only_topic='=>'?RdKafka\Topic', 'timeout_ms'=>'int'], 'RdKafka::getOutQLen' => ['int'], 'RdKafka::newQueue' => ['RdKafka\Queue'], 'RdKafka::newTopic' => ['RdKafka\Topic', 'topic_name'=>'string', 'topic_conf='=>'?RdKafka\TopicConf'], @@ -5213,7 +5213,7 @@ return [ 'RdKafka\Conf::setStatsCb' => ['void', 'callback'=>'callable'], 'RdKafka\Consumer::__construct' => ['void', 'conf='=>'?RdKafka\Conf'], 'RdKafka\Consumer::addBrokers' => ['int', 'broker_list'=>'string'], - 'RdKafka\Consumer::getMetadata' => ['RdKafka\Metadata', 'all_topics'=>'bool', 'only_topic='=>'RdKafka\Topic', 'timeout_ms'=>'int'], + 'RdKafka\Consumer::getMetadata' => ['RdKafka\Metadata', 'all_topics'=>'bool', 'only_topic='=>'?RdKafka\Topic', 'timeout_ms'=>'int'], 'RdKafka\Consumer::getOutQLen' => ['int'], 'RdKafka\Consumer::newQueue' => ['RdKafka\Queue'], 'RdKafka\Consumer::newTopic' => ['RdKafka\ConsumerTopic', 'topic_name'=>'string', 'topic_conf='=>'?RdKafka\TopicConf'], @@ -5232,7 +5232,7 @@ return [ 'RdKafka\KafkaConsumer::commitAsync' => ['void', 'message_or_offsets='=>'RdKafka\Message|RdKafka\TopicPartition[]|null'], 'RdKafka\KafkaConsumer::consume' => ['RdKafka\Message', 'timeout_ms'=>'int'], 'RdKafka\KafkaConsumer::getAssignment' => ['RdKafka\TopicPartition[]'], - 'RdKafka\KafkaConsumer::getMetadata' => ['RdKafka\Metadata', 'all_topics'=>'bool', 'only_topic='=>'RdKafka\KafkaConsumerTopic', 'timeout_ms'=>'int'], + 'RdKafka\KafkaConsumer::getMetadata' => ['RdKafka\Metadata', 'all_topics'=>'bool', 'only_topic='=>'?RdKafka\KafkaConsumerTopic', 'timeout_ms'=>'int'], 'RdKafka\KafkaConsumer::getSubscription' => ['array'], 'RdKafka\KafkaConsumer::subscribe' => ['void', 'topics'=>'array'], 'RdKafka\KafkaConsumer::unsubscribe' => ['void'], @@ -5260,7 +5260,7 @@ return [ 'RdKafka\Metadata\Topic::getTopic' => ['string'], 'RdKafka\Producer::__construct' => ['void', 'conf='=>'?RdKafka\Conf'], 'RdKafka\Producer::addBrokers' => ['int', 'broker_list'=>'string'], - 'RdKafka\Producer::getMetadata' => ['RdKafka\Metadata', 'all_topics'=>'bool', 'only_topic='=>'RdKafka\Topic', 'timeout_ms'=>'int'], + 'RdKafka\Producer::getMetadata' => ['RdKafka\Metadata', 'all_topics'=>'bool', 'only_topic='=>'?RdKafka\Topic', 'timeout_ms'=>'int'], 'RdKafka\Producer::getOutQLen' => ['int'], 'RdKafka\Producer::newQueue' => ['RdKafka\Queue'], 'RdKafka\Producer::newTopic' => ['RdKafka\ProducerTopic', 'topic_name'=>'string', 'topic_conf='=>'?RdKafka\TopicConf'], @@ -12914,7 +12914,7 @@ return [ 'mb_regex_encoding' => ['string|bool', 'encoding='=>'string'], 'mb_regex_set_options' => ['string', 'options='=>'string'], 'mb_send_mail' => ['bool', 'to'=>'string', 'subject'=>'string', 'message'=>'string', 'additional_headers='=>'string|array', 'additional_params='=>'string'], - 'mb_split' => ['list', 'pattern'=>'string', 'string'=>'string', 'limit='=>'int'], + 'mb_split' => ['list|false', 'pattern'=>'string', 'string'=>'string', 'limit='=>'int'], 'mb_strcut' => ['string', 'string'=>'string', 'start'=>'int', 'length='=>'?int', 'encoding='=>'string'], 'mb_strimwidth' => ['string', 'string'=>'string', 'start'=>'int', 'width'=>'int', 'trim_marker='=>'string', 'encoding='=>'string'], 'mb_stripos' => ['int|false', 'haystack'=>'string', 'needle'=>'string', 'offset='=>'int', 'encoding='=>'string'], @@ -14473,7 +14473,7 @@ return [ 'proc_close' => ['int', 'process'=>'resource'], 'proc_get_status' => ['array{command: string, pid: int, running: bool, signaled: bool, stopped: bool, exitcode: int, termsig: int, stopsig: int}|false', 'process'=>'resource'], 'proc_nice' => ['bool', 'priority'=>'int'], - 'proc_open' => ['resource|false', 'command'=>'string', 'descriptorspec'=>'array', '&w_pipes'=>'resource[]', 'cwd='=>'?string', 'env='=>'?array', 'other_options='=>'array'], + 'proc_open' => ['resource|false', 'command'=>'string', 'descriptor_spec'=>'array', '&pipes'=>'resource[]', 'cwd='=>'?string', 'env_vars='=>'?array', 'options='=>'?array'], 'proc_terminate' => ['bool', 'process'=>'resource', 'signal='=>'int'], 'projectionObj::__construct' => ['void', 'projectionString'=>'string'], 'projectionObj::getUnits' => ['int'], diff --git a/docs/running_psalm/error_levels.md b/docs/running_psalm/error_levels.md index 8cd594272..a131a41f0 100644 --- a/docs/running_psalm/error_levels.md +++ b/docs/running_psalm/error_levels.md @@ -56,6 +56,7 @@ Level 5 and above allows a more non-verifiable code, and higher levels are even - [InvalidThrow](issues/InvalidThrow.md) - [LoopInvalidation](issues/LoopInvalidation.md) - [MethodSignatureMustOmitReturnType](issues/MethodSignatureMustOmitReturnType.md) + - [MethodSignatureMustProvideReturnType](issues/MethodSignatureMustProvideReturnType.md) - [MissingDependency](issues/MissingDependency.md) - [MissingFile](issues/MissingFile.md) - [MissingImmutableAnnotation](issues/MissingImmutableAnnotation.md) diff --git a/docs/running_psalm/issues.md b/docs/running_psalm/issues.md index b392b7fad..7c3da4843 100644 --- a/docs/running_psalm/issues.md +++ b/docs/running_psalm/issues.md @@ -98,6 +98,7 @@ - [LoopInvalidation](issues/LoopInvalidation.md) - [MethodSignatureMismatch](issues/MethodSignatureMismatch.md) - [MethodSignatureMustOmitReturnType](issues/MethodSignatureMustOmitReturnType.md) + - [MethodSignatureMustProvideReturnType](issues/MethodSignatureMustProvideReturnType.md) - [MismatchingDocblockParamType](issues/MismatchingDocblockParamType.md) - [MismatchingDocblockPropertyType](issues/MismatchingDocblockPropertyType.md) - [MismatchingDocblockReturnType](issues/MismatchingDocblockReturnType.md) diff --git a/docs/running_psalm/issues/MethodSignatureMustProvideReturnType.md b/docs/running_psalm/issues/MethodSignatureMustProvideReturnType.md new file mode 100644 index 000000000..80aa53c8a --- /dev/null +++ b/docs/running_psalm/issues/MethodSignatureMustProvideReturnType.md @@ -0,0 +1,17 @@ +# MethodSignatureMustProvideReturnType + +In PHP 8.1+, [most non-final internal methods now require overriding methods to declare a compatible return type, otherwise a deprecated notice is emitted during inheritance validation](https://www.php.net/manual/en/migration81.incompatible.php#migration81.incompatible.core.type-compatibility-internal). + +This issue is emitted when a method overriding a native method is defined without a return type. + +**Only if** the return type cannot be declared to keep support for PHP 7, a `#[ReturnTypeWillChange]` attribute can be added to silence the PHP deprecation notice and Psalm issue. + +```php + 'A']; + } +} +``` diff --git a/src/Psalm/Internal/Analyzer/MethodComparator.php b/src/Psalm/Internal/Analyzer/MethodComparator.php index 7e16c4269..8ffd003a0 100644 --- a/src/Psalm/Internal/Analyzer/MethodComparator.php +++ b/src/Psalm/Internal/Analyzer/MethodComparator.php @@ -20,12 +20,14 @@ use Psalm\Issue\ImplementedParamTypeMismatch; use Psalm\Issue\ImplementedReturnTypeMismatch; use Psalm\Issue\LessSpecificImplementedReturnType; use Psalm\Issue\MethodSignatureMismatch; +use Psalm\Issue\MethodSignatureMustProvideReturnType; use Psalm\Issue\MissingImmutableAnnotation; use Psalm\Issue\MoreSpecificImplementedParamType; use Psalm\Issue\OverriddenMethodAccess; use Psalm\Issue\ParamNameMismatch; use Psalm\Issue\TraitMethodSignatureMismatch; use Psalm\IssueBuffer; +use Psalm\Storage\AttributeStorage; use Psalm\Storage\ClassLikeStorage; use Psalm\Storage\FunctionLikeParameter; use Psalm\Storage\MethodStorage; @@ -34,6 +36,7 @@ use Psalm\Type\Atomic\TNull; use Psalm\Type\Atomic\TTemplateParam; use Psalm\Type\Union; +use function array_filter; use function in_array; use function strpos; use function strtolower; @@ -115,6 +118,29 @@ class MethodComparator ); } + if (!$guide_classlike_storage->user_defined + && $implementer_classlike_storage->user_defined + && $codebase->analysis_php_version_id >= 80100 + && ($guide_method_storage->return_type + || $guide_method_storage->signature_return_type + ) + && !$implementer_method_storage->signature_return_type + && !array_filter( + $implementer_method_storage->attributes, + function (AttributeStorage $s) { + return $s->fq_class_name === 'ReturnTypeWillChange'; + } + ) + ) { + IssueBuffer::maybeAdd( + new MethodSignatureMustProvideReturnType( + 'Method ' . $cased_implementer_method_id . ' must have a return type signature!', + $implementer_method_storage->location ?: $code_location + ), + $suppressed_issues + $implementer_classlike_storage->suppressed_issues + ); + } + if ($guide_method_storage->return_type && $implementer_method_storage->return_type && !$implementer_method_storage->inherited_return_type @@ -860,7 +886,14 @@ class MethodComparator $implementer_signature_return_type, $guide_signature_return_type ) - : UnionTypeComparator::isContainedByInPhp($implementer_signature_return_type, $guide_signature_return_type); + : (!$implementer_signature_return_type + && $guide_signature_return_type->isMixed() + ? false + : UnionTypeComparator::isContainedByInPhp( + $implementer_signature_return_type, + $guide_signature_return_type + ) + ); if (!$is_contained_by) { if ($codebase->analysis_php_version_id >= 8_00_00 diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/MissingMethodCallHandler.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/MissingMethodCallHandler.php index b84b8e172..d9f802643 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/MissingMethodCallHandler.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/MissingMethodCallHandler.php @@ -116,7 +116,7 @@ class MissingMethodCallHandler $found_generic_params = ClassTemplateParamCollector::collect( $codebase, - $class_storage, + $defining_class_storage, $class_storage, $method_name_lc, $lhs_type_part, @@ -160,7 +160,7 @@ class MissingMethodCallHandler $codebase, $return_type_candidate, $defining_class_storage->name, - $fq_class_name, + $lhs_type_part instanceof Atomic\TNamedObject ? $lhs_type_part : $fq_class_name, $defining_class_storage->parent_class ); @@ -281,7 +281,7 @@ class MissingMethodCallHandler $found_generic_params = ClassTemplateParamCollector::collect( $codebase, - $class_storage, + $defining_class_storage, $class_storage, $method_name_lc, $lhs_type_part, @@ -337,7 +337,7 @@ class MissingMethodCallHandler $codebase, $return_type_candidate, $defining_class_storage->name, - $fq_class_name, + $lhs_type_part instanceof Atomic\TNamedObject ? $lhs_type_part : $fq_class_name, $defining_class_storage->parent_class, true, false, @@ -424,11 +424,20 @@ class MissingMethodCallHandler ClassLikeStorage $static_class_storage, string $method_name_lc ): ?array { + if (isset($static_class_storage->declaring_pseudo_method_ids[$method_name_lc])) { + $method_id = $static_class_storage->declaring_pseudo_method_ids[$method_name_lc]; + $class_storage = $codebase->classlikes->getStorageFor($method_id->fq_class_name); + + if ($class_storage && isset($class_storage->pseudo_methods[$method_name_lc])) { + return [$class_storage->pseudo_methods[$method_name_lc], $class_storage]; + } + } + if ($pseudo_method_storage = $static_class_storage->pseudo_methods[$method_name_lc] ?? null) { return [$pseudo_method_storage, $static_class_storage]; } - $ancestors = $static_class_storage->class_implements + $static_class_storage->parent_classes; + $ancestors = $static_class_storage->class_implements; foreach ($ancestors as $fq_class_name => $_) { $class_storage = $codebase->classlikes->getStorageFor($fq_class_name); diff --git a/src/Psalm/Internal/Codebase/Populator.php b/src/Psalm/Internal/Codebase/Populator.php index 15cc6fc83..6937916be 100644 --- a/src/Psalm/Internal/Codebase/Populator.php +++ b/src/Psalm/Internal/Codebase/Populator.php @@ -399,6 +399,7 @@ class Populator $storage->pseudo_property_set_types += $trait_storage->pseudo_property_set_types; $storage->pseudo_methods += $trait_storage->pseudo_methods; + $storage->declaring_pseudo_method_ids += $trait_storage->declaring_pseudo_method_ids; } } @@ -555,6 +556,7 @@ class Populator $parent_storage->dependent_classlikes[strtolower($storage->name)] = true; $storage->pseudo_methods += $parent_storage->pseudo_methods; + $storage->declaring_pseudo_method_ids += $parent_storage->declaring_pseudo_method_ids; } private function populateInterfaceDataFromParentInterfaces( @@ -641,6 +643,7 @@ class Populator $this->inheritMethodsFromParent($storage, $parent_interface_storage); $storage->pseudo_methods += $parent_interface_storage->pseudo_methods; + $storage->declaring_pseudo_method_ids += $parent_interface_storage->declaring_pseudo_method_ids; } $storage->parent_interfaces = array_merge($parent_interfaces, $storage->parent_interfaces); diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php index 4c77a4ae1..977d7888e 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php @@ -600,11 +600,16 @@ class ClassLikeNodeScanner /** @var MethodStorage */ $pseudo_method_storage = $functionlike_node_scanner->start($method, true); + $lc_method_name = strtolower($method->name->name); if ($pseudo_method_storage->is_static) { - $storage->pseudo_static_methods[strtolower($method->name->name)] = $pseudo_method_storage; + $storage->pseudo_static_methods[$lc_method_name] = $pseudo_method_storage; } else { - $storage->pseudo_methods[strtolower($method->name->name)] = $pseudo_method_storage; + $storage->pseudo_methods[$lc_method_name] = $pseudo_method_storage; + $storage->declaring_pseudo_method_ids[$lc_method_name] = new MethodIdentifier( + $fq_classlike_name, + $lc_method_name + ); } $storage->sealed_methods = true; diff --git a/src/Psalm/Issue/MethodSignatureMustProvideReturnType.php b/src/Psalm/Issue/MethodSignatureMustProvideReturnType.php new file mode 100644 index 000000000..ab05f06c0 --- /dev/null +++ b/src/Psalm/Issue/MethodSignatureMustProvideReturnType.php @@ -0,0 +1,9 @@ + + */ + public $declaring_pseudo_method_ids = []; + /** * @var array */ diff --git a/tests/Config/PluginListTest.php b/tests/Config/PluginListTest.php index 1274eff9d..52fce5d3b 100644 --- a/tests/Config/PluginListTest.php +++ b/tests/Config/PluginListTest.php @@ -159,7 +159,7 @@ class PluginListTest extends TestCase { $plugin_list = new PluginList($this->config_file->reveal(), $this->composer_lock->reveal()); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageRegExp('/unknown plugin/i'); + $this->expectExceptionMessageMatches('/unknown plugin/i'); $plugin_list->resolvePluginClass('vendor/package'); } diff --git a/tests/Config/PluginTest.php b/tests/Config/PluginTest.php index 017d85af2..0de42408e 100644 --- a/tests/Config/PluginTest.php +++ b/tests/Config/PluginTest.php @@ -1018,7 +1018,7 @@ class PluginTest extends TestCase $this->project_analyzer->trackTaintedInputs(); $this->expectException(CodeException::class); - $this->expectExceptionMessageRegExp('/TaintedHtml/'); + $this->expectExceptionMessageMatches('/TaintedHtml/'); $this->analyzeFile($file_path, new Context()); } diff --git a/tests/CoreStubsTest.php b/tests/CoreStubsTest.php index 946e1e1d1..3719fdd03 100644 --- a/tests/CoreStubsTest.php +++ b/tests/CoreStubsTest.php @@ -18,5 +18,20 @@ class CoreStubsTest extends TestCase new RecursiveArrayIterator([], RecursiveArrayIterator::CHILD_ARRAYS_ONLY);' ]; + yield 'proc_open() named arguments' => [ + ' [], + 'error_levels' => [], + 'php_version' => '8.0', + ]; } } diff --git a/tests/DocumentationTest.php b/tests/DocumentationTest.php index fce66e37a..c24ff0462 100644 --- a/tests/DocumentationTest.php +++ b/tests/DocumentationTest.php @@ -218,6 +218,10 @@ class DocumentationTest extends TestCase $this->markTestSkipped(); } + if (strpos($error_message, 'MethodSignatureMustProvideReturnType') !== false) { + $php_version = '8.1'; + } + $this->project_analyzer->setPhpVersion($php_version, 'tests'); if ($check_references) { @@ -237,7 +241,7 @@ class DocumentationTest extends TestCase } $this->expectException(CodeException::class); - $this->expectExceptionMessageRegExp('/\b' . preg_quote($error_message, '/') . '\b/'); + $this->expectExceptionMessageMatches('/\b' . preg_quote($error_message, '/') . '\b/'); $codebase = $this->project_analyzer->getCodebase(); $codebase->config->visitPreloadedStubFiles($codebase); diff --git a/tests/FileUpdates/ErrorAfterUpdateTest.php b/tests/FileUpdates/ErrorAfterUpdateTest.php index 1f2bade42..c9805f589 100644 --- a/tests/FileUpdates/ErrorAfterUpdateTest.php +++ b/tests/FileUpdates/ErrorAfterUpdateTest.php @@ -90,7 +90,7 @@ class ErrorAfterUpdateTest extends TestCase } $this->expectException(CodeException::class); - $this->expectExceptionMessageRegExp('/\b' . preg_quote($error_message, '/') . '\b/'); + $this->expectExceptionMessageMatches('/\b' . preg_quote($error_message, '/') . '\b/'); $codebase->reloadFiles($this->project_analyzer, array_keys($end_files)); diff --git a/tests/IncludeTest.php b/tests/IncludeTest.php index dee302928..3f13695fd 100644 --- a/tests/IncludeTest.php +++ b/tests/IncludeTest.php @@ -88,7 +88,7 @@ class IncludeTest extends TestCase $config->skip_checks_on_unresolvable_includes = false; $this->expectException(CodeException::class); - $this->expectExceptionMessageRegExp('/\b' . preg_quote($error_message, '/') . '\b/'); + $this->expectExceptionMessageMatches('/\b' . preg_quote($error_message, '/') . '\b/'); $codebase->scanFiles(); diff --git a/tests/MagicMethodAnnotationTest.php b/tests/MagicMethodAnnotationTest.php index 0a9e78ef4..482658fce 100644 --- a/tests/MagicMethodAnnotationTest.php +++ b/tests/MagicMethodAnnotationTest.php @@ -819,6 +819,92 @@ class MagicMethodAnnotationTest extends TestCase consumeInt(B::bar());' ], + 'returnThisShouldKeepGenerics' => [ + ' $a */ + $a = new A(); + $b = $a->foo(); + + /** @var I $i */ + $c = $i->foo();', + [ + '$b' => 'A', + '$c' => 'I', + ] + ], + 'genericsOfInheritedMethodsShouldBeResolved' => [ + ' + */ + class A implements I + { + public function __call(string $name, array $args) {} + } + + /** + * @template E + * @extends I + */ + interface I2 extends I {} + + class B {} + + /** + * @template E + * @method E get() + */ + class C + { + public function __call(string $name, array $args) {} + } + + /** + * @template E + * @extends C + */ + class D extends C {} + + /** @var A $a */ + $a = new A(); + $b = $a->get(); + + /** @var I2 $i */ + $c = $i->get(); + + /** @var D $d */ + $d = new D(); + $e = $d->get();', + [ + '$b' => 'B', + '$c' => 'B', + '$e' => 'B', + ] + ], ]; } diff --git a/tests/MethodSignatureTest.php b/tests/MethodSignatureTest.php index 86276aa9e..81e907ab6 100644 --- a/tests/MethodSignatureTest.php +++ b/tests/MethodSignatureTest.php @@ -1568,6 +1568,37 @@ class MethodSignatureTest extends TestCase ', 'error_message' => 'MethodSignatureMismatch', ], + 'noMixedTypehintInDescendant' => [ + ' 'MethodSignatureMismatch', + [], + false, + '8.0' + ], + 'noTypehintInNativeDescendant' => [ + ' 'MethodSignatureMustProvideReturnType', + [], + false, + '8.1' + ], ]; } } diff --git a/tests/PsalmPluginTest.php b/tests/PsalmPluginTest.php index 4a33e6034..592c2d97e 100644 --- a/tests/PsalmPluginTest.php +++ b/tests/PsalmPluginTest.php @@ -150,7 +150,7 @@ class PsalmPluginTest extends TestCase $help_command = new CommandTester($this->app->find('help')); $help_command->execute(['command_name' => $command]); $output = $help_command->getDisplay(); - $this->assertRegExp('/Usage:.*$\s+' . preg_quote($command, '/') . '\b/m', $output); + $this->assertMatchesRegularExpression('/Usage:.*$\s+' . preg_quote($command, '/') . '\b/m', $output); } /** diff --git a/tests/TaintTest.php b/tests/TaintTest.php index 2fc31b0d9..4259da95d 100644 --- a/tests/TaintTest.php +++ b/tests/TaintTest.php @@ -50,7 +50,7 @@ class TaintTest extends TestCase } $this->expectException(CodeException::class); - $this->expectExceptionMessageRegExp('/\b' . preg_quote($error_message, '/') . '\b/'); + $this->expectExceptionMessageMatches('/\b' . preg_quote($error_message, '/') . '\b/'); $file_path = self::$src_dir_path . 'somefile.php'; diff --git a/tests/TestCase.php b/tests/TestCase.php index 84a50bfff..db21c93dd 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -24,7 +24,6 @@ use function defined; use function getcwd; use function ini_set; use function is_string; -use function method_exists; use const ARRAY_FILTER_USE_KEY; use const DIRECTORY_SEPARATOR; @@ -153,28 +152,6 @@ class TestCase extends BaseTestCase return $this->getName($withDataSet); } - /** - * Compatibility alias - */ - public function expectExceptionMessageRegExp(string $regexp): void - { - if (method_exists($this, 'expectExceptionMessageMatches')) { - $this->expectExceptionMessageMatches($regexp); - } else { - /** @psalm-suppress UndefinedMethod */ - parent::expectExceptionMessageRegExp($regexp); - } - } - - public static function assertRegExp(string $pattern, string $string, string $message = ''): void - { - if (method_exists(self::class, 'assertMatchesRegularExpression')) { - self::assertMatchesRegularExpression($pattern, $string, $message); - } else { - parent::assertRegExp($pattern, $string, $message); - } - } - public static function assertArrayKeysAreStrings(array $array, string $message = ''): void { $validKeys = array_filter($array, 'is_string', ARRAY_FILTER_USE_KEY); diff --git a/tests/Traits/InvalidCodeAnalysisTestTrait.php b/tests/Traits/InvalidCodeAnalysisTestTrait.php index 9dd9cbae2..ab2aa52b8 100644 --- a/tests/Traits/InvalidCodeAnalysisTestTrait.php +++ b/tests/Traits/InvalidCodeAnalysisTestTrait.php @@ -6,7 +6,7 @@ use Psalm\Config; use Psalm\Context; use Psalm\Exception\CodeException; -use function method_exists; +use function is_int; use function preg_quote; use function str_replace; use function strpos; @@ -64,11 +64,7 @@ trait InvalidCodeAnalysisTestTrait $this->expectException(CodeException::class); - if (method_exists($this, 'expectExceptionMessageMatches')) { - $this->expectExceptionMessageMatches('/\b' . preg_quote($error_message, '/') . '\b/'); - } else { - $this->expectExceptionMessageRegExp('/\b' . preg_quote($error_message, '/') . '\b/'); - } + $this->expectExceptionMessageMatches('/\b' . preg_quote($error_message, '/') . '\b/'); $codebase = $this->project_analyzer->getCodebase(); $codebase->config->visitPreloadedStubFiles($codebase); diff --git a/tests/UnusedCodeTest.php b/tests/UnusedCodeTest.php index bc0d97cad..bcf358ff3 100644 --- a/tests/UnusedCodeTest.php +++ b/tests/UnusedCodeTest.php @@ -90,7 +90,7 @@ class UnusedCodeTest extends TestCase } $this->expectException(CodeException::class); - $this->expectExceptionMessageRegExp('/\b' . preg_quote($error_message, '/') . '\b/'); + $this->expectExceptionMessageMatches('/\b' . preg_quote($error_message, '/') . '\b/'); $file_path = self::$src_dir_path . 'somefile.php'; diff --git a/tests/UnusedVariableTest.php b/tests/UnusedVariableTest.php index 76e727ed5..a1fa642d8 100644 --- a/tests/UnusedVariableTest.php +++ b/tests/UnusedVariableTest.php @@ -83,7 +83,7 @@ class UnusedVariableTest extends TestCase } $this->expectException(CodeException::class); - $this->expectExceptionMessageRegExp('/\b' . preg_quote($error_message, '/') . '\b/'); + $this->expectExceptionMessageMatches('/\b' . preg_quote($error_message, '/') . '\b/'); $this->project_analyzer->setPhpVersion('7.4', 'tests');