diff --git a/.styleci.yml b/.styleci.yml index 91d210bed..a1a1caa0c 100644 --- a/.styleci.yml +++ b/.styleci.yml @@ -9,4 +9,5 @@ disabled: finder: not-name: + - Php80LanguageFeaturesTest.php - SemiReservedWordsAsMethods.php diff --git a/composer.json b/composer.json index f502090c6..43caba8c6 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ "hamcrest/hamcrest-php": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^8.5 || ^9.0" + "phpunit/phpunit": "^8.5 || ^9.3" }, "conflict": { "phpunit/phpunit": "<8.0" @@ -51,6 +51,9 @@ "test\\": "tests/" } }, + "config": { + "preferred-install": "dist" + }, "extra": { "branch-alias": { "dev-master": "1.4.x-dev" diff --git a/library/Mockery/Container.php b/library/Mockery/Container.php index 0f93aa86d..196dc996e 100644 --- a/library/Mockery/Container.php +++ b/library/Mockery/Container.php @@ -120,80 +120,72 @@ public function mock(...$args) $builder->setConstantsMap(\Mockery::getConfiguration()->getConstantsMap()); while (count($args) > 0) { - $arg = current($args); + $arg = array_shift($args); // check for multiple interfaces - if (is_string($arg) && strpos($arg, ',') && !strpos($arg, ']')) { - $interfaces = explode(',', str_replace(' ', '', $arg)); - $builder->addTargets($interfaces); - array_shift($args); - - continue; - } elseif (is_string($arg) && substr($arg, 0, 6) == 'alias:') { - $name = array_shift($args); - $name = str_replace('alias:', '', $name); - $builder->addTarget('stdClass'); - $builder->setName($name); - continue; - } elseif (is_string($arg) && substr($arg, 0, 9) == 'overload:') { - $name = array_shift($args); - $name = str_replace('overload:', '', $name); - $builder->setInstanceMock(true); - $builder->addTarget('stdClass'); - $builder->setName($name); - continue; - } elseif (is_string($arg) && substr($arg, strlen($arg)-1, 1) == ']') { - $parts = explode('[', $arg); - if (!class_exists($parts[0], true) && !interface_exists($parts[0], true)) { - throw new \Mockery\Exception('Can only create a partial mock from' - . ' an existing class or interface'); - } - $class = $parts[0]; - $parts[1] = str_replace(' ', '', $parts[1]); - $partialMethods = array_filter(explode(',', strtolower(rtrim($parts[1], ']')))); - $builder->addTarget($class); - foreach ($partialMethods as $partialMethod) { - if ($partialMethod[0] === '!') { - $builder->addBlackListedMethod(substr($partialMethod, 1)); - continue; + if (is_string($arg)) { + foreach (explode('|', $arg) as $type) { + if ($arg === 'null') { + // skip PHP 8 'null's + } elseif (strpos($type, ',') && !strpos($type, ']')) { + $interfaces = explode(',', str_replace(' ', '', $type)); + $builder->addTargets($interfaces); + } elseif (substr($type, 0, 6) == 'alias:') { + $type = str_replace('alias:', '', $type); + $builder->addTarget('stdClass'); + $builder->setName($type); + } elseif (substr($type, 0, 9) == 'overload:') { + $type = str_replace('overload:', '', $type); + $builder->setInstanceMock(true); + $builder->addTarget('stdClass'); + $builder->setName($type); + } elseif (substr($type, strlen($type)-1, 1) == ']') { + $parts = explode('[', $type); + if (!class_exists($parts[0], true) && !interface_exists($parts[0], true)) { + throw new \Mockery\Exception('Can only create a partial mock from' + . ' an existing class or interface'); + } + $class = $parts[0]; + $parts[1] = str_replace(' ', '', $parts[1]); + $partialMethods = array_filter(explode(',', strtolower(rtrim($parts[1], ']')))); + $builder->addTarget($class); + foreach ($partialMethods as $partialMethod) { + if ($partialMethod[0] === '!') { + $builder->addBlackListedMethod(substr($partialMethod, 1)); + continue; + } + $builder->addWhiteListedMethod($partialMethod); + } + } elseif (class_exists($type, true) || interface_exists($type, true) || trait_exists($type, true)) { + $builder->addTarget($type); + } elseif (!\Mockery::getConfiguration()->mockingNonExistentMethodsAllowed() && (!class_exists($type, true) && !interface_exists($type, true))) { + throw new \Mockery\Exception("Mockery can't find '$type' so can't mock it"); + } else { + if (!$this->isValidClassName($type)) { + throw new \Mockery\Exception('Class name contains invalid characters'); + } + $builder->addTarget($type); } - $builder->addWhiteListedMethod($partialMethod); - } - array_shift($args); - continue; - } elseif (is_string($arg) && (class_exists($arg, true) || interface_exists($arg, true) || trait_exists($arg, true))) { - $class = array_shift($args); - $builder->addTarget($class); - continue; - } elseif (is_string($arg) && !\Mockery::getConfiguration()->mockingNonExistentMethodsAllowed() && (!class_exists($arg, true) && !interface_exists($arg, true))) { - throw new \Mockery\Exception("Mockery can't find '$arg' so can't mock it"); - } elseif (is_string($arg)) { - if (!$this->isValidClassName($arg)) { - throw new \Mockery\Exception('Class name contains invalid characters'); + break; // unions are "sum" types and not "intersections", and so we must only process the first part } - $class = array_shift($args); - $builder->addTarget($class); - continue; } elseif (is_object($arg)) { - $partial = array_shift($args); - $builder->addTarget($partial); - continue; - } elseif (is_array($arg) && !empty($arg) && array_keys($arg) !== range(0, count($arg) - 1)) { - // if associative array - if (array_key_exists(self::BLOCKS, $arg)) { - $blocks = $arg[self::BLOCKS]; - } - unset($arg[self::BLOCKS]); - $quickdefs = array_shift($args); - continue; + $builder->addTarget($arg); } elseif (is_array($arg)) { - $constructorArgs = array_shift($args); - continue; + if (!empty($arg) && array_keys($arg) !== range(0, count($arg) - 1)) { + // if associative array + if (array_key_exists(self::BLOCKS, $arg)) { + $blocks = $arg[self::BLOCKS]; + } + unset($arg[self::BLOCKS]); + $quickdefs = $arg; + } else { + $constructorArgs = $arg; + } + } else { + throw new \Mockery\Exception( + 'Unable to parse arguments sent to ' + . get_class($this) . '::mock()' + ); } - - throw new \Mockery\Exception( - 'Unable to parse arguments sent to ' - . get_class($this) . '::mock()' - ); } $builder->addBlackListedMethods($blocks); diff --git a/library/Mockery/Generator/Parameter.php b/library/Mockery/Generator/Parameter.php index d16787b52..700953369 100644 --- a/library/Mockery/Generator/Parameter.php +++ b/library/Mockery/Generator/Parameter.php @@ -46,6 +46,8 @@ public function __call($method, array $args) * This will be null if there was no type, or it was a scalar or a union. * * @return \ReflectionClass|null + * + * @deprecated since 1.3.3 and will be removed in 2.0. */ public function getClass() { diff --git a/library/Mockery/Reflector.php b/library/Mockery/Reflector.php index e2a88a257..e6ca619a7 100644 --- a/library/Mockery/Reflector.php +++ b/library/Mockery/Reflector.php @@ -55,10 +55,10 @@ public static function getTypeHint(\ReflectionParameter $param, $withoutNullable } $type = $param->getType(); - $declaringClass = $param->getDeclaringClass()->getName(); + $declaringClass = $param->getDeclaringClass(); $typeHint = self::typeToString($type, $declaringClass); - return (!$withoutNullable && $type->allowsNull()) ? sprintf('?%s', $typeHint) : $typeHint; + return (!$withoutNullable && $type->allowsNull()) ? self::formatNullableType($typeHint) : $typeHint; } /** @@ -76,10 +76,92 @@ public static function getReturnType(\ReflectionMethod $method, $withoutNullable } $type = $method->getReturnType(); - $declaringClass = $method->getDeclaringClass()->getName(); + $declaringClass = $method->getDeclaringClass(); $typeHint = self::typeToString($type, $declaringClass); - return (!$withoutNullable && $type->allowsNull()) ? sprintf('?%s', $typeHint) : $typeHint; + return (!$withoutNullable && $type->allowsNull()) ? self::formatNullableType($typeHint) : $typeHint; + } + + /** + * Compute the legacy type hint. + * + * We return: + * - string: the legacy type hint + * - null: if there is no legacy type hint + * - false: if we must check for PHP 7+ typing + * + * @param \ReflectionParameter $param + * + * @return string|null|false + */ + private static function getLegacyTypeHint(\ReflectionParameter $param) + { + // Handle HHVM typing + if (\method_exists($param, 'getTypehintText')) { + if ($param->isArray()) { + return 'array'; + } + + if ($param->isCallable()) { + return 'callable'; + } + + $typeHint = $param->getTypehintText(); + + // throw away HHVM scalar types + if (\in_array($typeHint, array('int', 'integer', 'float', 'string', 'bool', 'boolean'), true)) { + return null; + } + + return sprintf('\\%s', $typeHint); + } + + // Handle PHP 5 typing + if (\PHP_VERSION_ID < 70000) { + if ($param->isArray()) { + return 'array'; + } + + if ($param->isCallable()) { + return 'callable'; + } + + $typeHint = self::getLegacyClassName($param); + + return $typeHint === null ? null : sprintf('\\%s', $typeHint); + } + + return false; + } + + /** + * Compute the class name using legacy APIs, if possible. + * + * This method MUST only be called on PHP 5. + * + * @param \ReflectionParameter $param + * + * @return string|null + */ + private static function getLegacyClassName(\ReflectionParameter $param) + { + try { + $class = $param->getClass(); + + $typeHint = $class === null ? null : $class->getName(); + } catch (\ReflectionException $e) { + $typeHint = null; + } + + if ($typeHint === null) { + if (preg_match('/^Parameter #[0-9]+ \[ \<(required|optional)\> (?\S+ )?.*\$' . $param->getName() . ' .*\]$/', (string) $param, $typehintMatch)) { + if (!empty($typehintMatch['typehint']) && $typehintMatch['typehint']) { + $typeHint = $typehintMatch['typehint']; + } + } + } + + return $typeHint; } /** @@ -90,7 +172,7 @@ public static function getReturnType(\ReflectionMethod $method, $withoutNullable * * @return string|null */ - private static function typeToString(\ReflectionType $type, $declaringClass) + private static function typeToString(\ReflectionType $type, \ReflectionClass $declaringClass) { // PHP 8 union types can be recursively processed if ($type instanceof \ReflectionUnionType) { @@ -102,8 +184,40 @@ private static function typeToString(\ReflectionType $type, $declaringClass) // $type must be an instance of \ReflectionNamedType $typeHint = $type->getName(); - // 'self' needs to be resolved to the name of the declaring class and - // 'static' is a special type reserved as a return type in PHP 8 - return ($type->isBuiltin() || $typeHint === 'static') ? $typeHint : sprintf('\\%s', $typeHint === 'self' ? $declaringClass : $typeHint); + // builtins and 'static' can be returned as is + if (($type->isBuiltin() || $typeHint === 'static')) { + return $typeHint; + } + + // 'self' needs to be resolved to the name of the declaring class + if ($typeHint === 'self') { + $typeHint = $declaringClass->getName(); + } + + // 'parent' needs to be resolved to the name of the parent class + if ($typeHint === 'parent') { + $typeHint = $declaringClass->getParentClass()->getName(); + } + + // class names need prefixing with a slash + return sprintf('\\%s', $typeHint); + } + + /** + * Format the given type as a nullable type. + * + * This method MUST only be called on PHP 7.1+. + * + * @param string $typeHint + * + * @return string + */ + private static function formatNullableType($typeHint) + { + if (\PHP_VERSION_ID < 80000) { + return sprintf('?%s', $typeHint); + } + + return $typeHint === 'mixed' ? 'mixed' : sprintf('%s|null', $typeHint); } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9683c4b1e..bbcfc3e3e 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -4,7 +4,12 @@ verbose="true" > - + + ./tests + ./tests/PHP80 + + + ./tests diff --git a/tests/PHP80/Php80LanguageFeaturesTest.php b/tests/PHP80/Php80LanguageFeaturesTest.php new file mode 100644 index 000000000..fbecee555 --- /dev/null +++ b/tests/PHP80/Php80LanguageFeaturesTest.php @@ -0,0 +1,107 @@ +allows()->foo($object); + + $mock->foo($object); + } + + /** @test */ + public function it_can_mock_a_class_with_a_union_argument_type_hint() + { + $mock = mock(ArgumentUnionTypeHint::class); + $object = new ArgumentUnionTypeHint(); + $mock->allows()->foo($object); + + $mock->foo($object); + } + + /** @test */ + public function it_can_mock_a_class_with_a_parent_argument_type_hint() + { + $mock = mock(ArgumentParentTypeHint::class); + $object = new ArgumentParentTypeHint(); + $mock->allows()->foo($object); + + $mock->foo($object); + } + + /** @test */ + public function it_can_mock_a_class_with_a_mixed_return_type_hint() + { + $mock = spy(ReturnTypeMixedTypeHint::class); + + $this->assertNull($mock->foo()); + } + + /** @test */ + public function it_can_mock_a_class_with_a_union_return_type_hint() + { + $mock = spy(ReturnTypeUnionTypeHint::class); + + $this->assertTrue(is_object($mock->foo())); + } + + /** @test */ + public function it_can_mock_a_class_with_a_parent_return_type_hint() + { + $mock = spy(ReturnTypeParentTypeHint::class); + + $this->assertInstanceOf(\stdClass::class, $mock->foo()); + } +} + +class ArgumentMixedTypeHint +{ + public function foo(mixed $foo) + { + } +} + +class ArgumentUnionTypeHint +{ + public function foo(string|array|self $foo) + { + } +} + +class ArgumentParentTypeHint extends \stdClass +{ + public function foo(parent $foo) + { + } +} + +class ReturnTypeMixedTypeHint +{ + public function foo(): mixed + { + } +} + +class ReturnTypeUnionTypeHint +{ + public function foo(): ReturnTypeMixedTypeHint|self + { + } +} + +class ReturnTypeParentTypeHint extends \stdClass +{ + public function foo(): parent + { + } +}