diff --git a/dictionaries/CallMap.php b/dictionaries/CallMap.php index 10f9d7189c7..e0086c01973 100644 --- a/dictionaries/CallMap.php +++ b/dictionaries/CallMap.php @@ -11453,7 +11453,9 @@ 'ReflectionFunctionAbstract::getShortName' => ['string'], 'ReflectionFunctionAbstract::getStartLine' => ['int|false'], 'ReflectionFunctionAbstract::getStaticVariables' => ['array'], +'ReflectionFunctionAbstract::getTentativeReturnType' => ['?ReflectionType'], 'ReflectionFunctionAbstract::hasReturnType' => ['bool'], +'ReflectionFunctionAbstract::hasTentativeReturnType' => ['bool'], 'ReflectionFunctionAbstract::inNamespace' => ['bool'], 'ReflectionFunctionAbstract::isClosure' => ['bool'], 'ReflectionFunctionAbstract::isDeprecated' => ['bool'], diff --git a/dictionaries/CallMap_81_delta.php b/dictionaries/CallMap_81_delta.php index a22c8b4613a..e4e9579260e 100644 --- a/dictionaries/CallMap_81_delta.php +++ b/dictionaries/CallMap_81_delta.php @@ -46,6 +46,8 @@ 'ReflectionEnumUnitCase::getEnum' => ['ReflectionEnum'], 'ReflectionEnumUnitCase::getValue' => ['UnitEnum'], 'ReflectionEnumBackedCase::getBackingValue' => ['string|int'], + 'ReflectionFunctionAbstract::getTentativeReturnType' => ['?ReflectionType'], + 'ReflectionFunctionAbstract::hasTentativeReturnType' => ['bool'], 'ReflectionFunctionAbstract::isStatic' => ['bool'], 'ReflectionObject::isEnum' => ['bool'], ], diff --git a/tests/Internal/Codebase/InternalCallMapHandlerTest.php b/tests/Internal/Codebase/InternalCallMapHandlerTest.php index 198509bceab..262cc73fb95 100644 --- a/tests/Internal/Codebase/InternalCallMapHandlerTest.php +++ b/tests/Internal/Codebase/InternalCallMapHandlerTest.php @@ -2,7 +2,9 @@ namespace Psalm\Tests\Internal\Codebase; +use Exception; use InvalidArgumentException; +use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\SkippedTestError; use Psalm\Codebase; @@ -16,6 +18,7 @@ use Psalm\Tests\TestCase; use Psalm\Tests\TestConfig; use Psalm\Type; +use ReflectionException; use ReflectionFunction; use ReflectionParameter; use ReflectionType; @@ -336,6 +339,167 @@ class InternalCallMapHandlerTest extends TestCase 'zlib_encode', ]; + /** + * List of function names to ignore only for return type checks. + * + * @var list + */ + private static $ignoredReturnTypeOnlyFunctions = [ + 'bcsqrt', + 'bzopen', + 'cal_from_jd', + 'collator_get_strength', + 'curl_multi_init', + 'date_add', + 'date_date_set', + 'date_diff', + 'date_offset_get', + 'date_parse', + 'date_sub', + 'date_sun_info', + 'date_sunrise', + 'date_sunset', + 'date_time_set', + 'date_timestamp_set', + 'date_timezone_set', + 'datefmt_set_lenient', + 'dba_open', + 'dba_popen', + 'deflate_init', + 'enchant_broker_init', + 'fgetcsv', + 'filter_input_array', + 'fopen', + 'fpassthru', + 'fsockopen', + 'ftp_get_option', + 'get_declared_traits', + 'gmp_export', + 'gmp_hamdist', + 'gmp_import', + 'gzeof', + 'gzopen', + 'gzpassthru', + 'hash', + 'hash_hkdf', + 'hash_hmac', + 'iconv_get_encoding', + 'igbinary_serialize', + 'imagecolorclosest', + 'imagecolorclosestalpha', + 'imagecolorclosesthwb', + 'imagecolorexact', + 'imagecolorexactalpha', + 'imagecolorresolve', + 'imagecolorresolvealpha', + 'imagecolorset', + 'imagecolorsforindex', + 'imagecolorstotal', + 'imagecolortransparent', + 'imageloadfont', + 'imagesx', + 'imagesy', + 'imap_mailboxmsginfo', + 'imap_msgno', + 'imap_num_recent', + 'inflate_init', + 'intlcal_get', + 'intlcal_get_keyword_values_for_locale', + 'intlgregcal_set_gregorian_change', + 'intltz_get_offset', + 'jddayofweek', + 'jdtounix', + 'ldap_count_entries', + 'ldap_exop', + 'ldap_get_attributes', + 'mb_encoding_aliases', + 'metaphone', + 'mongodb\\bson\\fromjson', + 'mongodb\\bson\\fromphp', + 'mongodb\\bson\\tojson', + 'msg_get_queue', + 'mysqli_stmt_get_warnings', + 'mysqli_stmt_insert_id', + 'numfmt_create', + 'ob_list_handlers', + 'odbc_autocommit', + 'odbc_columnprivileges', + 'odbc_columns', + 'odbc_connect', + 'odbc_do', + 'odbc_exec', + 'odbc_fetch_object', + 'odbc_foreignkeys', + 'odbc_gettypeinfo', + 'odbc_pconnect', + 'odbc_prepare', + 'odbc_primarykeys', + 'odbc_specialcolumns', + 'odbc_statistics', + 'odbc_tableprivileges', + 'odbc_tables', + 'opendir', + 'openssl_random_pseudo_bytes', + 'openssl_spki_export', + 'openssl_spki_export_challenge', + 'pack', + 'parse_url', + 'passthru', + 'pcntl_exec', + 'pcntl_signal_get_handler', + 'pcntl_strerror', + 'pfsockopen', + 'pg_port', + 'pg_socket', + 'popen', + 'proc_open', + 'pspell_config_create', + 'pspell_new', + 'pspell_new_config', + 'pspell_new_personal', + 'register_shutdown_function', + 'rewinddir', + 'set_error_handler', + 'set_exception_handler', + 'shm_attach', + 'shmop_open', + 'simplexml_import_dom', + 'sleep', + 'snmp_set_oid_numeric_print', + 'socket_export_stream', + 'socket_import_stream', + 'sodium_crypto_aead_chacha20poly1305_encrypt', + 'sodium_crypto_aead_chacha20poly1305_ietf_encrypt', + 'sodium_crypto_aead_xchacha20poly1305_ietf_encrypt', + 'spl_autoload_functions', + 'stream_bucket_new', + 'stream_context_create', + 'stream_context_get_default', + 'stream_context_set_default', + 'stream_filter_append', + 'stream_filter_prepend', + 'stream_set_chunk_size', + 'stream_socket_accept', + 'stream_socket_client', + 'stream_socket_server', + 'substr', + 'substr_compare', + 'timezone_abbreviations_list', + 'timezone_offset_get', + 'tmpfile', + 'user_error', + 'xml_get_current_byte_index', + 'xml_get_current_column_number', + 'xml_get_current_line_number', + 'xml_get_error_code', + 'xml_parser_get_option', + 'yaml_parse', + 'yaml_parse_file', + 'yaml_parse_url', + 'zip_open', + 'zip_read', + ]; + /** * * @var Codebase @@ -391,7 +555,7 @@ public function testGetcallmapReturnsAValidCallmap(): void /** * - * @return iterable}> + * @return iterable}> */ public function callMapEntryProvider(): iterable { @@ -437,45 +601,59 @@ private function isIgnored(string $functionName): bool return false; } + /** + */ + private function isReturnTypeOnlyIgnored(string $functionName): bool + { + return in_array($functionName, static::$ignoredReturnTypeOnlyFunctions, true); + } + /** * @depends testIgnoresAreSortedAndUnique * @depends testGetcallmapReturnsAValidCallmap * @dataProvider callMapEntryProvider * @coversNothing * @psalm-param callable-string $functionName + * @param array $callMapEntry */ public function testIgnoredFunctionsStillFail(string $functionName, array $callMapEntry): void { - if (!$this->isIgnored($functionName)) { + $functionIgnored = $this->isIgnored($functionName); + if (!$functionIgnored && !$this->isReturnTypeOnlyIgnored($functionName)) { // Dummy assertion to mark it as passed $this->assertTrue(true); return; } - $this->expectException(ExpectationFailedException::class); + $function = new ReflectionFunction($functionName); + /** @var string $entryReturnType */ + $entryReturnType = array_shift($callMapEntry); + + if ($functionIgnored) { + try { + /** @var array $callMapEntry */ + $this->assertEntryParameters($function, $callMapEntry); + $this->assertEntryReturnType($function, $entryReturnType); + } catch (AssertionFailedError $e) { + $this->assertTrue(true); + return; + } catch (ExpectationFailedException $e) { + $this->assertTrue(true); + return; + } + $this->fail("Remove '{$functionName}' from InternalCallMapHandlerTest::\$ignoredFunctions"); + } try { - unset($callMapEntry[0]); - /** @var array $callMapEntry */ - $this->assertEntryIsCorrect($callMapEntry, $functionName); - } catch (InvalidArgumentException $t) { - // Silence this one for now. - $this->markTestSkipped('IA'); - } catch (SkippedTestError $t) { - die('this should not happen'); + $this->assertEntryReturnType($function, $entryReturnType); + } catch (AssertionFailedError $e) { + $this->assertTrue(true); + return; } catch (ExpectationFailedException $e) { - // This is good! - throw $e; - } catch (InvalidArgumentException $e) { - // This can happen if a class does not exist, we handle the message to check for this case. - if (preg_match('/^Could not get class storage for (.*)$/', $e->getMessage(), $matches) - && !class_exists($matches[1]) - ) { - die("Class mentioned in callmap does not exist: " . $matches[1]); - } + $this->assertTrue(true); + return; } - - $this->markTestIncomplete("Remove function '{$functionName}' from your ignores"); + $this->fail("Remove '{$functionName}' from InternalCallMapHandlerTest::\$ignoredReturnTypeOnlyFunctions"); } /** @@ -485,7 +663,7 @@ public function testIgnoredFunctionsStillFail(string $functionName, array $callM * @depends testIgnoresAreSortedAndUnique * @dataProvider callMapEntryProvider * @psalm-param callable-string $functionName - * @param array $callMapEntry + * @param array $callMapEntry */ public function testCallMapCompliesWithReflection(string $functionName, array $callMapEntry): void { @@ -493,27 +671,31 @@ public function testCallMapCompliesWithReflection(string $functionName, array $c $this->markTestSkipped("Function $functionName is ignored in config"); } - unset($callMapEntry[0]); + $function = new ReflectionFunction($functionName); + /** @var string $entryReturnType */ + $entryReturnType = array_shift($callMapEntry); + /** @var array $callMapEntry */ - $this->assertEntryIsCorrect($callMapEntry, $functionName); + $this->assertEntryParameters($function, $callMapEntry); + + if (!$this->isReturnTypeOnlyIgnored($functionName)) { + $this->assertEntryReturnType($function, $entryReturnType); + } } /** * - * @param array $callMapEntryWithoutReturn - * @psalm-param callable-string $functionName + * @param array $entryParameters */ - private function assertEntryIsCorrect(array $callMapEntryWithoutReturn, string $functionName): void + private function assertEntryParameters(ReflectionFunction $function, array $entryParameters): void { - $rF = new ReflectionFunction($functionName); - /** * Parse the parameter names from the map. * @var array */ $normalizedEntries = []; - foreach ($callMapEntryWithoutReturn as $key => $entry) { + foreach ($entryParameters as $key => $entry) { $normalizedKey = $key; /** * @@ -555,8 +737,8 @@ private function assertEntryIsCorrect(array $callMapEntryWithoutReturn, string $ $normalizedEntry['name'] = $normalizedKey; $normalizedEntries[$normalizedKey] = $normalizedEntry; } - foreach ($rF->getParameters() as $parameter) { - $this->assertArrayHasKey($parameter->getName(), $normalizedEntries, "Callmap is missing entry for param {$parameter->getName()} in $functionName: " . print_r($normalizedEntries, true)); + foreach ($function->getParameters() as $parameter) { + $this->assertArrayHasKey($parameter->getName(), $normalizedEntries, "Callmap is missing entry for param {$parameter->getName()} in {$function->getName()}: " . print_r($normalizedEntries, true)); $this->assertParameter($normalizedEntries[$parameter->getName()], $parameter); } } @@ -579,6 +761,26 @@ private function assertParameter(array $normalizedEntry, ReflectionParameter $pa } } + /** + * + * @psalm-suppress UndefinedMethod + */ + public function assertEntryReturnType(ReflectionFunction $function, string $entryReturnType): void + { + if (version_compare(PHP_VERSION, '8.1.0', '>=')) { + /** @var \ReflectionType|null $expectedType */ + $expectedType = $function->hasTentativeReturnType() ? $function->getTentativeReturnType() : $function->getReturnType(); + } else { + $expectedType = $function->getReturnType(); + } + if ($expectedType === null) { + $this->assertSame('', $entryReturnType, 'CallMap entry has incorrect return type'); + return; + } + + $this->assertTypeValidity($expectedType, $entryReturnType, 'CallMap entry has incorrect return type'); + } + /** * Since string equality is too strict, we do some extra checking here */ @@ -594,7 +796,7 @@ private function assertTypeValidity(ReflectionType $reflected, string $specified if (preg_match('/^Could not get class storage for (.*)$/', $e->getMessage(), $matches) && !class_exists($matches[1]) ) { - die("Class mentioned in callmap does not exist: " . $matches[1]); + $this->fail("Class used in CallMap does not exist: {$matches[1]}"); } } }