getCodebase(); } public function testIgnoresAreSorted(): void { $ignoredFunctions = self::$ignoredFunctions; sort($ignoredFunctions); $this->assertSame($ignoredFunctions, self::$ignoredFunctions); } public static function tearDownAfterClass(): void { self::$codebase = null; } /** * @covers \Psalm\Internal\Codebase\InternalCallMapHandler::getCallMap */ public function testGetcallmapReturnsAValidCallmap(): void { $callMap = InternalCallMapHandler::getCallMap(); self::assertArrayKeysAreStrings($callMap, "Returned CallMap has non-string keys"); self::assertArrayValuesAreArrays($callMap, "Returned CallMap has non-array values"); foreach ($callMap as $function => $signature) { self::assertArrayKeysAreZeroOrString($signature, "Function " . $function . " in returned CallMap has invalid keys"); self::assertArrayValuesAreStrings($signature, "Function " . $function . " in returned CallMap has non-string values"); foreach ($signature as $type) { self::assertStringIsParsableType($type, "Function " . $function . " in returned CallMap contains invalid type declaration " . $type); } } } /** * * @return iterable}> */ public function callMapEntryProvider(): iterable { /** * This call is needed since InternalCallMapHandler uses the singleton that is initialized by it. **/ new ProjectAnalyzer( new TestConfig(), new Providers( new FakeFileProvider(), new FakeParserCacheProvider() ) ); $callMap = InternalCallMapHandler::getCallMap(); foreach ($callMap as $function => $entry) { // Skip class methods if (strpos($function, '::') !== false || !function_exists($function)) { continue; } // Skip functions with alternate signatures if (isset($callMap["$function'1"]) || preg_match("/\'\d$/", $function)) { continue; } // if ($function != 'fprintf') continue; yield "$function: " . json_encode($entry) => [$function, $entry]; } } /** */ private function isIgnored(string $functionName): bool { /** @psalm-assert callable-string $functionName */ if (in_array($functionName, self::$ignoredFunctions)) { return true; } return false; } /** * @depends testIgnoresAreSorted * @depends testGetcallmapReturnsAValidCallmap * @dataProvider callMapEntryProvider * @coversNothing * @psalm-param callable-string $functionName */ public function testIgnoredFunctionsStillFail(string $functionName, array $callMapEntry): void { if (!$this->isIgnored($functionName)) { // Dummy assertion to mark it as passed $this->assertTrue(true); return; } $this->expectException(ExpectationFailedException::class); 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'); } 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->markTestIncomplete("Remove function '{$functionName}' from your ignores"); } /** * This function will test functions that are in the callmap AND currently defined * @coversNothing * @depends testGetcallmapReturnsAValidCallmap * @depends testIgnoresAreSorted * @dataProvider callMapEntryProvider * @psalm-param callable-string $functionName * @param array $callMapEntry */ public function testCallMapCompliesWithReflection(string $functionName, array $callMapEntry): void { if ($this->isIgnored($functionName)) { $this->markTestSkipped("Function $functionName is ignored in config"); } unset($callMapEntry[0]); /** @var array $callMapEntry */ $this->assertEntryIsCorrect($callMapEntry, $functionName); } /** * * @param array $callMapEntryWithoutReturn * @psalm-param callable-string $functionName */ private function assertEntryIsCorrect(array $callMapEntryWithoutReturn, string $functionName): void { $rF = new ReflectionFunction($functionName); /** * Parse the parameter names from the map. * @var array */ $normalizedEntries = []; foreach ($callMapEntryWithoutReturn as $key => $entry) { $normalizedKey = $key; /** * * @var array{byRef: bool, refMode: 'rw'|'w', variadic: bool, optional: bool, type: string} $normalizedEntry */ $normalizedEntry = [ 'variadic' => false, 'byRef' => false, 'optional' => false, 'type' => $entry, ]; if (strncmp($normalizedKey, '&', 1) === 0) { $normalizedEntry['byRef'] = true; $normalizedKey = substr($normalizedKey, 1); } if (strncmp($normalizedKey, '...', 3) === 0) { $normalizedEntry['variadic'] = true; $normalizedKey = substr($normalizedKey, 3); } // Read the reference mode if ($normalizedEntry['byRef']) { $parts = explode('_', $normalizedKey, 2); if (count($parts) === 2) { $normalizedEntry['refMode'] = $parts[0]; $normalizedKey = $parts[1]; } else { $normalizedEntry['refMode'] = 'rw'; } } // Strip prefixes. if (substr($normalizedKey, -1, 1) === "=") { $normalizedEntry['optional'] = true; $normalizedKey = substr($normalizedKey, 0, -1); } $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)); $this->assertParameter($normalizedEntries[$parameter->getName()], $parameter); } } /** * * @param array{byRef: bool, refMode: 'rw'|'w', variadic: bool, optional: bool, type: string} $normalizedEntry */ private function assertParameter(array $normalizedEntry, ReflectionParameter $param): void { $name = $param->getName(); $this->assertSame($param->isOptional(), $normalizedEntry['optional'], "Expected param '{$name}' to " . ($param->isOptional() ? "be" : "not be") . " optional"); $this->assertSame($param->isVariadic(), $normalizedEntry['variadic'], "Expected param '{$name}' to " . ($param->isVariadic() ? "be" : "not be") . " variadic"); $this->assertSame($param->isPassedByReference(), $normalizedEntry['byRef'], "Expected param '{$name}' to " . ($param->isPassedByReference() ? "be" : "not be") . " by reference"); $expectedType = $param->getType(); if (isset($expectedType) && !empty($normalizedEntry['type'])) { $this->assertTypeValidity($expectedType, $normalizedEntry['type'], "Param '{$name}' has incorrect type"); } } /** * Since string equality is too strict, we do some extra checking here */ private function assertTypeValidity(ReflectionType $reflected, string $specified, string $message): void { $expectedType = Reflection::getPsalmTypeFromReflectionType($reflected); try { $parsedType = Type::parseString($specified); } catch (Throwable $t) { die("Failed to parse type: $specified -- $message"); } try { $this->assertTrue(UnionTypeComparator::isContainedBy(self::$codebase, $parsedType, $expectedType), $message); } catch (InvalidArgumentException $e) { 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]); } } } }