diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b71e6e4..b28f1c3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,36 +8,45 @@ on: - '**.md' jobs: tests: + name: PHP ${{ matrix.php }} runs-on: ubuntu-latest + strategy: - fail-fast: true matrix: - php: [5.4, 5.5, 5.6, 7.0, 7.1, 7.2, 7.3, 7.4] - stability: [prefer-stable] - - name: PHP ${{ matrix.php }} + php: ['5.4', '5.5', '5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0'] steps: - - name: Checkout code + - name: Checkout Code uses: actions/checkout@v2 - - name: Cache dependencies - uses: actions/cache@v1 - with: - path: ~/.composer/cache/files - key: dependencies-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} - - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} + tools: composer:v2 coverage: none - - name: Update composer - run: composer self-update + - name: Setup Problem Matchers + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install PHP 5/7 Dependencies + uses: nick-invision/retry@v1 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer update --no-interaction --no-progress + if: "matrix.php != '8.0'" - - name: Install dependencies - run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress + - name: Install PHP 8 Dependencies + uses: nick-invision/retry@v1 + with: + timeout_minutes: 5 + max_attempts: 5 + command: | + composer require "phpunit/phpunit:^9.3" --no-update + composer update --no-interaction --no-progress --ignore-platform-req=php + php -v + if: "matrix.php == '8.0'" - - name: Execute tests - run: vendor/bin/phpunit --verbose \ No newline at end of file + - name: Execute PHPUnit + run: vendor/bin/phpunit diff --git a/composer.json b/composer.json index 31953f5..d85278a 100644 --- a/composer.json +++ b/composer.json @@ -15,11 +15,11 @@ } ], "require": { - "php": "^5.4 || ^7.0" + "php": "^5.4 || ^7.0 || ^8.0" }, "require-dev": { "jeremeamia/superclosure": "^2.0", - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0" }, "autoload": { "psr-4": { @@ -36,5 +36,9 @@ "branch-alias": { "dev-master": "3.5.x-dev" } + }, + "config": { + "preferred-install": "dist", + "sort-packages": true } } diff --git a/phpunit.xml b/phpunit.xml index caa4a28..2601f14 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,4 +1,4 @@ - + ./tests/ClosureTest.php @@ -12,6 +12,12 @@ ./tests/SignedClosureTest.php + + ./tests/NamespaceTest.php + ./tests/NamespaceUnqualifiedTest.php + ./tests/NamespaceFullyQualifiedTest.php + ./tests/NamespacePartiallyQualifiedTest.php + ./tests/ReflectionClosureTest.php @@ -25,7 +31,11 @@ ./tests/ReflectionClosure4Test.php - ./tests/ReflectionClosure5Test.php + ./tests/ReflectionClosure5Test.php + ./tests/NamespaceGroupTest.php + + + ./tests/ReflectionClosure6Test.php - \ No newline at end of file + diff --git a/src/ReflectionClosure.php b/src/ReflectionClosure.php index 204b13b..d31b814 100644 --- a/src/ReflectionClosure.php +++ b/src/ReflectionClosure.php @@ -7,6 +7,10 @@ namespace Opis\Closure; +defined('T_NAME_QUALIFIED') || define('T_NAME_QUALIFIED', -4); +defined('T_NAME_FULLY_QUALIFIED') || define('T_NAME_FULLY_QUALIFIED', -5); +defined('T_FN') || define('T_FN', -6); + use Closure; use ReflectionFunction; @@ -84,29 +88,12 @@ public function getCode() } $className = null; - $fn = false; - if (null !== $className = $this->getClosureScopeClass()) { $className = '\\' . trim($className->getName(), '\\'); } - - if($php7 = PHP_MAJOR_VERSION === 7){ - switch (PHP_MINOR_VERSION){ - case 0: - $php7_types = array('string', 'int', 'bool', 'float'); - break; - case 1: - $php7_types = array('string', 'int', 'bool', 'float', 'void'); - break; - case 2: - default: - $php7_types = array('string', 'int', 'bool', 'float', 'void', 'object'); - } - $fn = PHP_MINOR_VERSION === 4; - } - + $builtin_types = self::getBuiltinTypes(); $class_keywords = ['self', 'static', 'parent']; $ns = $this->getNamespaceName(); @@ -143,7 +130,7 @@ public function getCode() if ($token[0] === T_FUNCTION || $token[0] === T_STATIC) { $code .= $token[1]; $state = $token[0] === T_FUNCTION ? 'function' : 'static'; - } elseif ($fn && $token[0] === T_FN) { + } elseif ($token[0] === T_FN) { $isShortClosure = true; $code .= $token[1]; $state = 'closure_args'; @@ -155,7 +142,7 @@ public function getCode() if ($token[0] === T_FUNCTION) { $state = 'function'; } - } elseif ($fn && $token[0] === T_FN) { + } elseif ($token[0] === T_FN) { $isShortClosure = true; $code .= $token[1]; $state = 'closure_args'; @@ -182,7 +169,7 @@ public function getCode() if($token[0] === T_FUNCTION || $token[0] === T_STATIC){ $code = $token[1]; $state = $token[0] === T_FUNCTION ? 'function' : 'static'; - } elseif ($fn && $token[0] === T_FN) { + } elseif ($token[0] === T_FN) { $isShortClosure = true; $code .= $token[1]; $state = 'closure_args'; @@ -190,6 +177,12 @@ public function getCode() break; case 'closure_args': switch ($token[0]){ + case T_NAME_QUALIFIED: + list($id_start, $id_start_ci, $id_name) = $this->parseNameQualified($token[1]); + $context = 'args'; + $state = 'id_name'; + $lastState = 'closure_args'; + break; case T_NS_SEPARATOR: case T_STRING: $id_start = $token[1]; @@ -258,6 +251,12 @@ public function getCode() $state = 'id_name'; $lastState = 'return'; break 2; + case T_NAME_QUALIFIED: + list($id_start, $id_start_ci, $id_name) = $this->parseNameQualified($token[1]); + $context = 'return_type'; + $state = 'id_name'; + $lastState = 'return'; + break 2; case T_DOUBLE_ARROW: $code .= $token[1]; if ($isShortClosure) { @@ -364,6 +363,12 @@ public function getCode() $state = 'id_name'; $lastState = 'closure'; break 2; + case T_NAME_QUALIFIED: + list($id_start, $id_start_ci, $id_name) = $this->parseNameQualified($token[1]); + $context = 'root'; + $state = 'id_name'; + $lastState = 'closure'; + break 2; case T_NEW: $code .= $token[1]; $context = 'new'; @@ -463,6 +468,7 @@ public function getCode() $code .= $token[1]; break; case T_NS_SEPARATOR: + case T_NAME_FULLY_QUALIFIED: case T_STRING: case T_STATIC: $id_start = $token[1]; @@ -470,6 +476,10 @@ public function getCode() $id_name = ''; $state = 'id_name'; break 2; + case T_NAME_QUALIFIED: + list($id_start, $id_start_ci, $id_name) = $this->parseNameQualified($token[1]); + $state = 'id_name'; + break 2; case T_VARIABLE: $code .= $token[1]; $state = $lastState; @@ -485,10 +495,9 @@ public function getCode() break; case 'id_name': switch ($token[0]){ + case T_NAME_QUALIFIED: case T_NS_SEPARATOR: case T_STRING: - $id_name .= $token[1]; - break; case T_WHITESPACE: case T_COMMENT: case T_DOC_COMMENT: @@ -538,7 +547,7 @@ public function getCode() if (!$inside_structure) { $isUsingScope = $token[0] === T_DOUBLE_COLON; } - } elseif (!($php7 && in_array($id_start_ci, $php7_types))){ + } elseif (!(\PHP_MAJOR_VERSION >= 7 && in_array($id_start_ci, $builtin_types))){ if ($classes === null) { $classes = $this->getClasses(); } @@ -588,7 +597,7 @@ public function getCode() if (!$inside_structure && !$id_start_ci === 'static') { $isUsingScope = true; } - } elseif (!($php7 && in_array($id_start_ci, $php7_types))){ + } elseif (!(\PHP_MAJOR_VERSION >= 7 && in_array($id_start_ci, $builtin_types))){ if($classes === null){ $classes = $this->getClasses(); } @@ -646,6 +655,32 @@ public function getCode() return $this->code; } + /** + * @return array + */ + private static function getBuiltinTypes() + { + // PHP 5 + if (\PHP_MAJOR_VERSION === 5) { + return ['array', 'callable']; + } + + // PHP 8 + if (\PHP_MAJOR_VERSION === 8) { + return ['array', 'callable', 'string', 'int', 'bool', 'float', 'iterable', 'void', 'object', 'mixed', 'false', 'null']; + } + + // PHP 7 + switch (\PHP_MINOR_VERSION) { + case 0: + return ['array', 'callable', 'string', 'int', 'bool', 'float']; + case 1: + return ['array', 'callable', 'string', 'int', 'bool', 'float', 'iterable', 'void']; + default: + return ['array', 'callable', 'string', 'int', 'bool', 'float', 'iterable', 'void', 'object']; + } + } + /** * @return array */ @@ -901,6 +936,11 @@ protected function fetchItems() $name .= $token[1]; $alias = $token[1]; break; + case T_NAME_QUALIFIED: + $name .= $token[1]; + $pieces = explode('\\', $token[1]); + $alias = end($pieces); + break; case T_AS: $lastState = 'use'; $state = 'alias'; @@ -935,6 +975,11 @@ protected function fetchItems() case T_NS_SEPARATOR: $name .= $token[1]; break; + case T_NAME_QUALIFIED: + $name .= $token[1]; + $pieces = explode('\\', $token[1]); + $alias = end($pieces); + break; case T_STRING: $name .= $token[1]; $alias = $token[1]; @@ -1035,4 +1080,17 @@ protected function fetchItems() static::$constants[$key] = $constants; static::$structures[$key] = $structures; } + + private function parseNameQualified($token) + { + $pieces = explode('\\', $token); + + $id_start = array_shift($pieces); + + $id_start_ci = strtolower($id_start); + + $id_name = '\\' . implode('\\', $pieces); + + return [$id_start, $id_start_ci, $id_name]; + } } diff --git a/tests/ClosureTest.php b/tests/ClosureTest.php index 679f586..18a65be 100644 --- a/tests/ClosureTest.php +++ b/tests/ClosureTest.php @@ -230,6 +230,7 @@ public function testClosureNested() $n = function ($b) { return !$b; }; + $ns = unserialize(serialize(new SerializableClosure($n))); return $ns(false); diff --git a/tests/NamespaceFullyQualifiedTest.php b/tests/NamespaceFullyQualifiedTest.php new file mode 100644 index 0000000..3caa6c7 --- /dev/null +++ b/tests/NamespaceFullyQualifiedTest.php @@ -0,0 +1,23 @@ +assertEquals($e, $this->c($f)); + } + + protected function c(Closure $closure) + { + $r = new ReflectionClosure($closure); + + return $r->getCode(); + } +} diff --git a/tests/NamespaceGroupTest.php b/tests/NamespaceGroupTest.php new file mode 100644 index 0000000..b4b6912 --- /dev/null +++ b/tests/NamespaceGroupTest.php @@ -0,0 +1,27 @@ + new Forest; + $e = 'fn(): \Foo\Baz\Qux\Forest => new \Foo\Baz\Qux\Forest'; + $this->assertEquals($e, $this->c($f)); + } + + protected function c(Closure $closure) + { + $r = new ReflectionClosure($closure); + + return $r->getCode(); + } +} diff --git a/tests/NamespacePartiallyQualifiedTest.php b/tests/NamespacePartiallyQualifiedTest.php new file mode 100644 index 0000000..fb8bfe0 --- /dev/null +++ b/tests/NamespacePartiallyQualifiedTest.php @@ -0,0 +1,24 @@ +assertEquals($e, $this->c($f)); + } + + protected function c(Closure $closure) + { + $r = new ReflectionClosure($closure); + + return $r->getCode(); + } +} diff --git a/tests/NamespaceTest.php b/tests/NamespaceTest.php new file mode 100644 index 0000000..1d6d49b --- /dev/null +++ b/tests/NamespaceTest.php @@ -0,0 +1,34 @@ +s($closure); + + $executable(); + } + + protected function s($closure) + { + if ($closure instanceof Closure) { + $closure = new SerializableClosure($closure); + } + + return unserialize(serialize($closure))->getClosure(); + } +} diff --git a/tests/NamespaceUnqualifiedTest.php b/tests/NamespaceUnqualifiedTest.php new file mode 100644 index 0000000..d276480 --- /dev/null +++ b/tests/NamespaceUnqualifiedTest.php @@ -0,0 +1,23 @@ +assertEquals($e, $this->c($f)); + } + + protected function c(Closure $closure) + { + $r = new ReflectionClosure($closure); + + return $r->getCode(); + } +} diff --git a/tests/ReflectionClosure6Test.php b/tests/ReflectionClosure6Test.php new file mode 100644 index 0000000..7d6ed30 --- /dev/null +++ b/tests/ReflectionClosure6Test.php @@ -0,0 +1,117 @@ +getCode(); + } + + protected function s($closure) + { + $closure = new SerializableClosure($closure); + + return unserialize(serialize($closure))->getClosure(); + } + + public function testUnionTypes() + { + $f1 = fn(): string|int|false|Bar|null => 1; + $e1 = 'fn(): string|int|false|\Opis\Closure\Test\Bar|null => 1'; + + $f2 = fn(): \Foo|\Bar => 1; + $e2 = 'fn(): \Foo|\Bar => 1'; + + $f3 = fn(): int|false => false; + $e3 = 'fn(): int|false => false'; + + $f4 = function (): null|MyClass|ClassAlias|Relative\Ns\ClassName|\Absolute\Ns\ClassName { return null; }; + $e4 = 'function (): null|\Opis\Closure\Test\MyClass|\Some\ClassName|\Opis\Closure\Test\Relative\Ns\ClassName|\Absolute\Ns\ClassName { return null; }'; + + $this->assertEquals($e1, $this->c($f1)); + $this->assertEquals($e2, $this->c($f2)); + $this->assertEquals($e3, $this->c($f3)); + $this->assertEquals($e4, $this->c($f4)); + + self::assertTrue(true); + } + + public function testMixedType() + { + $f1 = function (): mixed { return 42; }; + $e1 = 'function (): mixed { return 42; }'; + + $this->assertEquals($e1, $this->c($f1)); + } + + public function testNullsafeOperator() + { + $f1 = function () { $obj = new \stdClass(); return $obj?->invalid(); }; + $e1 = 'function () { $obj = new \stdClass(); return $obj?->invalid(); }'; + + $this->assertEquals($e1, $this->c($f1)); + } + + public function testTraillingComma() + { + $f1 = function (string $param,) {}; + $e1 = 'function (string $param,) {}'; + + $this->assertEquals($e1, $this->c($f1)); + } + + public function testNamedParameter() + { + $f1 = function(string $firstName, string $lastName) { return $firstName . ' ' . $lastName;}; + + $unserialized = $this->s($f1); + + $this->assertEquals('Marco Deleu', $unserialized( + lastName: 'Deleu', + firstName: 'Marco' + )); + } + + public function testConstructorPropertyPromotion() + { + $class = new PropertyPromotion('public', 'protected', 'private'); + + $f1 = fn() => $class; + + $object = $this->s($f1)(); + + $this->assertEquals('public', $object->public); + $this->assertEquals('protected', $object->getProtected()); + $this->assertEquals('private', $object->getPrivate()); + } +} + +class PropertyPromotion +{ + public function __construct( + public string $public, + protected string $protected, + private string $private, + ) {} + + public function getProtected(): string + { + return $this->protected; + } + + public function getPrivate(): string + { + return $this->private; + } +} diff --git a/tests/SignedClosureTest.php b/tests/SignedClosureTest.php index e0bdddb..61cc1bd 100644 --- a/tests/SignedClosureTest.php +++ b/tests/SignedClosureTest.php @@ -7,15 +7,19 @@ namespace Opis\Closure\Test; +use Opis\Closure\SecurityException; use Opis\Closure\SerializableClosure; class SignedClosureTest extends ClosureTest { - /** - * @expectedException \Opis\Closure\SecurityException - */ public function testSecureClosureIntegrityFail() { + if (method_exists($this, 'expectException')) { + $this->expectException('\Opis\Closure\SecurityException'); + } else { + $this->setExpectedException('\Opis\Closure\SecurityException'); + } + $closure = function(){ /*x*/ }; @@ -27,11 +31,14 @@ public function testSecureClosureIntegrityFail() unserialize($value); } - /** - * @expectedException \Opis\Closure\SecurityException - */ public function testJsonSecureClosureIntegrityFail() { + if (method_exists($this, 'expectException')) { + $this->expectException('\Opis\Closure\SecurityException'); + } else { + $this->setExpectedException('\Opis\Closure\SecurityException'); + } + $closure = function(){ /*x*/ }; @@ -43,11 +50,14 @@ public function testJsonSecureClosureIntegrityFail() unserialize($value); } - /** - * @expectedException \Opis\Closure\SecurityException - */ public function testUnsecuredClosureWithSecurityProvider() { + if (method_exists($this, 'expectException')) { + $this->expectException('\Opis\Closure\SecurityException'); + } else { + $this->setExpectedException('\Opis\Closure\SecurityException'); + } + SerializableClosure::removeSecurityProvider(); $closure = function(){ @@ -59,11 +69,14 @@ public function testUnsecuredClosureWithSecurityProvider() unserialize($value); } - /** - * @expectedException \Opis\Closure\SecurityException - */ public function testJsonUnsecuredClosureWithSecurityProvider() { + if (method_exists($this, 'expectException')) { + $this->expectException('\Opis\Closure\SecurityException'); + } else { + $this->setExpectedException('\Opis\Closure\SecurityException'); + } + SerializableClosure::removeSecurityProvider(); $closure = function(){ @@ -103,11 +116,14 @@ public function testJsonSecuredClosureWithoutSecuriyProvider() $this->assertTrue($closure()); } - /** - * @expectedException \Opis\Closure\SecurityException - */ public function testInvalidSecuredClosureWithoutSecuriyProvider() { + if (method_exists($this, 'expectException')) { + $this->expectException('\Opis\Closure\SecurityException'); + } else { + $this->setExpectedException('\Opis\Closure\SecurityException'); + } + SerializableClosure::setSecretKey('secret'); $closure = function(){ /*x*/ @@ -119,11 +135,14 @@ public function testInvalidSecuredClosureWithoutSecuriyProvider() unserialize($value); } - /** - * @expectedException \Opis\Closure\SecurityException - */ public function testInvalidJsonSecuredClosureWithoutSecuriyProvider() { + if (method_exists($this, 'expectException')) { + $this->expectException('\Opis\Closure\SecurityException'); + } else { + $this->setExpectedException('\Opis\Closure\SecurityException'); + } + SerializableClosure::setSecretKey('secret'); $closure = function(){ /*x*/