diff --git a/Exception/UnsupportedPsalmVersion.php b/Exception/UnsupportedPsalmVersion.php new file mode 100644 index 0000000..00c4222 --- /dev/null +++ b/Exception/UnsupportedPsalmVersion.php @@ -0,0 +1,8 @@ +methods as $method_name => $method_storage) { + if (!$codebase->classExtends($class_storage->name, TestCase::class)) { + return null; + } + + /** @var MethodStorage $method_storage */ + foreach ($class_storage->methods as $method_name => $method_storage) { if (!$method_storage->location) { continue; } - $stmt_method = $classNode->getMethod($method_name); + $stmt_method = $class_node->getMethod($method_name); if (!$stmt_method) { throw new \RuntimeException('Failed to find ' . $method_name); @@ -60,27 +67,237 @@ public static function afterStatementAnalysis( $specials = self::getSpecials($stmt_method); + $method_id = $class_storage->name . '::' . $method_storage->cased_name; + if (!isset($specials['dataProvider'])) { continue; } foreach ($specials['dataProvider'] as $line => $provider) { - $provider_method_id = $classStorage->name . '::' . (string) $provider; + $provider_method_id = $class_storage->name . '::' . (string) $provider; - if (!$codebase->methodExists($provider_method_id)) { - $location = clone $method_storage->location; - $location->setCommentLine($line); + $provider_docblock_location = clone $method_storage->location; + $provider_docblock_location->setCommentLine($line); - IssueBuffer::accepts(new UndefinedMethod( + if (!$codebase->methodExists($provider_method_id)) { + IssueBuffer::accepts(new Issue\UndefinedMethod( 'Provider method ' . $provider_method_id . ' is not defined', - $location, + $provider_docblock_location, $provider_method_id )); + + continue; + } + + $provider_return_type = $codebase->getMethodReturnType($provider_method_id, $classStorage->name); + assert(null !== $provider_return_type); + + $provider_return_type_location = $codebase->getMethodReturnTypeLocation($provider_method_id); + assert(null !== $provider_return_type_location); + + $expected_provider_return_type = new Type\Atomic\TIterable([ + Type::combineUnionTypes(Type::getInt(), Type::getString()), + Type::getArray(), + ]); + + if (!self::isTypeContainedByType( + $codebase, + $provider_return_type, + new Type\Union([$expected_provider_return_type]) + )) { + IssueBuffer::accepts(new Issue\InvalidReturnType( + 'Providers must return ' . $expected_provider_return_type->getId() + . ', ' . $provider_return_type->getId() . ' provided', + $provider_return_type_location + )); + + continue; + } + + foreach ($provider_return_type->getTypes() as $type) { + if (!$type->isIterable($codebase)) { + IssueBuffer::accepts(new Issue\InvalidReturnType( + 'Providers must return ' . $expected_provider_return_type->getId() + . ', ' . $provider_return_type->getId() . ' provided', + $provider_return_type_location + )); + continue; + } + } + + // unionize iterable so that instead of array|Traversable + // we get iterable + + $provider_return_type = self::unionizeIterables($codebase, $provider_return_type); + + // this is basically a repetition of above $expected_provider_return_type check + // but older Psalm versions couldn't check iterable descendants (see vimeo/psalm#1359) + if (!self::isTypeContainedByType( + $codebase, + $provider_return_type->type_params[0], + $expected_provider_return_type->type_params[0] + ) || !self::isTypeContainedByType( + $codebase, + $provider_return_type->type_params[1], + $expected_provider_return_type->type_params[1] + )) { + IssueBuffer::accepts(new Issue\InvalidReturnType( + 'Providers must return ' . $expected_provider_return_type->getId() + . ', ' . $provider_return_type->getId() . ' provided', + $provider_return_type_location + )); + continue; + } + + $checkParam = function ( + Type\Union $potential_argument_type, + FunctionLikeParameter $param, + int $param_offset + ) use ( + $codebase, + $method_id, + $provider_method_id, + $provider_return_type, + $provider_docblock_location + ): void { + assert(null !== $param->type); + if (self::isTypeContainedByType($codebase, $potential_argument_type, $param->type)) { + // ok + } elseif (self::canTypeBeContainedByType($codebase, $potential_argument_type, $param->type)) { + IssueBuffer::accepts(new Issue\PossiblyInvalidArgument( + 'Argument ' . ($param_offset + 1) . ' of ' . $method_id + . ' expects ' . $param->type->getId() . ', ' + . $potential_argument_type->getId() . ' provided' + . ' by ' . $provider_method_id . '():(' . $provider_return_type->getId() . ')', + $provider_docblock_location + )); + } else { + IssueBuffer::accepts(new Issue\InvalidArgument( + 'Argument ' . ($param_offset + 1) . ' of ' . $method_id + . ' expects ' . $param->type->getId() . ', ' + . $potential_argument_type->getId() . ' provided' + . ' by ' . $provider_method_id . '():(' . $provider_return_type->getId() . ')', + $provider_docblock_location + )); + } + }; + + /** @var Type\Atomic\TArray|Type\Atomic\ObjectLike $dataset_type */ + $dataset_type = $provider_return_type->type_params[1]->getTypes()['array']; + + if ($dataset_type instanceof Type\Atomic\TArray) { + // check that all of the required (?) params accept value type + $potential_argument_type = $dataset_type->type_params[1]; + foreach ($method_storage->params as $param_offset => $param) { + $checkParam($potential_argument_type, $param, $param_offset); + } + } else { + // iterate over all params checking if corresponding value type is acceptable + // let's hope properties are sorted in array order + $potential_argument_types = array_values($dataset_type->properties); + + if (count($potential_argument_types) < $method_storage->required_param_count) { + IssueBuffer::accepts(new Issue\TooFewArguments( + 'Too few arguments for ' . $method_id + . ' - expecting ' . $method_storage->required_param_count + . ' but saw ' . count($potential_argument_types) + . ' provided by ' . $provider_method_id . '()' + . ':(' . $provider_return_type->getId() . ')', + $provider_docblock_location, + $method_id + )); + } + + + foreach ($method_storage->params as $param_offset => $param) { + if (!isset($potential_argument_types[$param_offset])) { + break; + } + $potential_argument_type = $potential_argument_types[$param_offset]; + + $checkParam($potential_argument_type, $param, $param_offset); + } } } } } + private static function isTypeContainedByType( + Codebase $codebase, + Type\Union $input_type, + Type\Union $container_type + ): bool { + if (method_exists($codebase, 'isTypeContainedByType')) { + return (bool) $codebase->isTypeContainedByType($input_type, $container_type); + } elseif (class_exists(\Psalm\Internal\Analyzer\TypeAnalyzer::class, true) + && method_exists(\Psalm\Internal\Analyzer\TypeAnalyzer::class, 'isContainedBy')) { + return \Psalm\Internal\Analyzer\TypeAnalyzer::isContainedBy($codebase, $input_type, $container_type); + } else { + throw new UnsupportedPsalmVersion(); + } + } + + private static function canTypeBeContainedByType( + Codebase $codebase, + Type\Union $input_type, + Type\Union $container_type + ): bool { + if (method_exists($codebase, 'canTypeBeContainedByType')) { + return (bool) $codebase->canTypeBeContainedByType($input_type, $container_type); + } elseif (class_exists(\Psalm\Internal\Analyzer\TypeAnalyzer::class, true) + && method_exists(\Psalm\Internal\Analyzer\TypeAnalyzer::class, 'canBeContainedBy')) { + return \Psalm\Internal\Analyzer\TypeAnalyzer::canBeContainedBy($codebase, $input_type, $container_type); + } else { + throw new UnsupportedPsalmVersion(); + } + } + + private static function unionizeIterables(Codebase $codebase, Type\Union $iterables): Type\Atomic\TIterable + { + /** @var Type\Union[] $key_types */ + $key_types = []; + + /** @var Type\Union[] $value_types */ + $value_types = []; + + foreach ($iterables->getTypes() as $type) { + if (!$type->isIterable($codebase)) { + throw new \RuntimeException('should be iterable'); + } + + if ($type instanceof Type\Atomic\TArray) { + $key_types[] = $type->type_params[0] ?? Type::getMixed(); + $value_types[] = $type->type_params[1] ?? Type::getMixed(); + } elseif ($type instanceof Type\Atomic\ObjectLike) { + $key_types[] = $type->getGenericKeyType(); + $value_types[] = $type->getGenericValueType(); + } elseif ($type instanceof Type\Atomic\TNamedObject || $type instanceof Type\Atomic\TIterable) { + $iterable_key_type = null; + $iterable_value_type = null; + + \Psalm\Internal\Analyzer\Statements\Block\ForeachAnalyzer::getKeyValueParamsForTraversableObject( + $type, + $codebase, + $iterable_key_type, + $iterable_value_type + ); + $key_types[] = $iterable_key_type ?? Type::getMixed(); + $value_types[] = $iterable_value_type ?? Type::getMixed(); + } else { + throw new \RuntimeException('unexpected type'); + } + } + + $combine = function (Type\Union $a, Type\Union $b) use ($codebase): Type\Union { + return Type::combineUnionTypes($a, $b, $codebase); + }; + + return new Type\Atomic\TIterable([ + array_reduce($key_types, $combine, new Type\Union([])), + array_reduce($value_types, $combine, new Type\Union([])) + ]); + } + private static function hasInitializers(ClassLikeStorage $storage, ClassLike $stmt): bool { diff --git a/tests/acceptance/TestCase.feature b/tests/acceptance/TestCase.feature index cf200b0..e4c9d39 100644 --- a/tests/acceptance/TestCase.feature +++ b/tests/acceptance/TestCase.feature @@ -167,3 +167,249 @@ Feature: TestCase | Type | Message | | UndefinedMethod | Provider method NS\MyTestCase::provide is not defined | And I see no other errors + + Scenario: Invalid iterable data provider is reported + Given I have the following code + """ + class MyTestCase extends TestCase + { + /** @return iterable */ + public function provide() { + yield 1; + } + /** + * @return void + * @psalm-suppress UnusedMethod + * @dataProvider provide + */ + public function testSomething(int $int) { + $this->assertEquals(1, $int); + } + } + new MyTestCase; + """ + When I run Psalm + Then I see these errors + | Type | Message | + | InvalidReturnType | Providers must return iterable>, iterable provided | + + Scenario: Valid iterable data provider is allowed + Given I have the following code + """ + class MyTestCase extends TestCase + { + /** @return iterable> */ + public function provide() { + yield [1]; + } + /** + * @return void + * @psalm-suppress UnusedMethod + * @dataProvider provide + */ + public function testSomething(int $int) { + $this->assertEquals(1, $int); + } + } + new MyTestCase; + """ + When I run Psalm + Then I see no errors + + Scenario: Invalid generator data provider is reported + Given I have the following code + """ + class MyTestCase extends TestCase + { + /** @return \Generator */ + public function provide() { + yield 1; + } + /** + * @return void + * @psalm-suppress UnusedMethod + * @dataProvider provide + */ + public function testSomething(int $int) { + $this->assertEquals(1, $int); + } + } + new MyTestCase; + """ + When I run Psalm + Then I see these errors + | Type | Message | + | InvalidReturnType | Providers must return iterable>, Generator provided | + + Scenario: Valid generator data provider is allowed + Given I have the following code + """ + class MyTestCase extends TestCase + { + /** @return \Generator,mixed,void> */ + public function provide() { + yield [1]; + } + /** + * @return void + * @psalm-suppress UnusedMethod + * @dataProvider provide + */ + public function testSomething(int $int) { + $this->assertEquals(1, $int); + } + } + new MyTestCase; + """ + When I run Psalm + Then I see no errors + + Scenario: Invalid array data provider is reported + Given I have the following code + """ + class MyTestCase extends TestCase + { + /** @return array */ + public function provide() { + return [1 => 1]; + } + /** + * @return void + * @psalm-suppress UnusedMethod + * @dataProvider provide + */ + public function testSomething(int $int) { + $this->assertEquals(1, $int); + } + } + new MyTestCase; + """ + When I run Psalm + Then I see these errors + | Type | Message | + | InvalidReturnType | Providers must return iterable>, array provided | + + Scenario: Valid array data provider is allowed + Given I have the following code + """ + class MyTestCase extends TestCase + { + /** @return array> */ + public function provide() { + return [ + "data set name" => [1], + ]; + } + /** + * @return void + * @psalm-suppress UnusedMethod + * @dataProvider provide + */ + public function testSomething(int $int) { + $this->assertEquals(1, $int); + } + } + new MyTestCase; + """ + When I run Psalm + Then I see no errors + + Scenario: Valid object data provider is allowed + Given I have the following code + """ + class MyTestCase extends TestCase + { + /** @return \ArrayObject> */ + public function provide() { + return new \ArrayObject([ + "data set name" => [1], + ]); + } + /** + * @return void + * @psalm-suppress UnusedMethod + * @dataProvider provide + */ + public function testSomething(int $int) { + $this->assertEquals(1, $int); + } + } + new MyTestCase; + """ + When I run Psalm + Then I see no errors + + Scenario: Invalid dataset shape is reported + Given I have the following code + """ + class MyTestCase extends TestCase + { + /** @return iterable */ + public function provide() { + yield "data set name" => ["str"]; + } + /** + * @return void + * @psalm-suppress UnusedMethod + * @dataProvider provide + */ + public function testSomething(int $int) { + $this->assertEquals(1, $int); + } + } + new MyTestCase; + """ + When I run Psalm + Then I see these errors + | Type | Message | + | InvalidArgument | Argument 1 of NS\MyTestCase::testSomething expects int, string provided by NS\MyTestCase::provide():(iterable) | + + Scenario: Invalid dataset array is reported + Given I have the following code + """ + class MyTestCase extends TestCase + { + /** @return iterable> */ + public function provide() { + yield "data set name" => ["str"]; + } + /** + * @return void + * @psalm-suppress UnusedMethod + * @dataProvider provide + */ + public function testSomething(int $int) { + $this->assertEquals(1, $int); + } + } + new MyTestCase; + """ + When I run Psalm + Then I see these errors + | Type | Message | + | PossiblyInvalidArgument | Argument 1 of NS\MyTestCase::testSomething expects int, string\|int provided by NS\MyTestCase::provide():(iterable>) | + + Scenario: Shape dataset with missing params is reported + Given I have the following code + """ + class MyTestCase extends TestCase + { + /** @return iterable */ + public function provide() { + yield "data set name" => [1]; + } + /** + * @return void + * @psalm-suppress UnusedMethod + * @dataProvider provide + */ + public function testSomething(int $int, int $i) { + $this->assertEquals(1, $int); + } + } + new MyTestCase; + """ + When I run Psalm + Then I see these errors + | Type | Message | + | TooFewArguments | Too few arguments for NS\MyTestCase::testSomething - expecting 2 but saw 1 provided by NS\MyTestCase::provide():(iterable) |