diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e8ed8f3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +; This file is for unifying the coding style for different editors and IDEs. +; More information at http://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes index ca0e475..7b5e0e7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,8 @@ +.editorconfig export-ignore .gitattributes export-ignore .gitignore export-ignore -.travis.yml export-ignore phpunit.xml export-ignore +psalm.xml export-ignore README.md export-ignore +.github export-ignore tests/ export-ignore diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..689b999 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: BenMorel diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..323ea04 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: CI + +on: + push: + pull_request: + +env: + PSALM_PHP_VERSION: "8.5" + COVERAGE_PHP_VERSION: "8.5" + +jobs: + psalm: + name: Psalm + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PSALM_PHP_VERSION }} + + - name: Install composer dependencies + uses: ramsey/composer-install@v3 + + - name: Run Psalm + run: vendor/bin/psalm --show-info=false --no-progress + + coding-standard: + name: Coding Standard + uses: brick/coding-standard/.github/workflows/coding-standard.yml@v4 + with: + working-directory: "tools/ecs" + + phpunit: + name: PHPUnit + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php-version: + - "8.2" + - "8.3" + - "8.4" + - "8.5" + deps: + - "highest" + include: + - php-version: "8.2" + deps: "lowest" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: pcov + + - name: Install composer dependencies + uses: ramsey/composer-install@v3 + with: + dependency-versions: ${{ matrix.deps }} + + - name: Run PHPUnit + run: vendor/bin/phpunit + if: ${{ matrix.php-version != env.COVERAGE_PHP_VERSION }} + + - name: Run PHPUnit with coverage + run: vendor/bin/phpunit --coverage-clover clover.xml + if: ${{ matrix.php-version == env.COVERAGE_PHP_VERSION }} + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + if: ${{ matrix.php-version == env.COVERAGE_PHP_VERSION }} diff --git a/.gitignore b/.gitignore index 99d85f6..d9ac457 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ /vendor /composer.lock -/.phpunit.result.cache -/.idea +/.phpunit.cache + +/tools/* +!/tools/ecs/composer.json +!/tools/ecs/ecs.php diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 26a633d..0000000 --- a/.travis.yml +++ /dev/null @@ -1,17 +0,0 @@ -language: php - -php: - - 7.1 - - 7.2 - - 7.3 - - 7.4snapshot - -before_script: - - composer install - -script: - - mkdir -p build/logs - - vendor/bin/phpunit --coverage-clover build/logs/clover.xml - -after_script: - - vendor/bin/php-coveralls -v diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..923e9c3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,157 @@ +# Changelog + +## [0.7.0](https://github.com/brick/varexporter/releases/tag/0.7.0) - 2026-01-06 + +✨ **New features** + +- Added `VarExporter::INLINE_LITERAL_LIST`, similar to `INLINE_SCALAR_LIST` but includes enum values + +πŸ’₯ **BC breaks** + +- Minimum PHP version is now `8.2` + +## [0.6.0](https://github.com/brick/varexporter/releases/tag/0.6.0) - 2025-02-20 + +πŸ’₯ **BC breaks** + +- Minimum PHP version is now `8.1` (#39) + +✨ **New features** + +- Support for exporting `match` constructs in closures (#38) + +Thanks to @reinfi! + +## [0.5.0](https://github.com/brick/varexporter/releases/tag/0.5.0) - 2024-05-10 + +✨ **Compatibility** + +- Added compatibility with `nikic/php-parser` `5.x` +- Removed compatibility with `nikic/php-parser` `4.x` + +πŸ’₯ **BC breaks** + +- deprecated constant `VarExporter::INLINE_NUMERIC_SCALAR_ARRAY` has been removed, please use `INLINE_SCALAR_LIST` instead + +## [0.4.0](https://github.com/brick/varexporter/releases/tag/0.4.0) - 2023-09-01 + +Minimum PHP version is now `7.4`. No breaking changes. + +## [0.3.8](https://github.com/brick/varexporter/releases/tag/0.3.8) - 2023-01-22 + +✨ **New feature** + +- Support for PHP 8.1 `readonly` properties (#27, #28) + +Thanks @AnnaDamm! + +## [0.3.7](https://github.com/brick/varexporter/releases/tag/0.3.7) - 2022-06-30 + +✨ **New feature** + +- New option: `VarExporter::INLINE_ARRAY` + +πŸ—‘οΈ **Deprecated** + +- The `VarExporter::INLINE_NUMERIC_SCALAR_ARRAY` is deprecated, please use `INLINE_SCALAR_LIST` instead + +## [0.3.6](https://github.com/brick/varexporter/releases/tag/0.3.6) - 2022-06-15 + +✨ **New feature** + +Support for PHP 8.1 enums (#23). + +Thanks @Jacobs63! + +## [0.3.5](https://github.com/brick/varexporter/releases/tag/0.3.5) - 2021-02-10 + +✨ **New feature** + +Support for controlling the base indentation level (#17). + +Thanks @ADmad! + +## [0.3.4](https://github.com/brick/varexporter/releases/tag/0.3.4) - 2021-02-07 + +✨ **New feature** + +Support for trailing comma in non-inline arrays, with the `TRAILING_COMMA_IN_ARRAY` flag (#16). + +Thanks @ADmad! + +## [0.3.3](https://github.com/brick/varexporter/releases/tag/0.3.3) - 2020-12-24 + +πŸ› **Bug fix** + +- Exporting an object with numeric dynamic properties would lead to a `TypeError` + +## [0.3.2](https://github.com/brick/varexporter/releases/tag/0.3.2) - 2020-03-13 + +✨ **New feature** + +Support for exporting internal classes implementing `__set_state()`: + +- `DateTime` +- `DateTimeImmutable` +- `DateTimeZone` +- `DateInterval` +- `DatePeriod` + +Thanks @GameplayJDK! + +## [0.3.1](https://github.com/brick/varexporter/releases/tag/0.3.1) - 2020-01-23 + +✨ **New features** + +- Support for closures with `use()` using the `CLOSURE_SNAPSHOT_USE` option (#7) +- Support for arrow functions in PHP 7.4 (#8) + +Thanks to @jasny for his awesome work! + +## [0.3.0](https://github.com/brick/varexporter/releases/tag/0.3.0) - 2019-12-24 + +Minimum PHP version is now `7.2`. No breaking changes. + +## [0.2.1](https://github.com/brick/varexporter/releases/tag/0.2.1) - 2019-04-16 + +✨ **New option**: `VarExporter::INLINE_NUMERIC_SCALAR_ARRAY` (#3) + +Formats numeric arrays containing only scalar values on a single line. + +## [0.2.0](https://github.com/brick/varexporter/releases/tag/0.2.0) - 2019-04-09 + +✨ **New feature** + +- Experimental support for closures πŸŽ‰ + +πŸ’₯ **Minor BC break** + +- `export()` does not throw an exception anymore when encountering a `Closure`. + To get the old behaviour back, use the `NO_CLOSURES` option. + +## [0.1.2](https://github.com/brick/varexporter/releases/tag/0.1.2) - 2019-04-08 + +πŸ› **Bug fixes** + +- Static properties in custom classes were wrongly includedβ€”`unset()`β€”in the output + +✨ **Improvements** + +- Circular references are now detected, and throw an `ExportException` instead of erroring. + +## [0.1.1](https://github.com/brick/varexporter/releases/tag/0.1.1) - 2019-04-08 + +πŸ› **Bug fixes** + +- Single-letter properties were wrongly exported using `->{'x'}` notation. + +✨ **Improvements** + +- Exception messages now contain the path (array keys / object properties) to the failure: + + > `[foo][bar][0]` Type "resource" is not supported. + +## [0.1.0](https://github.com/brick/varexporter/releases/tag/0.1.0) - 2019-04-07 + +First release. + diff --git a/README.md b/README.md index ccf4f25..0571437 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ A powerful and pretty replacement for PHP's `var_export()`. -[![Build Status](https://secure.travis-ci.org/brick/varexporter.svg?branch=master)](http://travis-ci.org/brick/varexporter) -[![Coverage Status](https://coveralls.io/repos/brick/varexporter/badge.svg?branch=master)](https://coveralls.io/r/brick/varexporter?branch=master) +[![Build Status](https://github.com/brick/varexporter/workflows/CI/badge.svg)](https://github.com/brick/varexporter/actions) +[![Coverage Status](https://codecov.io/github/brick/varexporter/graph/badge.svg)](https://codecov.io/github/brick/varexporter) [![Latest Stable Version](https://poser.pugx.org/brick/varexporter/v/stable)](https://packagist.org/packages/brick/varexporter) +[![Total Downloads](https://poser.pugx.org/brick/varexporter/downloads)](https://packagist.org/packages/brick/varexporter) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](http://opensource.org/licenses/MIT) ## Introduction @@ -17,7 +18,6 @@ It is particularly useful to store data that can be cached by OPCache, just like But it also suffers from several drawbacks: -- It outputs invalid PHP code for `stdClass` objects, using `stdClass::__set_state()` which doesn't exist - It cannot export custom objects that do not implement `__set_state()`, and `__set_state()` does not play well with private properties in parent classes, which makes the implementation tedious - It does not support closures @@ -26,7 +26,8 @@ Additionally, the output is not very pretty: - It outputs arrays as `array()` notation, instead of the short `[]` notation - It outputs numeric arrays with explicit and unnecessary `0 => ...` key => value syntax -This library aims to provide a prettier, safer, and powerful alternative to `var_export()`. +This library aims to provide a prettier, safer, and powerful alternative to `var_export()`. +The output is **valid and standalone PHP code, that does not depend on the `brick/varexporter` library**. ### Installation @@ -38,7 +39,9 @@ composer require brick/varexporter ### Requirements -This library requires PHP 7.1 or later. +This library requires PHP 8.2 or later. + +For PHP 8.1 compatibility, you can use version `0.6`. For PHP 7.4 and PHP 8.0, you can use version `0.5`. For PHP 7.2 & 7.3, you can use version `0.3`. Note that [these PHP versions are EOL](http://php.net/supported-versions.php) and not supported anymore. If you're still using one of these PHP versions, you should consider upgrading as soon as possible. ### Project status & release process @@ -48,7 +51,7 @@ The current releases are numbered `0.x.y`. When a non-breaking change is introdu **When a breaking change is introduced, a new `0.x` version cycle is always started.** -It is therefore safe to lock your project to a given release cycle, such as `0.1.*`. +It is therefore safe to lock your project to a given release cycle, such as `0.7.*`. If you need to upgrade to a newer release cycle, check the [release history](https://github.com/brick/varexporter/releases) for a list of changes introduced by each further `0.x.0` version. @@ -93,66 +96,11 @@ array ( Note: unlike `var_export()`, `export()` always returns the exported variable, and never outputs it. -## Exporting stdClass objects - -You come across a `stdClass` object every time you cast an array to an object, or use `json_decode()` with the second argument set to `false` (which is the default). - -While the output of `var_export()` for `stdClass` is syntactically valid PHP code: - -```php -var_export(json_decode(' - { - "foo": "bar", - "baz": { - "hello": "world" - } - } -')); -``` - -```php -stdClass::__set_state(array( - 'foo' => 'bar', - 'baz' => - stdClass::__set_state(array( - 'hello' => 'world', - )), -)) -``` - -it is totally useless as it assumes that `stdClass` has a static `__set_state()` method, when it doesn't: - -> Error: Call to undefined method stdClass::__set_state() - -### What does `VarExporter` do instead? - -It outputs an array to object cast, which is syntactically valid, readable **and** executable: - -```php -echo VarExporter::export(json_decode(' - { - "foo": "bar", - "baz": { - "hello": "world" - } - } -')); -``` - -```php -(object) [ - 'foo' => 'bar', - 'baz' => (object) [ - 'hello' => 'world' - ] -] -``` - ## Exporting custom objects -As we've seen above, `var_export()` assumes that every object has a static [__set_state()](https://www.php.net/manual/en/language.oop5.magic.php#object.set-state) method that takes an associative array of property names to values, and returns a object. +`var_export()` assumes that every object has a static [__set_state()](https://www.php.net/manual/en/language.oop5.magic.php#object.set-state) method that takes an associative array of property names to values, and returns a object. -This means that if you want to export an instance of a class outside of your control, you're screwed up. This also means that you have to write boilerplate code for your classes, that looks like: +This means that if you want to export an instance of a class outside your control, you're out of luck. This also means that you have to write boilerplate code for your classes, that looks like: ```php class Foo @@ -210,7 +158,7 @@ It determines the most appropriate method to export your object, in this order: You can disable exporting objects this way, even if they implement `__set_state()`, using the [`NO_SET_STATE`](#varexporterno_set_state) option. -- If your class has `__serialize()` and `__unserialize()` methods ([introduced in PHP 7.4](https://wiki.php.net/rfc/custom_object_serialization), but this library accepts them in previous versions of PHP!), `VarExporter` uses the output of `__serialize()` to export the object, and gives it as input to `__unserialize()` to reconstruct the object: +- If your class has `__serialize()` and `__unserialize()` methods, `VarExporter` uses the output of `__serialize()` to export the object, and gives it as input to `__unserialize()` to reconstruct the object: ```php (static function() { @@ -226,7 +174,7 @@ It determines the most appropriate method to export your object, in this order: })() ``` - This method is recommended for exporting complex custom objects: it is forward compatible with the new serialization mechanism introduced in PHP 7.4, flexible, safe, and composes very well under inheritance. + This method is recommended for exporting complex custom objects: it is compatible with the new serialization mechanism introduced in PHP 7.4, flexible, safe, and composes very well under inheritance. If for any reason you do not want to export objects that implement `__serialize()` and `__unserialize()` using this method, you can opt out by using the [`NO_SERIALIZE`](#varexporterno_serialize) option. @@ -301,7 +249,7 @@ echo VarExporter::export([ ] ``` -To do this magic, `VarExporter` parses the PHP source file where your closure is defined, using the well-established [nikic/php-parser](https://github.com/nikic/PHP-Parser) library. +To do this magic, `VarExporter` parses the PHP source file where your closure is defined, using the well-established [nikic/php-parser](https://github.com/nikic/PHP-Parser) library, inspired by [SuperClosure](https://github.com/jeremeamia/super_closure). To ensure that the closure will work in any context, it rewrites its source code, replacing any namespaced class/function/constant name with its fully qualified counterpart: @@ -332,14 +280,62 @@ function (\My\App\Service $service) : \My\App\Model\Entity { } ``` -Note how all namespaced classes, and explicitly namespaced functions and constants, have been rewritten, while the non-namespaced function `strlen()` and the non-namespaced constant have been left as is. This brings us to the first caveat: +Note how all namespaced classes, and explicitly namespaced functions and constants, have been rewritten, while the non-namespaced function `strlen()` and the non-namespaced constant have been left as is. Please see the first [caveat](#caveats). + +### Use statements + +By default, exporting closures that have variables bound through `use()` will throw an `ExportException`. This is intentional, because exported closures can be executed in another context, and as such must not rely on the context they've been originally defined in. + +When using the [`CLOSURE_SNAPSHOT_USES`](#varexporterclosure_snapshot_uses) option, `VarExporter` will export the current value of each `use()` variable instead of throwing an exception. The exported variables are added as expression inside the exported closure. + +```php +$planet = 'world'; + +echo VarExporter::export([ + 'callback' => function(string $greeting) use ($planet) { + return $greeting . ', ' . $planet . '!'; + } +], VarExporter::CLOSURE_SNAPSHOT_USE); +``` + +```php +[ + 'callback' => function (string $greeting) { + $planet = 'world'; + return $greeting . ', ' . $planet . '!'; + } +] +``` + +### Arrow functions + +PHP supports shorthand syntax for closures, also known as arrow functions. `VarExporter` will export these as normal closures. + +Arrow functions can implicitly use variables from the context they've been defined in. If any context variable is used in the arrow function, `VarExporter` will throw an `ExportException` unless the [`CLOSURE_SNAPSHOT_USES`](#varexporterclosure_snapshot_uses) option is used. + +```php +$planet = 'world'; + +echo VarExporter::export([ + 'callback' => fn(string $greeting) => $greeting . ', ' . $planet . '!'; +], VarExporter::CLOSURE_SNAPSHOT_USES); +``` + +```php +[ + 'callback' => function (string $greeting) { + $planet = 'world'; + return $greeting . ', ' . $planet . '!'; + } +] +``` ### Caveats -- **Functions and constants that are not not explicitly namespaced**, either directly or through a `use function` or `use const` statement, **are always exported as is**. This is because the parser does not have the runtime context to check if a definition for this function exists in the current namespace. Be really careful here if you're relying on PHP's [fallback to global function/constant](https://www.php.net/manual/en/language.namespaces.fallback.php), and always explicitly import your functions in this case. -- **Closures that have variables bound through `use()` cannot be exported**, and will throw an `ExportException`. This is intentional, because exported closures can be executed in another context, and as such must not rely on the context they've been originally defined in. +- **Functions and constants that are not explicitly namespaced**, either directly or through a `use function` or `use const` statement, **are always exported as is**. This is because the parser does not have the runtime context to check if a definition for this function or constant exists in the current namespace, and as such cannot reliably predict the behaviour of PHP's [fallback to global function/constant](https://www.php.net/manual/en/language.namespaces.fallback.php). Be really careful here if you're using namespaced functions or constants: **always explicitly import your namespaced functions and constants**, if any. - Closures can use `$this`, but **will not be bound to an object once exported**. You must explicitly bind them through [`bindTo()`](https://www.php.net/manual/en/closure.bindto.php) if required, after running the exported code. - **You cannot have 2 closures on the same line in your source file**, or an `ExportException` will be thrown. This is because `VarExporter` cannot know which one holds the definition for the `\Closure` object it encountered. +- **Closures defined in eval()'d code cannot be exported** and throw an `ExportException`, because there is no source file to parse. You can disable exporting closures, using the [`NO_CLOSURES`](#varexporterno_closures) option. When this option is set, an `ExportException` will be thrown when attempting to export a closure. @@ -399,6 +395,101 @@ Disallows exporting any custom object using direct property access and bound clo Disallows exporting closures. +### `VarExporter::INLINE_ARRAY` + +Formats arrays on a single line: + +```php +VarExporter::export([ + 'one' => ['hello', 'world', 123, true, false, null, 7.5], + 'two' => ['hello', 'world', [ + 'one', + 'two', + 'three' + ]] +], VarExporter::INLINE_ARRAY); +``` + +```php +['one' => ['hello', 'world', 123, true, false, null, 7.5], 'two' => ['hello', 'world', ['one', 'two', 'three']]] +``` + +### `VarExporter::INLINE_SCALAR_LIST` + +Formats numeric arrays containing only scalar values on a single line: + +```php +VarExporter::export([ + 'one' => ['hello', 'world', 123, true, false, null, 7.5], + 'two' => ['hello', 'world', ['one', 'two', 'three']] +], VarExporter::INLINE_SCALAR_LIST); +``` + +```php +[ + 'one' => ['hello', 'world', 123, true, false, null, 7.5], + 'two' => [ + 'hello', + 'world', + ['one', 'two', 'three'] + ] +] +``` + +Types considered scalar here are `int`, `bool`, `float`, `string` and `null`. + +This option is a subset of `INLINE_ARRAY`, and has no effect when `INLINE_ARRAY` is used. + +### `VarExporter::TRAILING_COMMA_IN_ARRAY` + +Adds a trailing comma after the last item of *non-inline* arrays: + +```php +VarExporter::export( + ['hello', 'world', ['one', 'two', 'three']], + VarExporter::TRAILING_COMMA_IN_ARRAY | VarExporter::INLINE_SCALAR_LIST +); +``` + +```php +[ + 'hello', + 'world', + ['one', 'two', 'three'], +] +``` + +### `VarExporter::CLOSURE_SNAPSHOT_USES` + +Export the current value of each `use()` variable as expression inside the exported closure. + +## Indentation + +You can use the 3rd argument of `VarExporter::export()` to control the indentation level. +This is useful when you want to use the generated code string to replace a placeholder in a template used to generate code files. + +So using output of `VarExporter::export(['foo' => 'bar'], indentLevel: 1)` in the template below to replace `{{exported}}`: + +``` +public foo() +{ + $data = {{exported}}; +} +``` + +would result in: + +```php +public foo() +{ + $data = [ + 'foo' => 'bar' + ]; +} +``` + +Note that the first line will never be indented, as we can see in the example above. + ## Error handling Any error occurring on `export()` will throw an `ExportException`: @@ -416,12 +507,14 @@ try { ## Limitations -- Exporting internal classes other than `stdClass` and `Closure` is currently not supported. `VarExporter` will throw an `ExportException` if it finds one. +- Exporting internal classes other than `stdClass` and `Closure`, and classes implementing `__set_state()` (most notably DateTime classes) is currently not supported. `VarExporter` will throw an `ExportException` if it finds one. To avoid hitting this brick wall, you can implement `__serialize()` and `__unserialize()` in classes that contain references to internal objects. Feel free to open an issue or a pull request if you think that an internal class could/should be exportable. +- Exporting anonymous classes is not supported yet. Ideas or pull requests welcome. + - Just like `var_export()`, `VarExporter` cannot currently maintain object identity (two instances of the same object, once exported, will create two equal (`==`) yet distinct (`!==`) objects). - And just like `var_export()`, it cannot currently handle circular references, such as object `A` pointing to `B`, and `B` pointing back to `A`. diff --git a/composer.json b/composer.json index 076ffe2..233e1da 100644 --- a/composer.json +++ b/composer.json @@ -1,18 +1,18 @@ { "name": "brick/varexporter", - "description": "A pretty alternative to var_export(), which can export objects without __set_state()", + "description": "A powerful alternative to var_export(), which can export closures and objects without __set_state()", "type": "library", "keywords": [ "var_export" ], "license": "MIT", "require": { - "php": ">=7.1", - "nikic/php-parser": "^4.2" + "php": "^8.2", + "nikic/php-parser": "^5.0" }, "require-dev": { - "phpunit/phpunit": "^7.0", - "php-coveralls/php-coveralls": "^2.0" + "phpunit/phpunit": "^11.0", + "vimeo/psalm": "6.14.3" }, "autoload": { "psr-4": { diff --git a/phpunit.xml b/phpunit.xml index afd0ad8..91289ba 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,13 +1,13 @@ - - - - tests - - - - - src - - + + + + tests + + + + + src + + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..f19428c --- /dev/null +++ b/psalm.xml @@ -0,0 +1,18 @@ + + + + + + + + + diff --git a/src/ExportException.php b/src/ExportException.php index 888cf9a..18049a6 100644 --- a/src/ExportException.php +++ b/src/ExportException.php @@ -4,14 +4,15 @@ namespace Brick\VarExporter; +use Exception; use Throwable; -final class ExportException extends \Exception +use function implode; + +final class ExportException extends Exception { /** - * @param string $message - * @param string[] $path - * @param Throwable|null $previous + * @param string[] $path */ public function __construct(string $message, array $path, ?Throwable $previous = null) { @@ -26,10 +27,8 @@ public function __construct(string $message, array $path, ?Throwable $previous = * Returns a string representation of the given path. * * @param string[] $path - * - * @return string */ - public static function pathToString(array $path) : string + public static function pathToString(array $path): string { return '[' . implode('][', $path) . ']'; } diff --git a/src/Internal/GenericExporter.php b/src/Internal/GenericExporter.php index b09f85f..5f677ed 100644 --- a/src/Internal/GenericExporter.php +++ b/src/Internal/GenericExporter.php @@ -6,6 +6,21 @@ use Brick\VarExporter\ExportException; use Brick\VarExporter\VarExporter; +use ReflectionObject; +use UnitEnum; + +use function array_is_list; +use function array_merge; +use function array_unshift; +use function count; +use function gettype; +use function implode; +use function is_array; +use function is_object; +use function is_scalar; +use function spl_object_id; +use function sprintf; +use function var_export; /** * The main exporter implementation, that handles variables of any type. @@ -18,104 +33,121 @@ */ final class GenericExporter { + public readonly bool $addTypeHints; + + public readonly bool $skipDynamicProperties; + + public readonly bool $inlineArray; + + public readonly bool $inlineScalarList; + + public readonly bool $inlineLiteralList; + + public readonly bool $closureSnapshotUses; + + public readonly bool $trailingCommaInArray; + + public readonly int $indentLevel; + /** * @var ObjectExporter[] */ - private $objectExporters = []; + private readonly array $objectExporters; /** * The visited objects, to detect circular references. * - * This is a two-level map of parent object hash => child object hash => path where the object first appeared. - * [string => [string => string[]]] + * This is a two-level map of parent object id => child object id => path where the object first appeared. * - * @var array - */ - private $visitedObjects = []; - - /** - * @var bool - */ - public $addTypeHints; - - /** - * @var bool + * @var array> */ - public $skipDynamicProperties; + private array $visitedObjects = []; - /** - * @param int $options - */ - public function __construct(int $options) + public function __construct(int $options, int $indentLevel = 0) { - $this->objectExporters[] = new ObjectExporter\StdClassExporter($this); + $objectExporters = [ + new ObjectExporter\StdClassExporter($this), + ]; - if (! ($options & VarExporter::NO_CLOSURES)) { - $this->objectExporters[] = new ObjectExporter\ClosureExporter($this); + if (($options & VarExporter::NO_CLOSURES) === 0) { + $objectExporters[] = new ObjectExporter\ClosureExporter($this); } - $this->objectExporters[] = new ObjectExporter\InternalClassExporter($this); + if (($options & VarExporter::NO_SET_STATE) === 0) { + $objectExporters[] = new ObjectExporter\SetStateExporter($this); + } - if (! ($options & VarExporter::NO_SET_STATE)) { - $this->objectExporters[] = new ObjectExporter\SetStateExporter($this); + $objectExporters[] = new ObjectExporter\InternalClassExporter($this); + + if (($options & VarExporter::NO_SERIALIZE) === 0) { + $objectExporters[] = new ObjectExporter\SerializeExporter($this); } - if (! ($options & VarExporter::NO_SERIALIZE)) { - $this->objectExporters[] = new ObjectExporter\SerializeExporter($this); + if (($options & VarExporter::NO_ENUMS) === 0) { + $objectExporters[] = new ObjectExporter\EnumExporter($this); } - if (! ($options & VarExporter::NOT_ANY_OBJECT)) { - $this->objectExporters[] = new ObjectExporter\AnyObjectExporter($this); + if (($options & VarExporter::NOT_ANY_OBJECT) === 0) { + $objectExporters[] = new ObjectExporter\AnyObjectExporter($this); } - $this->addTypeHints = (bool) ($options & VarExporter::ADD_TYPE_HINTS); + $this->objectExporters = $objectExporters; + + $this->addTypeHints = (bool) ($options & VarExporter::ADD_TYPE_HINTS); $this->skipDynamicProperties = (bool) ($options & VarExporter::SKIP_DYNAMIC_PROPERTIES); + $this->inlineArray = (bool) ($options & VarExporter::INLINE_ARRAY); + $this->inlineScalarList = (bool) ($options & VarExporter::INLINE_SCALAR_LIST); + $this->inlineLiteralList = (bool) ($options & VarExporter::INLINE_LITERAL_LIST); + $this->closureSnapshotUses = (bool) ($options & VarExporter::CLOSURE_SNAPSHOT_USES); + $this->trailingCommaInArray = (bool) ($options & VarExporter::TRAILING_COMMA_IN_ARRAY); + + $this->indentLevel = $indentLevel; } /** - * @param mixed $var The variable to export. - * @param string[] $path The path to the current variable in the array/object graph. - * @param string[] $parents The hashes of all objects higher in the graph. + * @param mixed $var The variable to export. + * @param string[] $path The path to the current variable in the array/object graph. + * @param int[] $parentIds The ids of all objects higher in the graph. * * @return string[] The lines of code. * * @throws ExportException */ - public function export($var, array $path, array $parents) : array + public function export(mixed $var, array $path, array $parentIds): array { - switch ($type = gettype($var)) { - case 'boolean': - case 'integer': - case 'double': - case 'string': - return [var_export($var, true)]; - - case 'NULL': - // lowercase null - return ['null']; + if ($var === null) { + return ['null']; + } - case 'array': - return $this->exportArray($var, $path, $parents); + // bool, int, float, string + if (is_scalar($var)) { + return [var_export($var, true)]; + } - case 'object': - return $this->exportObject($var, $path, $parents); + if (is_array($var)) { + return $this->exportArray($var, $path, $parentIds); + } - default: - // resources - throw new ExportException(sprintf('Type "%s" is not supported.', $type), $path); + if (is_object($var)) { + return $this->exportObject($var, $path, $parentIds); } + + // resources + throw new ExportException(sprintf('Type "%s" is not supported.', gettype($var)), $path); } /** - * @param array $array The array to export. - * @param string[] $path The path to the current array in the array/object graph. - * @param string[] $parents The hashes of all objects higher in the graph. + * @param array $array The array to export. + * @param string[] $path The path to the current array in the array/object graph. + * @param int[] $parentIds The ids of all objects higher in the graph. * * @return string[] The lines of code. * * @throws ExportException + * + * @psalm-suppress MixedAssignment */ - public function exportArray(array $array, array $path, array $parents) : array + public function exportArray(array $array, array $path, array $parentIds): array { if (! $array) { return ['[]']; @@ -123,74 +155,85 @@ public function exportArray(array $array, array $path, array $parents) : array $result = []; - $result[] = '['; - $count = count($array); - $isNumeric = array_keys($array) === range(0, $count - 1); + $isList = array_is_list($array); $current = 0; + $inline = $this->inlineArray + || ($this->inlineScalarList && $isList && $this->isScalarList($array)) + || ($this->inlineLiteralList && $isList && $this->isLiteralList($array)); + foreach ($array as $key => $value) { $isLast = (++$current === $count); $newPath = $path; $newPath[] = (string) $key; - $exported = $this->export($value, $newPath, $parents); + $exported = $this->export($value, $newPath, $parentIds); - $prepend = ''; - $append = ''; + if ($inline) { + $result[] = $isList ? $exported[0] : var_export($key, true) . ' => ' . $exported[0]; + } else { + $prepend = ''; + $append = ''; - if (! $isNumeric) { - $prepend = var_export($key, true) . ' => '; - } + if (! $isList) { + $prepend = var_export($key, true) . ' => '; + } - if (! $isLast) { - $append = ','; - } + if (! $isLast || $this->trailingCommaInArray) { + $append = ','; + } - $exported = $this->wrap($exported, $prepend, $append); - $exported = $this->indent($exported); + $exported = $this->wrap($exported, $prepend, $append); + $exported = $this->indent($exported); + + $result = array_merge($result, $exported); + } + } - $result = array_merge($result, $exported); + if ($inline) { + return ['[' . implode(', ', $result) . ']']; } + array_unshift($result, '['); $result[] = ']'; return $result; } /** - * @param object $object The object to export. - * @param string[] $path The path to the current object in the array/object graph. - * @param string[] $parents The hashes of all objects higher in the graph. + * @param object $object The object to export. + * @param string[] $path The path to the current object in the array/object graph. + * @param int[] $parentIds The ids of all objects higher in the graph. * * @return string[] The lines of code. * * @throws ExportException */ - public function exportObject($object, array $path, array $parents) : array + public function exportObject(object $object, array $path, array $parentIds): array { - $hash = spl_object_hash($object); + $id = spl_object_id($object); - foreach ($parents as $parentHash) { - if (isset($this->visitedObjects[$parentHash][$hash])) { + foreach ($parentIds as $parentId) { + if (isset($this->visitedObjects[$parentId][$id])) { throw new ExportException(sprintf( 'Object of class "%s" has a circular reference at %s. ' . 'Circular references are currently not supported.', - get_class($object), - ExportException::pathToString($this->visitedObjects[$parentHash][$hash]) + $object::class, + ExportException::pathToString($this->visitedObjects[$parentId][$id]), ), $path); } - $this->visitedObjects[$parentHash][$hash] = $path; + $this->visitedObjects[$parentId][$id] = $path; } - $reflectionObject = new \ReflectionObject($object); + $reflectionObject = new ReflectionObject($object); foreach ($this->objectExporters as $objectExporter) { if ($objectExporter->supports($reflectionObject)) { - return $objectExporter->export($object, $reflectionObject, $path, $parents); + return $objectExporter->export($object, $reflectionObject, $path, $parentIds); } } @@ -208,9 +251,9 @@ public function exportObject($object, array $path, array $parents) : array * * @return string[] The indented lines of code. */ - public function indent(array $lines) : array + public function indent(array $lines): array { - foreach ($lines as & $value) { + foreach ($lines as &$value) { if ($value !== '') { $value = ' ' . $value; } @@ -226,11 +269,45 @@ public function indent(array $lines) : array * * @return string[] */ - public function wrap(array $lines, string $prepend, string $append) : array + public function wrap(array $lines, string $prepend, string $append): array { $lines[0] = $prepend . $lines[0]; $lines[count($lines) - 1] .= $append; return $lines; } + + /** + * Returns whether the given array only contains scalar values. + * + * Types considered scalar here are int, bool, float, string and null. + * If the array is empty, this method returns true. + */ + private function isScalarList(array $array): bool + { + foreach ($array as $value) { + if ($value !== null && ! is_scalar($value)) { + return false; + } + } + + return true; + } + + /** + * Returns whether the given array only contains literal values. + * + * Values considered literal are: int, float, string, bool, null, and enum values. + * If the array is empty, this method returns true. + */ + private function isLiteralList(array $array): bool + { + foreach ($array as $value) { + if ($value !== null && ! is_scalar($value) && ! $value instanceof UnitEnum) { + return false; + } + } + + return true; + } } diff --git a/src/Internal/ObjectExporter.php b/src/Internal/ObjectExporter.php index 0f82234..02fb7c3 100644 --- a/src/Internal/ObjectExporter.php +++ b/src/Internal/ObjectExporter.php @@ -5,6 +5,10 @@ namespace Brick\VarExporter\Internal; use Brick\VarExporter\ExportException; +use ReflectionClass; +use ReflectionObject; + +use function array_merge; /** * An exporter that handles a specific type of object. @@ -13,52 +17,40 @@ */ abstract class ObjectExporter { - /** - * @var GenericExporter - */ - protected $exporter; - - /** - * @param GenericExporter $exporter - */ - public function __construct(GenericExporter $exporter) - { - $this->exporter = $exporter; + public function __construct( + protected readonly GenericExporter $exporter, + ) { } /** * Returns whether this exporter supports the given object. * - * @param \ReflectionObject $reflectionObject A reflection of the object. - * - * @return bool + * @param ReflectionObject $reflectionObject A reflection of the object. */ - abstract public function supports(\ReflectionObject $reflectionObject) : bool; + abstract public function supports(ReflectionObject $reflectionObject): bool; /** * Exports the given object. * - * @param object $object The object to export. - * @param \ReflectionObject $reflectionObject A reflection of the object. - * @param string[] $path The path to the current object in the array/object graph. - * @param string[] $parents The hashes of all objects higher in the graph. + * @param object $object The object to export. + * @param ReflectionObject $reflectionObject A reflection of the object. + * @param string[] $path The path to the current object in the array/object graph. + * @param int[] $parentIds The ids of all objects higher in the graph. * * @return string[] The lines of code. * * @throws ExportException */ - abstract public function export($object, \ReflectionObject $reflectionObject, array $path, array $parents) : array; + abstract public function export(object $object, ReflectionObject $reflectionObject, array $path, array $parentIds): array; /** * Returns the code to create a new object of the given class. * * If the class has a constructor, reflection will be used to bypass it. * - * @param \ReflectionClass $class - * * @return string[] The lines of code. */ - final protected function getCreateObjectCode(\ReflectionClass $class) : array + final protected function getCreateObjectCode(ReflectionClass $class): array { $className = '\\' . $class->getName(); @@ -85,12 +77,12 @@ final protected function getCreateObjectCode(\ReflectionClass $class) : array * * @return string[] The lines of code, wrapped in a closure. */ - final protected function wrapInClosure(array $code) : array + final protected function wrapInClosure(array $code): array { return array_merge( ['(static function() {'], $this->exporter->indent($code), - ['})()'] + ['})()'], ); } } diff --git a/src/Internal/ObjectExporter/AnyObjectExporter.php b/src/Internal/ObjectExporter/AnyObjectExporter.php index 046c76e..2ab0416 100644 --- a/src/Internal/ObjectExporter/AnyObjectExporter.php +++ b/src/Internal/ObjectExporter/AnyObjectExporter.php @@ -5,6 +5,17 @@ namespace Brick\VarExporter\Internal\ObjectExporter; use Brick\VarExporter\Internal\ObjectExporter; +use Override; +use ReflectionClass; +use ReflectionObject; +use ReflectionProperty; + +use function array_key_exists; +use function array_merge; +use function method_exists; +use function preg_match; +use function spl_object_id; +use function var_export; /** * Handles any class through direct property access and bound closures. @@ -14,40 +25,43 @@ * * @internal This class is for internal use, and not part of the public API. It may change at any time without warning. */ -class AnyObjectExporter extends ObjectExporter +final class AnyObjectExporter extends ObjectExporter { - /** - * {@inheritDoc} - */ - public function supports(\ReflectionObject $reflectionObject) : bool + #[Override] + public function supports(ReflectionObject $reflectionObject): bool { return true; } /** - * {@inheritDoc} + * @psalm-suppress MixedAssignment */ - public function export($object, \ReflectionObject $reflectionObject, array $path, array $parents) : array + #[Override] + public function export(object $object, ReflectionObject $reflectionObject, array $path, array $parentIds): array { $lines = $this->getCreateObjectCode($reflectionObject); $objectAsArray = (array) $object; $current = $this->exporter->skipDynamicProperties - ? new \ReflectionClass($object) // properties from class definition only - : $reflectionObject; // properties from class definition + dynamic properties + ? new ReflectionClass($object) // properties from class definition only + : $reflectionObject; // properties from class definition + dynamic properties $isParentClass = false; $returnNewObject = ($reflectionObject->getConstructor() === null); while ($current) { - $publicProperties = []; - $nonPublicProperties = []; - $unsetPublicProperties = []; - $unsetNonPublicProperties = []; + $publicNonReadonlyProperties = []; + $nonPublicOrPublicReadonlyProperties = []; + $unsetPublicNonReadonlyProperties = []; + $unsetNonPublicOrPublicReadonlyProperties = []; foreach ($current->getProperties() as $property) { + if ($property->isStatic()) { + continue; + } + if ($isParentClass && ! $property->isPrivate()) { // property already handled in the child class. continue; @@ -64,62 +78,65 @@ public function export($object, \ReflectionObject $reflectionObject, array $path if (array_key_exists($key, $objectAsArray)) { $value = $objectAsArray[$key]; - if ($property->isPublic()) { - $publicProperties[$name] = $value; + if ($property->isPublic() && ! (method_exists($property, 'isReadOnly') && $property->isReadOnly())) { + $publicNonReadonlyProperties[$name] = $value; } else { - $nonPublicProperties[$name] = $value; + $nonPublicOrPublicReadonlyProperties[$name] = $value; } } else { - if ($property->isPublic()) { - $unsetPublicProperties[] = $name; + if ($property->isPublic() && ! (method_exists($property, 'isReadOnly') && $property->isReadOnly())) { + $unsetPublicNonReadonlyProperties[] = $name; } else { - $unsetNonPublicProperties[] = $name; + $unsetNonPublicOrPublicReadonlyProperties[] = $name; } } $returnNewObject = false; } - if ($publicProperties || $unsetPublicProperties) { + if ($publicNonReadonlyProperties || $unsetPublicNonReadonlyProperties) { $lines[] = ''; - foreach ($publicProperties as $name => $value) { + foreach ($publicNonReadonlyProperties as $name => $value) { + /** @psalm-suppress RedundantCast See: https://github.com/vimeo/psalm/issues/4891 */ + $name = (string) $name; + $newPath = $path; $newPath[] = $name; - $newParents = $parents; - $newParents[] = spl_object_hash($object); + $newParentIds = $parentIds; + $newParentIds[] = spl_object_id($object); - $exportedValue = $this->exporter->export($value, $newPath, $newParents); + $exportedValue = $this->exporter->export($value, $newPath, $newParentIds); $exportedValue = $this->exporter->wrap($exportedValue, '$object->' . $this->escapePropName($name) . ' = ', ';'); $lines = array_merge($lines, $exportedValue); } - foreach ($unsetPublicProperties as $name) { + foreach ($unsetPublicNonReadonlyProperties as $name) { $lines[] = 'unset($object->' . $this->escapePropName($name) . ');'; } } - if ($nonPublicProperties || $unsetNonPublicProperties) { + if ($nonPublicOrPublicReadonlyProperties || $unsetNonPublicOrPublicReadonlyProperties) { $closureLines = []; if ($this->exporter->addTypeHints) { $closureLines[] = '/** @var \\' . $current->getName() . ' $this */'; } - foreach ($nonPublicProperties as $name => $value) { + foreach ($nonPublicOrPublicReadonlyProperties as $name => $value) { $newPath = $path; $newPath[] = $name; - $newParents = $parents; - $newParents[] = spl_object_hash($object); + $newParentIds = $parentIds; + $newParentIds[] = spl_object_id($object); - $exportedValue = $this->exporter->export($value, $newPath, $newParents); + $exportedValue = $this->exporter->export($value, $newPath, $newParentIds); $exportedValue = $this->exporter->wrap($exportedValue, '$this->' . $this->escapePropName($name) . ' = ', ';'); $closureLines = array_merge($closureLines, $exportedValue); } - foreach ($unsetNonPublicProperties as $name) { + foreach ($unsetNonPublicOrPublicReadonlyProperties as $name) { $closureLines[] = 'unset($this->' . $this->escapePropName($name) . ');'; } @@ -146,12 +163,8 @@ public function export($object, \ReflectionObject $reflectionObject, array $path /** * Returns the key of the given property in the object-to-array cast. - * - * @param \ReflectionProperty $property - * - * @return string */ - private function getPropertyKey(\ReflectionProperty $property) : string + private function getPropertyKey(ReflectionProperty $property): string { $name = $property->getName(); @@ -166,12 +179,7 @@ private function getPropertyKey(\ReflectionProperty $property) : string return $name; } - /** - * @param string $var - * - * @return string - */ - private function escapePropName(string $var) : string + private function escapePropName(string $var): string { if (preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $var) === 1) { return $var; diff --git a/src/Internal/ObjectExporter/ClosureExporter.php b/src/Internal/ObjectExporter/ClosureExporter.php index 8adf3e2..1c2ecb7 100644 --- a/src/Internal/ObjectExporter/ClosureExporter.php +++ b/src/Internal/ObjectExporter/ClosureExporter.php @@ -6,35 +6,51 @@ use Brick\VarExporter\ExportException; use Brick\VarExporter\Internal\ObjectExporter; - +use Closure; +use Override; use PhpParser\Error; use PhpParser\Node; use PhpParser\NodeTraverser; use PhpParser\NodeVisitor\FindingVisitor; use PhpParser\NodeVisitor\NameResolver; +use PhpParser\Parser; use PhpParser\ParserFactory; +use ReflectionFunction; +use ReflectionObject; + +use function array_keys; +use function array_merge; +use function assert; +use function count; +use function file_get_contents; +use function implode; +use function is_string; +use function sprintf; +use function str_ends_with; + +use const PHP_EOL; /** * Handles closures. * * @internal This class is for internal use, and not part of the public API. It may change at any time without warning. */ -class ClosureExporter extends ObjectExporter +final class ClosureExporter extends ObjectExporter { - /** - * {@inheritDoc} - */ - public function supports(\ReflectionObject $reflectionObject) : bool + private ?Parser $parser = null; + + #[Override] + public function supports(ReflectionObject $reflectionObject): bool { - return $reflectionObject->getName() === \Closure::class; + return $reflectionObject->getName() === Closure::class; } - /** - * {@inheritDoc} - */ - public function export($object, \ReflectionObject $reflectionObject, array $path, array $parents) : array + #[Override] + public function export(object $object, ReflectionObject $reflectionObject, array $path, array $parentIds): array { - $reflectionFunction = new \ReflectionFunction($object); + assert($object instanceof Closure); + + $reflectionFunction = new ReflectionFunction($object); $file = $reflectionFunction->getFileName(); $line = $reflectionFunction->getStartLine(); @@ -42,10 +58,10 @@ public function export($object, \ReflectionObject $reflectionObject, array $path $ast = $this->parseFile($file, $path); $ast = $this->resolveNames($ast); - $closure = $this->getClosure($ast, $file, $line, $path); + $closure = $this->getClosure($reflectionFunction, $ast, $file, $line, $path); $prettyPrinter = new ClosureExporter\PrettyPrinter(); - $prettyPrinter->setVarExporterNestingLevel(count($path)); + $prettyPrinter->setVarExporterNestingLevel(count($path) + $this->exporter->indentLevel); $code = $prettyPrinter->prettyPrintExpr($closure); @@ -55,6 +71,15 @@ public function export($object, \ReflectionObject $reflectionObject, array $path return [$code]; } + private function getParser(): Parser + { + if ($this->parser === null) { + $this->parser = (new ParserFactory())->createForHostVersion(); + } + + return $this->parser; + } + /** * Parses the given source file. * @@ -65,13 +90,13 @@ public function export($object, \ReflectionObject $reflectionObject, array $path * * @throws ExportException */ - private function parseFile(string $filename, array $path) : array + private function parseFile(string $filename, array $path): array { - if (substr($filename, -16) === " : eval()'d code") { + if (str_ends_with($filename, " : eval()'d code")) { throw new ExportException("Closure defined in eval()'d code cannot be exported.", $path); } - $source = @ file_get_contents($filename); + $source = @file_get_contents($filename); if ($source === false) { // @codeCoverageIgnoreStart @@ -79,10 +104,13 @@ private function parseFile(string $filename, array $path) : array // @codeCoverageIgnoreEnd } - $parser = (new ParserFactory)->create(ParserFactory::ONLY_PHP7); - try { - return $parser->parse($source); + $nodes = $this->getParser()->parse($source); + + // throwing error handler + assert($nodes !== null); + + return $nodes; // @codeCoverageIgnoreStart } catch (Error $e) { throw new ExportException("Cannot parse file \"$filename\" for reading closure code.", $path, $e); @@ -97,7 +125,7 @@ private function parseFile(string $filename, array $path) : array * * @return Node[] */ - private function resolveNames(array $ast) : array + private function resolveNames(array $ast): array { $nameResolver = new NameResolver(); $nodeTraverser = new NodeTraverser(); @@ -109,21 +137,25 @@ private function resolveNames(array $ast) : array /** * Finds a closure in the source file and returns its node. * - * @param array $ast The AST. - * @param string $file The file name. - * @param int $line The line number where the closure is located in the source file. - * @param string[] $path The path to the closure in the array/object graph. - * - * @return Node\Expr\Closure + * @param ReflectionFunction $reflectionFunction Reflection of the closure. + * @param Node[] $ast The AST. + * @param string $file The file name. + * @param int $line The line number where the closure is located in the source file. + * @param string[] $path The path to the closure in the array/object graph. * * @throws ExportException */ - private function getClosure(array $ast, string $file, int $line, array $path) : Node\Expr\Closure - { - $finder = new FindingVisitor(function(Node $node) use ($line) : bool { - return $node instanceof Node\Expr\Closure - && $node->getStartLine() === $line; - }); + private function getClosure( + ReflectionFunction $reflectionFunction, + array $ast, + string $file, + int $line, + array $path, + ): Node\Expr\Closure { + $finder = new FindingVisitor( + fn (Node $node): bool => ($node instanceof Node\Expr\Closure || $node instanceof Node\Expr\ArrowFunction) + && $node->getStartLine() === $line, + ); $traverser = new NodeTraverser(); $traverser->addVisitor($finder); @@ -137,17 +169,104 @@ private function getClosure(array $ast, string $file, int $line, array $path) : 'Expected exactly 1 closure in %s on line %d, found %d.', $file, $line, - $count + $count, ), $path); } - /** @var Node\Expr\Closure $closure */ + /** @var Node\Expr\ArrowFunction|Node\Expr\Closure $closure */ $closure = $closures[0]; + if ($closure instanceof Node\Expr\ArrowFunction) { + $closure = $this->convertArrowFunction($reflectionFunction, $closure); + } + if ($closure->uses) { - throw new ExportException("The closure has bound variables through 'use', this is not supported.", $path); + $this->closureHandleUses($reflectionFunction, $closure, $path); } return $closure; } + + /** + * Convert a parsed arrow function to a closure. + * + * @param ReflectionFunction $reflectionFunction Reflection of the closure. + * @param Node\Expr\ArrowFunction $arrowFunction Parsed arrow function. + */ + private function convertArrowFunction( + ReflectionFunction $reflectionFunction, + Node\Expr\ArrowFunction $arrowFunction, + ): Node\Expr\Closure { + $closure = new Node\Expr\Closure([], ['arrow_function' => true]); + + $closure->static = false; + $closure->params = $arrowFunction->params; + $closure->returnType = $arrowFunction->returnType; + + $closure->stmts[] = new Node\Stmt\Return_($arrowFunction->expr); + + $static = $reflectionFunction->getStaticVariables(); + + foreach (array_keys($static) as $var) { + assert(is_string($var)); + + $closure->uses[] = new Node\ClosureUse( + new Node\Expr\Variable($var), + ); + } + + return $closure; + } + + /** + * Handle `use` part of closure. + * + * @param ReflectionFunction $reflectionFunction Reflection of the closure. + * @param Node\Expr\Closure $closure Parsed closure. + * @param string[] $path The path to the closure in the array/object graph. + * + * @throws ExportException + */ + private function closureHandleUses( + ReflectionFunction $reflectionFunction, + Node\Expr\Closure $closure, + array $path, + ): void { + if (! $this->exporter->closureSnapshotUses) { + $message = $closure->hasAttribute('arrow_function') + ? 'The arrow function uses variables in the parent scope, this is not supported by default' + : "The closure has bound variables through 'use', this is not supported by default"; + + throw new ExportException("$message. Use the CLOSURE_SNAPSHOT_USE option to export them.", $path); + } + + $static = $reflectionFunction->getStaticVariables(); + $stmts = []; + + $parser = $this->getParser(); + + foreach ($closure->uses as $use) { + $var = $use->var->name; + + assert(is_string($var)); + + $export = array_merge(['exporter->export($static[$var], $path, []), [';']); + $nodes = $parser->parse(implode(PHP_EOL, $export)); + + // throwing error handler + assert($nodes !== null); + + /** @var Node\Stmt\Expression $expr */ + $expr = $nodes[0]; + + $assign = new Node\Expr\Assign( + new Node\Expr\Variable($var), + $expr->expr, + ); + $stmts[] = new Node\Stmt\Expression($assign); + } + + $closure->uses = []; + $closure->stmts = array_merge($stmts, $closure->stmts); + } } diff --git a/src/Internal/ObjectExporter/ClosureExporter/PrettyPrinter.php b/src/Internal/ObjectExporter/ClosureExporter/PrettyPrinter.php index a899d09..7b89289 100644 --- a/src/Internal/ObjectExporter/ClosureExporter/PrettyPrinter.php +++ b/src/Internal/ObjectExporter/ClosureExporter/PrettyPrinter.php @@ -4,32 +4,27 @@ namespace Brick\VarExporter\Internal\ObjectExporter\ClosureExporter; +use Override; use PhpParser\PrettyPrinter\Standard; +use function str_repeat; + /** * Extends the standard pretty-printer to allow for a base indent level. + * + * @psalm-suppress PropertyNotSetInConstructor */ final class PrettyPrinter extends Standard { - /** - * @var int - */ - private $varExporterNestingLevel = 0; - - /** - * @param int $level - * - * @return void - */ - public function setVarExporterNestingLevel(int $level) : void + private int $varExporterNestingLevel = 0; + + public function setVarExporterNestingLevel(int $level): void { $this->varExporterNestingLevel = $level; } - /** - * {@inheritDoc} - */ - protected function resetState() + #[Override] + protected function resetState(): void { parent::resetState(); diff --git a/src/Internal/ObjectExporter/EnumExporter.php b/src/Internal/ObjectExporter/EnumExporter.php new file mode 100644 index 0000000..77cda93 --- /dev/null +++ b/src/Internal/ObjectExporter/EnumExporter.php @@ -0,0 +1,36 @@ +isEnum(); + } + + #[Override] + public function export(object $object, ReflectionObject $reflectionObject, array $path, array $parentIds): array + { + assert($object instanceof UnitEnum); + + return [ + $object::class . '::' . $object->name, + ]; + } +} diff --git a/src/Internal/ObjectExporter/InternalClassExporter.php b/src/Internal/ObjectExporter/InternalClassExporter.php index f962c2f..62be313 100644 --- a/src/Internal/ObjectExporter/InternalClassExporter.php +++ b/src/Internal/ObjectExporter/InternalClassExporter.php @@ -6,26 +6,24 @@ use Brick\VarExporter\ExportException; use Brick\VarExporter\Internal\ObjectExporter; +use Override; +use ReflectionObject; /** * Throws on internal classes. * * @internal This class is for internal use, and not part of the public API. It may change at any time without warning. */ -class InternalClassExporter extends ObjectExporter +final class InternalClassExporter extends ObjectExporter { - /** - * {@inheritDoc} - */ - public function supports(\ReflectionObject $reflectionObject) : bool + #[Override] + public function supports(ReflectionObject $reflectionObject): bool { return $reflectionObject->isInternal(); } - /** - * {@inheritDoc} - */ - public function export($object, \ReflectionObject $reflectionObject, array $path, array $parents) : array + #[Override] + public function export(object $object, ReflectionObject $reflectionObject, array $path, array $parentIds): array { $className = $reflectionObject->getName(); diff --git a/src/Internal/ObjectExporter/SerializeExporter.php b/src/Internal/ObjectExporter/SerializeExporter.php index ee774ff..639ac40 100644 --- a/src/Internal/ObjectExporter/SerializeExporter.php +++ b/src/Internal/ObjectExporter/SerializeExporter.php @@ -5,35 +5,39 @@ namespace Brick\VarExporter\Internal\ObjectExporter; use Brick\VarExporter\Internal\ObjectExporter; +use Override; +use ReflectionObject; + +use function array_merge; /** * Handles instances of classes with __serialize() and __unserialize() methods. * * @internal This class is for internal use, and not part of the public API. It may change at any time without warning. */ -class SerializeExporter extends ObjectExporter +final class SerializeExporter extends ObjectExporter { - /** - * {@inheritDoc} - */ - public function supports(\ReflectionObject $reflectionObject) : bool + #[Override] + public function supports(ReflectionObject $reflectionObject): bool { return $reflectionObject->hasMethod('__serialize') && $reflectionObject->hasMethod('__unserialize'); } - /** - * {@inheritDoc} - */ - public function export($object, \ReflectionObject $reflectionObject, array $path, array $parents) : array + #[Override] + public function export(object $object, ReflectionObject $reflectionObject, array $path, array $parentIds): array { $lines = $this->getCreateObjectCode($reflectionObject); $lines[] = ''; + /** + * @psalm-suppress MixedAssignment + * @psalm-suppress MixedMethodCall + */ $values = $object->__serialize(); - $exportedValues = $this->exporter->export($values, $path, $parents); + $exportedValues = $this->exporter->export($values, $path, $parentIds); $exportedValues = $this->exporter->wrap($exportedValues, '$object->__unserialize(', ');'); $lines = array_merge($lines, $exportedValues); diff --git a/src/Internal/ObjectExporter/SetStateExporter.php b/src/Internal/ObjectExporter/SetStateExporter.php index a30950f..d760c21 100644 --- a/src/Internal/ObjectExporter/SetStateExporter.php +++ b/src/Internal/ObjectExporter/SetStateExporter.php @@ -6,18 +6,23 @@ use Brick\VarExporter\ExportException; use Brick\VarExporter\Internal\ObjectExporter; +use Override; +use ReflectionClass; +use ReflectionObject; + +use function array_key_exists; +use function strrpos; +use function substr; /** * Handles instances of classes with a __set_state() method. * * @internal This class is for internal use, and not part of the public API. It may change at any time without warning. */ -class SetStateExporter extends ObjectExporter +final class SetStateExporter extends ObjectExporter { - /** - * {@inheritDoc} - */ - public function supports(\ReflectionObject $reflectionObject) : bool + #[Override] + public function supports(ReflectionObject $reflectionObject): bool { if ($reflectionObject->hasMethod('__set_state')) { $method = $reflectionObject->getMethod('__set_state'); @@ -28,19 +33,16 @@ public function supports(\ReflectionObject $reflectionObject) : bool return false; } - /** - * {@inheritDoc} - */ - public function export($object, \ReflectionObject $reflectionObject, array $path, array $parents) : array + #[Override] + public function export(object $object, ReflectionObject $reflectionObject, array $path, array $parentIds): array { $className = $reflectionObject->getName(); $vars = $this->getObjectVars($object, $path); - $exportedVars = $this->exporter->exportArray($vars, $path, $parents); - $exportedVars = $this->exporter->wrap($exportedVars, '\\' . $className . '::__set_state(', ')'); + $exportedVars = $this->exporter->exportArray($vars, $path, $parentIds); - return $exportedVars; + return $this->exporter->wrap($exportedVars, '\\' . $className . '::__set_state(', ')'); } /** @@ -55,18 +57,21 @@ public function export($object, \ReflectionObject $reflectionObject, array $path * * This way we offer a better safety guarantee, while staying compatible with var_export() in the output. * - * @param object $object The object to dump. - * @param array $path The path to the object, in the array/object graph. + * @param object $object The object to dump. + * @param string[] $path The path to the object, in the array/object graph. * - * @return array An associative array of property name to value. + * @return array An associative array of property name to value. * * @throws ExportException + * + * @psalm-suppress MixedAssignment */ - private function getObjectVars($object, array $path) : array + private function getObjectVars(object $object, array $path): array { $result = []; foreach ((array) $object as $name => $value) { + $name = (string) $name; $pos = strrpos($name, "\0"); if ($pos !== false) { @@ -74,12 +79,12 @@ private function getObjectVars($object, array $path) : array } if (array_key_exists($name, $result)) { - $className = get_class($object); + $className = $object::class; throw new ExportException( 'Class "' . $className . '" has overridden private property "' . $name . '". ' . 'This is not supported for exporting objects with __set_state().', - $path + $path, ); } @@ -93,16 +98,10 @@ private function getObjectVars($object, array $path) : array return $result; } - /** - * @param object $object - * @param string $name - * - * @return bool - */ - private function isDynamicProperty($object, string $name) : bool + private function isDynamicProperty(object $object, string $name): bool { - $reflectionClass = new \ReflectionClass($object); - $reflectionObject = new \ReflectionObject($object); + $reflectionClass = new ReflectionClass($object); + $reflectionObject = new ReflectionObject($object); return $reflectionObject->hasProperty($name) && ! $reflectionClass->hasProperty($name); } diff --git a/src/Internal/ObjectExporter/StdClassExporter.php b/src/Internal/ObjectExporter/StdClassExporter.php index 81e1857..6e49dcd 100644 --- a/src/Internal/ObjectExporter/StdClassExporter.php +++ b/src/Internal/ObjectExporter/StdClassExporter.php @@ -5,28 +5,27 @@ namespace Brick\VarExporter\Internal\ObjectExporter; use Brick\VarExporter\Internal\ObjectExporter; +use Override; +use ReflectionObject; +use stdClass; /** * Handles stdClass objects. * * @internal This class is for internal use, and not part of the public API. It may change at any time without warning. */ -class StdClassExporter extends ObjectExporter +final class StdClassExporter extends ObjectExporter { - /** - * {@inheritDoc} - */ - public function supports(\ReflectionObject $reflectionObject) : bool + #[Override] + public function supports(ReflectionObject $reflectionObject): bool { - return $reflectionObject->getName() === \stdClass::class; + return $reflectionObject->getName() === stdClass::class; } - /** - * {@inheritDoc} - */ - public function export($object, \ReflectionObject $reflectionObject, array $path, array $parents) : array + #[Override] + public function export(object $object, ReflectionObject $reflectionObject, array $path, array $parentIds): array { - $exported = $this->exporter->exportArray((array) $object, $path, $parents); + $exported = $this->exporter->exportArray((array) $object, $path, $parentIds); $exported[0] = '(object) ' . $exported[0]; diff --git a/src/VarExporter.php b/src/VarExporter.php index 3680d84..687a1ca 100644 --- a/src/VarExporter.php +++ b/src/VarExporter.php @@ -6,6 +6,14 @@ use Brick\VarExporter\Internal\GenericExporter; +use function array_map; +use function array_shift; +use function count; +use function implode; +use function str_repeat; + +use const PHP_EOL; + final class VarExporter { /** @@ -48,23 +56,66 @@ final class VarExporter public const NO_CLOSURES = 1 << 6; /** - * @param mixed $var The variable to export. - * @param int $options A bitmask of options. Possible values are `VarExporter::*` constants. - * Combine multiple options with a bitwise OR `|` operator. - * - * @return string + * Formats lists (0-based numeric arrays) containing only scalar values on a single line. + * Types considered scalar here are int, bool, float, string and null. + * This option is a subset of INLINE_ARRAY and INLINE_LITERAL_LIST and has no effect when either is used. + */ + public const INLINE_SCALAR_LIST = 1 << 7; + + /** + * Export static vars defined via `use` as variables. + */ + public const CLOSURE_SNAPSHOT_USES = 1 << 8; + + /** + * Add a trailing comma after the last item of non-inline arrays. + */ + public const TRAILING_COMMA_IN_ARRAY = 1 << 9; + + /** + * Disallows exporting enums. + */ + public const NO_ENUMS = 1 << 10; + + /** + * Formats all arrays on a single line. + */ + public const INLINE_ARRAY = 1 << 11; + + /** + * Formats lists (0-based numeric arrays) containing only literal values on a single line. + * Values considered literal are: int, float, string, bool, null, and enum values. + * This option is a subset of INLINE_ARRAY and has no effect when INLINE_ARRAY is used. + */ + public const INLINE_LITERAL_LIST = 1 << 12; + + /** + * @param mixed $var The variable to export. + * @param int $options A bitmask of options. Possible values are `VarExporter::*` constants. + * Combine multiple options with a bitwise OR `|` operator. + * @param int $indentLevel The base output indentation level. * * @throws ExportException */ - public static function export($var, int $options = 0) : string + public static function export(mixed $var, int $options = 0, int $indentLevel = 0): string { - $exporter = new GenericExporter($options); - + $exporter = new GenericExporter($options, $indentLevel); $lines = $exporter->export($var, [], []); - $export = implode(PHP_EOL, $lines); - if ($options & self::ADD_RETURN) { - return 'return ' . $export . ';' . PHP_EOL; + if ($indentLevel < 1 || count($lines) < 2) { + $export = implode(PHP_EOL, $lines); + } else { + $firstLine = array_shift($lines); + $lines = array_map( + fn ($line) => str_repeat(' ', $indentLevel) . $line, + $lines, + ); + + $export = $firstLine . PHP_EOL . implode(PHP_EOL, $lines); + } + + if (($options & self::ADD_RETURN) !== 0) { + return 'return ' . $export . ';' . PHP_EOL; } return $export; diff --git a/tests/AbstractTestCase.php b/tests/AbstractTestCase.php index f022610..8f8c4a9 100644 --- a/tests/AbstractTestCase.php +++ b/tests/AbstractTestCase.php @@ -6,7 +6,14 @@ use Brick\VarExporter\ExportException; use Brick\VarExporter\VarExporter; +use Closure; use PHPUnit\Framework\TestCase; +use ReflectionFunction; + +use function array_map; +use function explode; +use function implode; +use function preg_quote; abstract class AbstractTestCase extends TestCase { @@ -16,10 +23,8 @@ abstract class AbstractTestCase extends TestCase * @param string $expected The expected export() output. * @param mixed $var The variable to export. * @param int $options The options to pass to export(). - * - * @return void */ - public function assertExportEquals(string $expected, $var, int $options = 0) : void + public function assertExportEquals(string $expected, mixed $var, int $options = 0): void { // test the string output of export() @@ -39,16 +44,14 @@ public function assertExportEquals(string $expected, $var, int $options = 0) : v // only test if SKIP_DYNAMIC_PROPERTIES is not set, as this might create a non-equal object if (0 === ($options & VarExporter::SKIP_DYNAMIC_PROPERTIES)) { - $this->assertEquals($var, $exportedVar, 'The eval()ed exported var is different from the original var.'); + self::assertEquals($var, $exportedVar, 'The eval()ed exported var is different from the original var.'); } // if the exported value is a closure with no parameters, test that the exported closure returns the same // value as the original closure - if ($var instanceof \Closure) { - if ((new \ReflectionFunction($var))->getNumberOfRequiredParameters() === 0) { - $this->assertSame($var(), ($exportedVar()), 'The exported closure does not return the same value as the original closure.'); - } + if ($var instanceof Closure && (new ReflectionFunction($var))->getNumberOfRequiredParameters() === 0) { + self::assertSame($var(), ($exportedVar()), 'The exported closure does not return the same value as the original closure.'); } } @@ -58,17 +61,13 @@ public function assertExportEquals(string $expected, $var, int $options = 0) : v * @param string $expectedMessage The expected exception message. Can use '*' as a placeholder. * @param mixed $var The variable to export. * @param int $options The options to pass to export(). - * - * @return void */ - public function assertExportThrows(string $expectedMessage, $var, int $options = 0) : void + public function assertExportThrows(string $expectedMessage, mixed $var, int $options = 0): void { - $expectedMessageRegExp = '/' . implode('.*', array_map(function(string $str) { - return preg_quote($str, '/'); - }, explode('*', $expectedMessage))) . '/'; + $expectedMessageRegExp = '/' . implode('.*', array_map(fn (string $str) => preg_quote($str, '/'), explode('*', $expectedMessage))) . '/'; $this->expectException(ExportException::class); - $this->expectExceptionMessageRegExp($expectedMessageRegExp); + $this->expectExceptionMessageMatches($expectedMessageRegExp); VarExporter::export($var, $options); } diff --git a/tests/Classes/Enum.php b/tests/Classes/Enum.php new file mode 100644 index 0000000..850403d --- /dev/null +++ b/tests/Classes/Enum.php @@ -0,0 +1,10 @@ +baz = $baz; } - protected function unsetBaz() : void + protected function unsetBaz(): void { unset($this->baz); } diff --git a/tests/Classes/PublicPropertiesOnly.php b/tests/Classes/PublicPropertiesOnly.php index fbd3f73..23b6d7d 100644 --- a/tests/Classes/PublicPropertiesOnly.php +++ b/tests/Classes/PublicPropertiesOnly.php @@ -10,5 +10,6 @@ class PublicPropertiesOnly { public $foo; + public $bar; } diff --git a/tests/Classes/PublicReadonlyPropertiesWithoutConstructor.php b/tests/Classes/PublicReadonlyPropertiesWithoutConstructor.php new file mode 100644 index 0000000..a47349c --- /dev/null +++ b/tests/Classes/PublicReadonlyPropertiesWithoutConstructor.php @@ -0,0 +1,14 @@ + $this->foo, - 'bar' => $this->bar + 'bar' => $this->bar, ]; } - public function __unserialize(array $array) : void + public function __unserialize(array $array): void { $this->foo = $array['foo']; $this->bar = $array['bar']; diff --git a/tests/Classes/SetState.php b/tests/Classes/SetState.php index 5773079..76ceb71 100644 --- a/tests/Classes/SetState.php +++ b/tests/Classes/SetState.php @@ -4,11 +4,13 @@ namespace Brick\VarExporter\Tests\Classes; +use function array_key_exists; + class SetState extends PublicAndPrivateProperties { - public static function __set_state(array $array) : self + public static function __set_state(array $array): self { - $object = new self; + $object = new self(); // note: these checks would usually not be necessary in a typical app; they're just here because // we need to test our implementation against edge cases, such as unset properties. diff --git a/tests/ExportClosureTest.php b/tests/ExportClosureTest.php index c529428..67e4e05 100644 --- a/tests/ExportClosureTest.php +++ b/tests/ExportClosureTest.php @@ -4,6 +4,7 @@ namespace Brick\VarExporter\Tests; +use Brick\VarExporter\Tests\Classes\Enum; use Brick\VarExporter\Tests\Classes\NoProperties; use Brick\VarExporter\Tests\Classes\PublicPropertiesOnly; use Brick\VarExporter\Tests\Classes\SetState; @@ -20,22 +21,22 @@ */ class ExportClosureTest extends AbstractTestCase { - public function testExportSimpleClosure() + public function testExportSimpleClosure(): void { $var = function() { - echo 'Hello, world!'; + return 'Hello, world!'; }; $expected = <<<'PHP' -function () { - echo 'Hello, world!'; -} -PHP; + function () { + return 'Hello, world!'; + } + PHP; $this->assertExportEquals($expected, $var); } - public function testExportNestedComplexClosure() + public function testExportNestedComplexClosure(): void { $var = [ (object) [ @@ -58,25 +59,25 @@ public function testExportNestedComplexClosure() ]; $expected = <<<'PHP' -[ - (object) [ - 'callback' => function () { - return function (\Brick\VarExporter\Tests\Classes\PublicPropertiesOnly $a, int $b, string &$c, string ...$d) : ?string { - $a->foo += $b; - $c = $a->bar; - $a->bar = implode('', $d); - $this->someProp = [$a->foo, $a->bar, $c]; - return $this->someProp['c']; - }; - } - ] -] -PHP; + [ + (object) [ + 'callback' => function () { + return function (\Brick\VarExporter\Tests\Classes\PublicPropertiesOnly $a, int $b, string &$c, string ...$d): ?string { + $a->foo += $b; + $c = $a->bar; + $a->bar = implode('', $d); + $this->someProp = [$a->foo, $a->bar, $c]; + return $this->someProp['c']; + }; + } + ] + ] + PHP; $this->assertExportEquals($expected, $var); } - public function testExportNamespacedCode() + public function testExportNamespacedCode(): void { $var = function(SetState $a) : array { return [ @@ -91,59 +92,59 @@ public function testExportNamespacedCode() }; $expected = <<<'PHP' -function (\Brick\VarExporter\Tests\Classes\SetState $a) : array { - return ['callback' => function (\Brick\VarExporter\Tests\Classes\SetState $a) : \Brick\VarExporter\Tests\Classes\NoProperties { - strlen(PHP_VERSION); - \Brick\VarExporter\Dummy\Functions\imported_function(\Brick\VarExporter\Dummy\Constants\IMPORTED_CONSTANT); - \Brick\VarExporter\Dummy\Functions\explicitly_namespaced_function(\Brick\VarExporter\Dummy\Constants\EXPLICITLY_NAMESPACED_CONSTANT); - return new \Brick\VarExporter\Tests\Classes\NoProperties(); - }]; -} -PHP; + function (\Brick\VarExporter\Tests\Classes\SetState $a): array { + return ['callback' => function (\Brick\VarExporter\Tests\Classes\SetState $a): \Brick\VarExporter\Tests\Classes\NoProperties { + strlen(PHP_VERSION); + \Brick\VarExporter\Dummy\Functions\imported_function(\Brick\VarExporter\Dummy\Constants\IMPORTED_CONSTANT); + \Brick\VarExporter\Dummy\Functions\explicitly_namespaced_function(\Brick\VarExporter\Dummy\Constants\EXPLICITLY_NAMESPACED_CONSTANT); + return new \Brick\VarExporter\Tests\Classes\NoProperties(); + }]; + } + PHP; $this->assertExportEquals($expected, $var); } - public function testExportClosureWithStringsContainingLikeBreaks() + public function testExportClosureWithStringsContainingLikeBreaks(): void { $var = function() { $a = 'Hello, World!'; $b = <<assertExportEquals($expected, $var, VarExporter::ADD_RETURN); } - public function testExportClosureWithUse() + public function testExportClosureWithUse(): void { $foo = 'bar'; @@ -151,31 +152,153 @@ public function testExportClosureWithUse() return $foo; }; - $this->assertExportThrows("The closure has bound variables through 'use', this is not supported.", $var); + $this->assertExportThrows( + "The closure has bound variables through 'use', this is not supported by default. " . + "Use the CLOSURE_SNAPSHOT_USE option to export them.", + $var + ); + } + + public function testExportClosureWithUseAsVars(): void + { + $foo = 'b' . 'a' . 'r'; + + $var = function() use ($foo) { + return $foo; + }; + + $expected = <<<'PHP' + return function () { + $foo = 'bar'; + return $foo; + }; + + PHP; + + $this->assertExportEquals($expected, $var, VarExporter::ADD_RETURN | VarExporter::CLOSURE_SNAPSHOT_USES); + } + + public function testExportClosureWithUseClosure(): void + { + $foo = 'b' . 'a' . 'r'; + + $sub = function () use ($foo) { + return $foo; + }; + + $var = function() use ($sub) { + return $sub(); + }; + + $expected = <<<'PHP' + return function () { + $sub = function () { + $foo = 'bar'; + return $foo; + }; + return $sub(); + }; + + PHP; + + $this->assertExportEquals($expected, $var, VarExporter::ADD_RETURN | VarExporter::CLOSURE_SNAPSHOT_USES); } - public function testExportClosureDefinedInEval() + public function testExportArrowFunction(): void + { + $var = [fn ($planet) => 'hello ' . $planet]; // Wrapping in array for valid syntax PHP <7.4 + + $expected = <<<'PHP' + return [ + function ($planet) { + return 'hello ' . $planet; + } + ]; + + PHP; + + $this->assertExportEquals($expected, $var, VarExporter::ADD_RETURN); + } + + public function testExportArrowFunctionWithContext(): void + { + $greet = 'hello'; + + $var = [fn ($planet) => $greet . ' ' . $planet]; + + $this->assertExportThrows( + "The arrow function uses variables in the parent scope, this is not supported by default. " . + "Use the CLOSURE_SNAPSHOT_USE option to export them.", + $var + ); + } + + public function testExportArrowFunctionWithContextVarAsVar(): void + { + $greet = 'hello'; + + $var = [fn ($planet) => $greet . ' ' . $planet]; + + $expected = <<<'PHP' + return [ + function ($planet) { + $greet = 'hello'; + return $greet . ' ' . $planet; + } + ]; + + PHP; + + $this->assertExportEquals($expected, $var, VarExporter::ADD_RETURN | VarExporter::CLOSURE_SNAPSHOT_USES); + } + + public function testExportEnumMatchFunction(): void + { + $var = [ + (object) [ + 'callback' => static fn (Enum $enum): string => match ($enum) { + Enum::TEST => 'foo', + } + ] + ]; + + $expected = <<<'PHP' + [ + (object) [ + 'callback' => function (\Brick\VarExporter\Tests\Classes\Enum $enum): string { + return match ($enum) { + \Brick\VarExporter\Tests\Classes\Enum::TEST => 'foo', + }; + } + ] + ] + PHP; + + $this->assertExportEquals($expected, $var); + } + + public function testExportClosureDefinedInEval(): void { $var = eval(<<assertExportThrows("Closure defined in eval()'d code cannot be exported.", $var); } - public function testExportTwoClosuresOnSameLine() + public function testExportTwoClosuresOnSameLine(): void { $var = function() { return function() {}; }; $this->assertExportThrows("Expected exactly 1 closure in */tests/ExportClosureTest.php on line *, found 2.", $var); } - public function testExportClosureDisabled() + public function testExportClosureDisabled(): void { $var = function() { - echo 'Hello, world!'; + return 'Hello, world!'; }; $this->assertExportThrows('Class "Closure" is internal, and cannot be exported.', $var, VarExporter::NO_CLOSURES); diff --git a/tests/ExportObjectTest.php b/tests/ExportObjectTest.php index fa6e93e..d1530af 100644 --- a/tests/ExportObjectTest.php +++ b/tests/ExportObjectTest.php @@ -5,77 +5,83 @@ namespace Brick\VarExporter\Tests; use Brick\VarExporter\Tests\Classes\ConstructorAndNoProperties; -use Brick\VarExporter\Tests\Classes\NoProperties; +use Brick\VarExporter\Tests\Classes\Enum; use Brick\VarExporter\Tests\Classes\Hierarchy; -use Brick\VarExporter\Tests\Classes\PublicPropertiesWithConstructor; +use Brick\VarExporter\Tests\Classes\NoProperties; use Brick\VarExporter\Tests\Classes\PrivateConstructor; use Brick\VarExporter\Tests\Classes\PublicPropertiesOnly; +use Brick\VarExporter\Tests\Classes\PublicPropertiesWithConstructor; +use Brick\VarExporter\Tests\Classes\PublicReadonlyPropertiesWithoutConstructor; +use Brick\VarExporter\Tests\Classes\ReadonlyPropertiesWithConstructor; use Brick\VarExporter\Tests\Classes\SerializeMagicMethods; use Brick\VarExporter\Tests\Classes\SerializeMagicMethodsWithConstructor; use Brick\VarExporter\Tests\Classes\SetState; use Brick\VarExporter\Tests\Classes\SetStateWithOverriddenPrivateProperties; use Brick\VarExporter\VarExporter; +use stdClass; /** * Tests exporting various objects. */ class ExportObjectTest extends AbstractTestCase { - public function testExportStdClass() + public function testExportStdClass(): void { - $object = new \stdClass; + $object = new stdClass(); $object->foo = 'Hello'; $object->bar = 'bar'; - $object->baz = new \stdClass; + $object->baz = new stdClass(); $object->baz->foo = 'Hello'; $expected = <<<'PHP' -(object) [ - 'foo' => 'Hello', - 'bar' => 'bar', - 'baz' => (object) [ - 'foo' => 'Hello' - ] -] -PHP; + (object) [ + 'foo' => 'Hello', + 'bar' => 'bar', + 'baz' => (object) [ + 'foo' => 'Hello' + ] + ] + PHP; $this->assertExportEquals($expected, $object); } - public function testExportClassWithNoProperties() + public function testExportClassWithNoProperties(): void { - $object = new NoProperties; + $object = new NoProperties(); $expected = 'new \Brick\VarExporter\Tests\Classes\NoProperties'; $this->assertExportEquals($expected, $object); } - public function testExportClassWithDynamicPropertiesOnly() + public function testExportClassWithDynamicPropertiesOnly(): void { - $object = new NoProperties; + $object = new NoProperties(); $object->x = 1.0; $object->dynamicProp = 'Hello'; $object->{'$weird%Prop'} = 'World'; + $object->{'123'} = 'Numeric dynamic prop'; $expected = <<<'PHP' -(static function() { - $object = new \Brick\VarExporter\Tests\Classes\NoProperties; + (static function() { + $object = new \Brick\VarExporter\Tests\Classes\NoProperties; - $object->x = 1.0; - $object->dynamicProp = 'Hello'; - $object->{'$weird%Prop'} = 'World'; + $object->x = 1.0; + $object->dynamicProp = 'Hello'; + $object->{'$weird%Prop'} = 'World'; + $object->{'123'} = 'Numeric dynamic prop'; - return $object; -})() -PHP; + return $object; + })() + PHP; $this->assertExportEquals($expected, $object); } - public function testExportClassWithDynamicPropertiesOnly_SkipDynamicProperties() + public function testExportClassWithDynamicPropertiesOnly_SkipDynamicProperties(): void { - $object = new NoProperties; + $object = new NoProperties(); $object->dynamicProp = 'Hello'; $object->{'$weird%Prop'} = 'World'; @@ -84,412 +90,463 @@ public function testExportClassWithDynamicPropertiesOnly_SkipDynamicProperties() $this->assertExportEquals($expected, $object, VarExporter::SKIP_DYNAMIC_PROPERTIES); } - public function testExportClassWithConstructorAndNoProperties() + public function testExportClassWithConstructorAndNoProperties(): void { $object = new ConstructorAndNoProperties(); $expected = <<<'PHP' -(static function() { - $class = new \ReflectionClass(\Brick\VarExporter\Tests\Classes\ConstructorAndNoProperties::class); - $object = $class->newInstanceWithoutConstructor(); + (static function() { + $class = new \ReflectionClass(\Brick\VarExporter\Tests\Classes\ConstructorAndNoProperties::class); + $object = $class->newInstanceWithoutConstructor(); - return $object; -})() -PHP; + return $object; + })() + PHP; $this->assertExportEquals($expected, $object, VarExporter::SKIP_DYNAMIC_PROPERTIES); } - public function testExportClassWithPublicPropertiesOnly() + public function testExportClassWithPublicPropertiesOnly(): void { - $object = new PublicPropertiesOnly; + $object = new PublicPropertiesOnly(); $object->foo = 'Hello'; $object->bar = 'World'; $expected = <<<'PHP' -(static function() { - $object = new \Brick\VarExporter\Tests\Classes\PublicPropertiesOnly; + (static function() { + $object = new \Brick\VarExporter\Tests\Classes\PublicPropertiesOnly; - $object->foo = 'Hello'; - $object->bar = 'World'; + $object->foo = 'Hello'; + $object->bar = 'World'; - return $object; -})() -PHP; + return $object; + })() + PHP; $this->assertExportEquals($expected, $object); } - public function testExportObjectWithPublicAndDynamicProperties() + public function testExportObjectWithPublicAndDynamicProperties(): void { - $object = new PublicPropertiesOnly; + $object = new PublicPropertiesOnly(); $object->foo = 'Hello'; $object->bar = 'World'; $object->dynamic = 'Dynamic'; $expected = <<<'PHP' -(static function() { - $object = new \Brick\VarExporter\Tests\Classes\PublicPropertiesOnly; + (static function() { + $object = new \Brick\VarExporter\Tests\Classes\PublicPropertiesOnly; - $object->foo = 'Hello'; - $object->bar = 'World'; - $object->dynamic = 'Dynamic'; + $object->foo = 'Hello'; + $object->bar = 'World'; + $object->dynamic = 'Dynamic'; - return $object; -})() -PHP; + return $object; + })() + PHP; $this->assertExportEquals($expected, $object); } - public function testExportObjectWithPublicAndDynamicProperties_SkipDynamicProperties() + public function testExportObjectWithPublicAndDynamicProperties_SkipDynamicProperties(): void { - $object = new PublicPropertiesOnly; + $object = new PublicPropertiesOnly(); $object->foo = 'Hello'; $object->bar = 'World'; $object->dynamic = 'Dynamic'; $expected = <<<'PHP' -(static function() { - $object = new \Brick\VarExporter\Tests\Classes\PublicPropertiesOnly; + (static function() { + $object = new \Brick\VarExporter\Tests\Classes\PublicPropertiesOnly; - $object->foo = 'Hello'; - $object->bar = 'World'; + $object->foo = 'Hello'; + $object->bar = 'World'; - return $object; -})() -PHP; + return $object; + })() + PHP; $this->assertExportEquals($expected, $object, VarExporter::SKIP_DYNAMIC_PROPERTIES); } - public function testExportClassWithSetState() + public function testExportClassWithSetState(): void { - $object = new SetState; + $object = new SetState(); $object->foo = 'Hello'; $object->bar = 'World'; $expected = <<<'PHP' -\Brick\VarExporter\Tests\Classes\SetState::__set_state([ - 'baz' => 'defaultValue', - 'foo' => 'Hello', - 'bar' => 'World' -]) -PHP; + \Brick\VarExporter\Tests\Classes\SetState::__set_state([ + 'foo' => 'Hello', + 'bar' => 'World', + 'baz' => 'defaultValue' + ]) + PHP; $this->assertExportEquals($expected, $object); } - public function testExportClassWithSetStateAndUnsetProperties() + public function testExportClassWithSetStateAndUnsetProperties(): void { - $object = new SetState; + $object = new SetState(); $object->foo = null; unset($object->bar); $expected = <<<'PHP' -\Brick\VarExporter\Tests\Classes\SetState::__set_state([ - 'baz' => 'defaultValue', - 'foo' => null -]) -PHP; + \Brick\VarExporter\Tests\Classes\SetState::__set_state([ + 'foo' => null, + 'baz' => 'defaultValue' + ]) + PHP; $this->assertExportEquals($expected, $object); } - public function testExportClassWithSetStateAndDynamicProperties() + public function testExportClassWithSetStateAndDynamicProperties(): void { - $object = new SetState; + $object = new SetState(); $object->foo = 'Hello'; $object->bar = 'World'; $object->dynamic = 'Dynamic property'; + $object->{'123'} = 'Numeric dynamic property'; $expected = <<<'PHP' -\Brick\VarExporter\Tests\Classes\SetState::__set_state([ - 'baz' => 'defaultValue', - 'foo' => 'Hello', - 'bar' => 'World', - 'dynamic' => 'Dynamic property' -]) -PHP; + \Brick\VarExporter\Tests\Classes\SetState::__set_state([ + 'foo' => 'Hello', + 'bar' => 'World', + 'baz' => 'defaultValue', + 'dynamic' => 'Dynamic property', + 123 => 'Numeric dynamic property' + ]) + PHP; $this->assertExportEquals($expected, $object); } - public function testExportClassWithSetStateAndDynamicProperties_SkipDynamicProperties() + public function testExportClassWithSetStateAndDynamicProperties_SkipDynamicProperties(): void { - $object = new SetState; + $object = new SetState(); $object->foo = 'Hello'; $object->bar = 'World'; $object->dynamic = 'Dynamic property'; $expected = <<<'PHP' -\Brick\VarExporter\Tests\Classes\SetState::__set_state([ - 'baz' => 'defaultValue', - 'foo' => 'Hello', - 'bar' => 'World' -]) -PHP; + \Brick\VarExporter\Tests\Classes\SetState::__set_state([ + 'foo' => 'Hello', + 'bar' => 'World', + 'baz' => 'defaultValue' + ]) + PHP; $this->assertExportEquals($expected, $object, VarExporter::SKIP_DYNAMIC_PROPERTIES); } - public function testExportClassWithSetStateAndOverriddenPrivateProperties() + public function testExportClassWithSetStateAndOverriddenPrivateProperties(): void { - $object = new SetStateWithOverriddenPrivateProperties; + $object = new SetStateWithOverriddenPrivateProperties(); $expectedMessage = 'Class "Brick\VarExporter\Tests\Classes\SetStateWithOverriddenPrivateProperties" has overridden private property "baz". This is not supported for exporting objects with __set_state().'; $this->assertExportThrows($expectedMessage, $object); } - public function testExportClassWithPrivateConstructor() + public function testExportClassWithPrivateConstructor(): void { $object = PrivateConstructor::create(); $object->foo = 'Foo'; $object->bar = 'Bar'; $expected = <<<'PHP' -(static function() { - $class = new \ReflectionClass(\Brick\VarExporter\Tests\Classes\PrivateConstructor::class); - $object = $class->newInstanceWithoutConstructor(); + (static function() { + $class = new \ReflectionClass(\Brick\VarExporter\Tests\Classes\PrivateConstructor::class); + $object = $class->newInstanceWithoutConstructor(); - $object->foo = 'Foo'; - $object->bar = 'Bar'; + $object->foo = 'Foo'; + $object->bar = 'Bar'; - return $object; -})() -PHP; + return $object; + })() + PHP; $this->assertExportEquals($expected, $object); } - public function testExportClassWithPublicPropertiesAndConstructor() + public function testExportClassWithPublicPropertiesAndConstructor(): void { $object = new PublicPropertiesWithConstructor(); $expected = <<<'PHP' -(static function() { - $class = new \ReflectionClass(\Brick\VarExporter\Tests\Classes\PublicPropertiesWithConstructor::class); - $object = $class->newInstanceWithoutConstructor(); + (static function() { + $class = new \ReflectionClass(\Brick\VarExporter\Tests\Classes\PublicPropertiesWithConstructor::class); + $object = $class->newInstanceWithoutConstructor(); + + $object->foo = 'DefaultFoo'; + $object->bar = 0; + + return $object; + })() + PHP; + + $this->assertExportEquals($expected, $object); + } + + public function testExportClassWithReadonlyPublicPropertiesAndConstructor(): void + { + $object = new ReadonlyPropertiesWithConstructor('public readonly', 'private readonly', 'public'); + + $expected = <<<'PHP' + (static function() { + $class = new \ReflectionClass(\Brick\VarExporter\Tests\Classes\ReadonlyPropertiesWithConstructor::class); + $object = $class->newInstanceWithoutConstructor(); - $object->foo = 'DefaultFoo'; - $object->bar = 0; + $object->baz = 'public'; - return $object; -})() -PHP; + (function() { + $this->foo = 'public readonly'; + $this->bar = 'private readonly'; + })->bindTo($object, \Brick\VarExporter\Tests\Classes\ReadonlyPropertiesWithConstructor::class)(); + + return $object; + })() + PHP; $this->assertExportEquals($expected, $object); } - public function testExportClassWithSerializeMagicMethods() + public function testExportClassWithStateAndReadonlyPublicProperties(): void { - $object = new SerializeMagicMethods; + $object = new PublicReadonlyPropertiesWithoutConstructor(); + + (function (): void { + $this->foo = 'foo'; + })->bindTo($object, PublicReadonlyPropertiesWithoutConstructor::class)(); + + $expected = <<<'PHP' + (static function() { + $object = new \Brick\VarExporter\Tests\Classes\PublicReadonlyPropertiesWithoutConstructor; + + unset($object->baz); + + (function() { + $this->foo = 'foo'; + unset($this->bar); + })->bindTo($object, \Brick\VarExporter\Tests\Classes\PublicReadonlyPropertiesWithoutConstructor::class)(); + + return $object; + })() + PHP; + + $this->assertExportEquals($expected, $object); + } + + public function testExportClassWithSerializeMagicMethods(): void + { + $object = new SerializeMagicMethods(); $object->foo = 'Foo'; $object->bar = [1, 2]; $expected = <<<'PHP' -(static function() { - $object = new \Brick\VarExporter\Tests\Classes\SerializeMagicMethods; + (static function() { + $object = new \Brick\VarExporter\Tests\Classes\SerializeMagicMethods; - $object->__unserialize([ - 'foo' => 'Foo', - 'bar' => [ - 1, - 2 - ] - ]); + $object->__unserialize([ + 'foo' => 'Foo', + 'bar' => [ + 1, + 2 + ] + ]); - return $object; -})() -PHP; + return $object; + })() + PHP; $this->assertExportEquals($expected, $object); } - public function testExportClassWithSerializeMagicMethodsAndConstructor() + public function testExportClassWithSerializeMagicMethodsAndConstructor(): void { $object = new SerializeMagicMethodsWithConstructor('Test', 1234); $expected = <<<'PHP' -(static function() { - $class = new \ReflectionClass(\Brick\VarExporter\Tests\Classes\SerializeMagicMethodsWithConstructor::class); - $object = $class->newInstanceWithoutConstructor(); + (static function() { + $class = new \ReflectionClass(\Brick\VarExporter\Tests\Classes\SerializeMagicMethodsWithConstructor::class); + $object = $class->newInstanceWithoutConstructor(); - $object->__unserialize([ - 'foo' => 'Test', - 'bar' => 1234 - ]); + $object->__unserialize([ + 'foo' => 'Test', + 'bar' => 1234 + ]); - return $object; -})() -PHP; + return $object; + })() + PHP; $this->assertExportEquals($expected, $object); } - public function testExportClassWithSerializeMagicMethodsAndConstructor_AddTypeHints() + public function testExportClassWithSerializeMagicMethodsAndConstructor_AddTypeHints(): void { $object = new SerializeMagicMethodsWithConstructor('Test', 1234); $expected = <<<'PHP' -(static function() { - $class = new \ReflectionClass(\Brick\VarExporter\Tests\Classes\SerializeMagicMethodsWithConstructor::class); + (static function() { + $class = new \ReflectionClass(\Brick\VarExporter\Tests\Classes\SerializeMagicMethodsWithConstructor::class); - /** @var \Brick\VarExporter\Tests\Classes\SerializeMagicMethodsWithConstructor $object */ - $object = $class->newInstanceWithoutConstructor(); + /** @var \Brick\VarExporter\Tests\Classes\SerializeMagicMethodsWithConstructor $object */ + $object = $class->newInstanceWithoutConstructor(); - $object->__unserialize([ - 'foo' => 'Test', - 'bar' => 1234 - ]); + $object->__unserialize([ + 'foo' => 'Test', + 'bar' => 1234 + ]); - return $object; -})() -PHP; + return $object; + })() + PHP; $this->assertExportEquals($expected, $object, VarExporter::ADD_TYPE_HINTS); } - public function testExportClassHierarchy() + public function testExportClassHierarchy(): void { $object = Hierarchy\C::create(); $object->dynamicProperty = 'A property declared dynamically'; $expected = <<<'PHP' -(static function() { - $class = new \ReflectionClass(\Brick\VarExporter\Tests\Classes\Hierarchy\C::class); - $object = $class->newInstanceWithoutConstructor(); - - $object->publicInC = 'public in C'; - $object->publicInB = 'public in B'; - $object->publicInA = 'public in A'; - $object->dynamicProperty = 'A property declared dynamically'; - - (function() { - $this->privateInC = 'private in C'; - $this->protectedInC = 'protected in C'; - $this->privateOverridden = 'in C'; - $this->protectedInB = 'protected in B'; - $this->protectedInA = 'protected in A'; - })->bindTo($object, \Brick\VarExporter\Tests\Classes\Hierarchy\C::class)(); - - (function() { - $this->privateInB = 'private in B'; - $this->privateOverridden = 'in B'; - })->bindTo($object, \Brick\VarExporter\Tests\Classes\Hierarchy\B::class)(); - - (function() { - $this->privateInA = 'private in A'; - $this->privateOverridden = 'in A'; - })->bindTo($object, \Brick\VarExporter\Tests\Classes\Hierarchy\A::class)(); - - return $object; -})() -PHP; + (static function() { + $class = new \ReflectionClass(\Brick\VarExporter\Tests\Classes\Hierarchy\C::class); + $object = $class->newInstanceWithoutConstructor(); + + $object->publicInC = 'public in C'; + $object->publicInB = 'public in B'; + $object->publicInA = 'public in A'; + $object->dynamicProperty = 'A property declared dynamically'; + + (function() { + $this->privateInC = 'private in C'; + $this->protectedInC = 'protected in C'; + $this->privateOverridden = 'in C'; + $this->protectedInB = 'protected in B'; + $this->protectedInA = 'protected in A'; + })->bindTo($object, \Brick\VarExporter\Tests\Classes\Hierarchy\C::class)(); + + (function() { + $this->privateInB = 'private in B'; + $this->privateOverridden = 'in B'; + })->bindTo($object, \Brick\VarExporter\Tests\Classes\Hierarchy\B::class)(); + + (function() { + $this->privateInA = 'private in A'; + $this->privateOverridden = 'in A'; + })->bindTo($object, \Brick\VarExporter\Tests\Classes\Hierarchy\A::class)(); + + return $object; + })() + PHP; $this->assertExportEquals($expected, $object); } - public function testExportClassHierarchy_AddTypeHints_SkipDynamicProperties() + public function testExportClassHierarchy_AddTypeHints_SkipDynamicProperties(): void { $object = Hierarchy\C::create(); $object->dynamicProperty = 'A property declared dynamically'; $expected = <<<'PHP' -(static function() { - $class = new \ReflectionClass(\Brick\VarExporter\Tests\Classes\Hierarchy\C::class); - - /** @var \Brick\VarExporter\Tests\Classes\Hierarchy\C $object */ - $object = $class->newInstanceWithoutConstructor(); - - $object->publicInC = 'public in C'; - $object->publicInB = 'public in B'; - $object->publicInA = 'public in A'; - - (function() { - /** @var \Brick\VarExporter\Tests\Classes\Hierarchy\C $this */ - $this->privateInC = 'private in C'; - $this->protectedInC = 'protected in C'; - $this->privateOverridden = 'in C'; - $this->protectedInB = 'protected in B'; - $this->protectedInA = 'protected in A'; - })->bindTo($object, \Brick\VarExporter\Tests\Classes\Hierarchy\C::class)(); - - (function() { - /** @var \Brick\VarExporter\Tests\Classes\Hierarchy\B $this */ - $this->privateInB = 'private in B'; - $this->privateOverridden = 'in B'; - })->bindTo($object, \Brick\VarExporter\Tests\Classes\Hierarchy\B::class)(); - - (function() { - /** @var \Brick\VarExporter\Tests\Classes\Hierarchy\A $this */ - $this->privateInA = 'private in A'; - $this->privateOverridden = 'in A'; - })->bindTo($object, \Brick\VarExporter\Tests\Classes\Hierarchy\A::class)(); - - return $object; -})() -PHP; + (static function() { + $class = new \ReflectionClass(\Brick\VarExporter\Tests\Classes\Hierarchy\C::class); + + /** @var \Brick\VarExporter\Tests\Classes\Hierarchy\C $object */ + $object = $class->newInstanceWithoutConstructor(); + + $object->publicInC = 'public in C'; + $object->publicInB = 'public in B'; + $object->publicInA = 'public in A'; + + (function() { + /** @var \Brick\VarExporter\Tests\Classes\Hierarchy\C $this */ + $this->privateInC = 'private in C'; + $this->protectedInC = 'protected in C'; + $this->privateOverridden = 'in C'; + $this->protectedInB = 'protected in B'; + $this->protectedInA = 'protected in A'; + })->bindTo($object, \Brick\VarExporter\Tests\Classes\Hierarchy\C::class)(); + + (function() { + /** @var \Brick\VarExporter\Tests\Classes\Hierarchy\B $this */ + $this->privateInB = 'private in B'; + $this->privateOverridden = 'in B'; + })->bindTo($object, \Brick\VarExporter\Tests\Classes\Hierarchy\B::class)(); + + (function() { + /** @var \Brick\VarExporter\Tests\Classes\Hierarchy\A $this */ + $this->privateInA = 'private in A'; + $this->privateOverridden = 'in A'; + })->bindTo($object, \Brick\VarExporter\Tests\Classes\Hierarchy\A::class)(); + + return $object; + })() + PHP; $this->assertExportEquals($expected, $object, VarExporter::ADD_TYPE_HINTS | VarExporter::SKIP_DYNAMIC_PROPERTIES); } - public function testExportClassHierarchyWithUnsetProperties() + public function testExportClassHierarchyWithUnsetProperties(): void { $object = Hierarchy\C::create(); $object->publicInA = null; unset($object->publicInB); - (function() { + (function (): void { /** @var Hierarchy\C $this */ unset($this->privateInC); unset($this->protectedInB); })->bindTo($object, Hierarchy\C::class)(); - (function() { + (function (): void { /** @var Hierarchy\A $this */ unset($this->privateOverridden); })->bindTo($object, Hierarchy\A::class)(); $expected = <<<'PHP' -(static function() { - $class = new \ReflectionClass(\Brick\VarExporter\Tests\Classes\Hierarchy\C::class); - $object = $class->newInstanceWithoutConstructor(); - - $object->publicInC = 'public in C'; - $object->publicInA = null; - unset($object->publicInB); - - (function() { - $this->protectedInC = 'protected in C'; - $this->privateOverridden = 'in C'; - $this->protectedInA = 'protected in A'; - unset($this->privateInC); - unset($this->protectedInB); - })->bindTo($object, \Brick\VarExporter\Tests\Classes\Hierarchy\C::class)(); - - (function() { - $this->privateInB = 'private in B'; - $this->privateOverridden = 'in B'; - })->bindTo($object, \Brick\VarExporter\Tests\Classes\Hierarchy\B::class)(); - - (function() { - $this->privateInA = 'private in A'; - unset($this->privateOverridden); - })->bindTo($object, \Brick\VarExporter\Tests\Classes\Hierarchy\A::class)(); - - return $object; -})() -PHP; + (static function() { + $class = new \ReflectionClass(\Brick\VarExporter\Tests\Classes\Hierarchy\C::class); + $object = $class->newInstanceWithoutConstructor(); + + $object->publicInC = 'public in C'; + $object->publicInA = null; + unset($object->publicInB); + + (function() { + $this->protectedInC = 'protected in C'; + $this->privateOverridden = 'in C'; + $this->protectedInA = 'protected in A'; + unset($this->privateInC); + unset($this->protectedInB); + })->bindTo($object, \Brick\VarExporter\Tests\Classes\Hierarchy\C::class)(); + + (function() { + $this->privateInB = 'private in B'; + $this->privateOverridden = 'in B'; + })->bindTo($object, \Brick\VarExporter\Tests\Classes\Hierarchy\B::class)(); + + (function() { + $this->privateInA = 'private in A'; + unset($this->privateOverridden); + })->bindTo($object, \Brick\VarExporter\Tests\Classes\Hierarchy\A::class)(); + + return $object; + })() + PHP; $this->assertExportEquals($expected, $object); } - public function testExportObjectWithRestrictiveOptions() + public function testExportObjectWithRestrictiveOptions(): void { $object = new PublicPropertiesOnly(); @@ -499,4 +556,15 @@ public function testExportObjectWithRestrictiveOptions() $this->assertExportThrows($expectedMessage, $object, VarExporter::NOT_ANY_OBJECT); } + + public function testExportEnum(): void + { + $object = Enum::TEST; + + $expected = <<<'PHP' + Brick\VarExporter\Tests\Classes\Enum::TEST + PHP; + + $this->assertExportEquals($expected, $object); + } } diff --git a/tests/VarExporterTest.php b/tests/VarExporterTest.php index e037238..7898745 100644 --- a/tests/VarExporterTest.php +++ b/tests/VarExporterTest.php @@ -4,14 +4,26 @@ namespace Brick\VarExporter\Tests; +use ArrayIterator; use Brick\VarExporter\ExportException; +use Brick\VarExporter\Tests\Classes\Enum; use Brick\VarExporter\Tests\Classes\PublicPropertiesOnly; use Brick\VarExporter\Tests\Classes\SetState; use Brick\VarExporter\VarExporter; +use DateTime; +use DateTimeImmutable; +use DateTimeZone; +use PHPUnit\Framework\Attributes\DataProvider; +use stdClass; + +use function fopen; +use function str_replace; + +use const PHP_EOL; class VarExporterTest extends AbstractTestCase { - public function testMixedVar() + public function testMixedVar(): void { $myObject = new PublicPropertiesOnly(); $myObject->foo = 'hello'; @@ -27,102 +39,102 @@ public function testMixedVar() 'aFloat' => 0.75, 'anInt' => 123, 'aNumericArray' => ['a', 'b', null, [ - 'c' => 'd' + 'c' => 'd', ]], 'anAssociativeArray' => [ 'a' => 'b', 'c' => [ 'd' => 'e', 'f' => [ - 'g' => [[]] - ] - ] + 'g' => [[]], + ], + ], ], 'anObject' => (object) [ 'type' => 'string', '$ref' => '#/components/schema/User', 'items' => (object) [ 'foo' => 'bar', - 'empty' => (object) [] - ] + 'empty' => (object) [], + ], ], - 'aCustomObject' => $myObject + 'aCustomObject' => $myObject, ]; $expected = <<<'PHP' -[ - 'aString' => 'Hello', - 'aTrue' => true, - 'aFalse' => false, - 'aNull' => null, - 'aFloat' => 0.75, - 'anInt' => 123, - 'aNumericArray' => [ - 'a', - 'b', - null, - [ - 'c' => 'd' - ] - ], - 'anAssociativeArray' => [ - 'a' => 'b', - 'c' => [ - 'd' => 'e', - 'f' => [ - 'g' => [ - [] + [ + 'aString' => 'Hello', + 'aTrue' => true, + 'aFalse' => false, + 'aNull' => null, + 'aFloat' => 0.75, + 'anInt' => 123, + 'aNumericArray' => [ + 'a', + 'b', + null, + [ + 'c' => 'd' + ] + ], + 'anAssociativeArray' => [ + 'a' => 'b', + 'c' => [ + 'd' => 'e', + 'f' => [ + 'g' => [ + [] + ] + ] + ] + ], + 'anObject' => (object) [ + 'type' => 'string', + '$ref' => '#/components/schema/User', + 'items' => (object) [ + 'foo' => 'bar', + 'empty' => (object) [] + ] + ], + 'aCustomObject' => (static function() { + $object = new \Brick\VarExporter\Tests\Classes\PublicPropertiesOnly; + + $object->foo = 'hello'; + $object->bar = \Brick\VarExporter\Tests\Classes\SetState::__set_state([ + 'foo' => 'SetState.foo', + 'bar' => 'SetState.bar', + 'baz' => 'defaultValue' + ]); + + return $object; + })() ] - ] - ] - ], - 'anObject' => (object) [ - 'type' => 'string', - '$ref' => '#/components/schema/User', - 'items' => (object) [ - 'foo' => 'bar', - 'empty' => (object) [] - ] - ], - 'aCustomObject' => (static function() { - $object = new \Brick\VarExporter\Tests\Classes\PublicPropertiesOnly; - - $object->foo = 'hello'; - $object->bar = \Brick\VarExporter\Tests\Classes\SetState::__set_state([ - 'baz' => 'defaultValue', - 'foo' => 'SetState.foo', - 'bar' => 'SetState.bar' - ]); - - return $object; - })() -] -PHP; + PHP; $this->assertExportEquals($expected, $var); } - public function testExportObjectPropWithSpecialChars() + public function testExportObjectPropWithSpecialChars(): void { - $object = new PublicPropertiesOnly; + $object = new PublicPropertiesOnly(); $object->{'$ref'} = '#/components/schemas/User'; $expected = <<<'PHP' -(static function() { - $object = new \Brick\VarExporter\Tests\Classes\PublicPropertiesOnly; + (static function() { + $object = new \Brick\VarExporter\Tests\Classes\PublicPropertiesOnly; - $object->foo = null; - $object->bar = null; - $object->{'$ref'} = '#/components/schemas/User'; + $object->foo = null; + $object->bar = null; + $object->{'$ref'} = '#/components/schemas/User'; - return $object; -})() -PHP; + return $object; + })() + PHP; $this->assertExportEquals($expected, $object); } - public function testAddReturn() + public function testAddReturn(): void { $var = []; $expected = 'return [];' . PHP_EOL; @@ -130,25 +142,168 @@ public function testAddReturn() $this->assertExportEquals($expected, $var, VarExporter::ADD_RETURN); } - public function testExportInternalClass() + public function testInlineArray(): void + { + $var = [ + 'one' => ['hello', 'world', 123, true, false, null, 7.5], + 'two' => ['hello', 'world', [1 => 'one', 'two', 'three']], + ]; + + $expected = <<<'PHP' + ['one' => ['hello', 'world', 123, true, false, null, 7.5], 'two' => ['hello', 'world', [1 => 'one', 2 => 'two', 3 => 'three']]] + PHP; + + $this->assertExportEquals($expected, $var, VarExporter::INLINE_ARRAY); + } + + public function testInlineScalarList(): void + { + $var = [ + 'one' => ['hello', 'world', 123, true, false, null, 7.5], + 'two' => ['hello', 'world', ['one', 'two', 'three']], + ]; + + $expected = <<<'PHP' + [ + 'one' => ['hello', 'world', 123, true, false, null, 7.5], + 'two' => [ + 'hello', + 'world', + ['one', 'two', 'three'] + ] + ] + PHP; + + $this->assertExportEquals($expected, $var, VarExporter::INLINE_SCALAR_LIST); + } + + public function testInlineScalarListWithEnum(): void + { + $var = [ + 'one' => ['hello', 'world', 123, true, false, null, 7.5, Enum::TEST], + 'two' => ['hello', 'world', ['one', 'two', 'three']], + ]; + + $expected = <<<'PHP' + [ + 'one' => [ + 'hello', + 'world', + 123, + true, + false, + null, + 7.5, + Brick\VarExporter\Tests\Classes\Enum::TEST + ], + 'two' => [ + 'hello', + 'world', + ['one', 'two', 'three'] + ] + ] + PHP; + + $this->assertExportEquals($expected, $var, VarExporter::INLINE_SCALAR_LIST); + } + + public function testInlineLiteralList(): void + { + $var = [ + 'one' => ['hello', 'world', 123, true, false, null, 7.5, Enum::TEST], + 'two' => ['hello', 'world', ['one', 'two', 'three']], + ]; + + $expected = <<<'PHP' + [ + 'one' => ['hello', 'world', 123, true, false, null, 7.5, Brick\VarExporter\Tests\Classes\Enum::TEST], + 'two' => [ + 'hello', + 'world', + ['one', 'two', 'three'] + ] + ] + PHP; + + $this->assertExportEquals($expected, $var, VarExporter::INLINE_LITERAL_LIST); + } + + public function testTrailingCommaInArray(): void { - $object = new \stdClass; - $object->iterator = new \ArrayIterator(); + $var = [ + 'one' => ['hello', 'world', 123, true, false, null, 7.5], + 'two' => ['hello', 'world', ['one', 'two', 'three']], + ]; + + $expected = <<<'PHP' + [ + 'one' => ['hello', 'world', 123, true, false, null, 7.5], + 'two' => [ + 'hello', + 'world', + ['one', 'two', 'three'], + ], + ] + PHP; + + $this->assertExportEquals($expected, $var, VarExporter::INLINE_SCALAR_LIST | VarExporter::TRAILING_COMMA_IN_ARRAY); + } + + public function testExportDateTime(): void + { + $timezone = new DateTimeZone('Europe/Berlin'); + $format = 'Y-m-d H:i:s.u'; + + $var = DateTime::createFromFormat($format, '2020-03-09 18:51:23.000000', $timezone); + + $expected = <<<'PHP' + \DateTime::__set_state([ + 'date' => '2020-03-09 18:51:23.000000', + 'timezone_type' => 3, + 'timezone' => 'Europe/Berlin' + ]) + PHP; + + $this->assertExportEquals($expected, $var); + } + + public function testExportDateTimeImmutable(): void + { + $timezone = new DateTimeZone('Europe/Berlin'); + $format = 'Y-m-d H:i:s.u'; + + $var = DateTimeImmutable::createFromFormat($format, '2020-03-10 17:06:19.000000', $timezone); + + $expected = <<<'PHP' + \DateTimeImmutable::__set_state([ + 'date' => '2020-03-10 17:06:19.000000', + 'timezone_type' => 3, + 'timezone' => 'Europe/Berlin' + ]) + PHP; + + $this->assertExportEquals($expected, $var); + } + + public function testExportInternalClass(): void + { + $object = new stdClass(); + $object->iterator = new ArrayIterator(); $expectedMessage = 'Class "ArrayIterator" is internal, and cannot be exported.'; $this->assertExportThrows($expectedMessage, $object); } - public function testExportResource() + public function testExportResource(): void { $handle = fopen('php://memory', 'rb+'); // bury it deep $object = (object) [ 'foo' => (object) [ - 'bar' => $handle - ] + 'bar' => $handle, + ], ]; $this->expectException(ExportException::class); @@ -157,45 +312,45 @@ public function testExportResource() VarExporter::export($object); } - public function testExportObjectTwiceWithoutCircularReference() + public function testExportObjectTwiceWithoutCircularReference(): void { - $a = new PublicPropertiesOnly; + $a = new PublicPropertiesOnly(); $a->foo = 'Foo'; $a->bar = 'Bar'; $var = [ 'x' => $a, - 'y' => $a + 'y' => $a, ]; $expected = <<<'PHP' -[ - 'x' => (static function() { - $object = new \Brick\VarExporter\Tests\Classes\PublicPropertiesOnly; + [ + 'x' => (static function() { + $object = new \Brick\VarExporter\Tests\Classes\PublicPropertiesOnly; - $object->foo = 'Foo'; - $object->bar = 'Bar'; + $object->foo = 'Foo'; + $object->bar = 'Bar'; - return $object; - })(), - 'y' => (static function() { - $object = new \Brick\VarExporter\Tests\Classes\PublicPropertiesOnly; + return $object; + })(), + 'y' => (static function() { + $object = new \Brick\VarExporter\Tests\Classes\PublicPropertiesOnly; - $object->foo = 'Foo'; - $object->bar = 'Bar'; + $object->foo = 'Foo'; + $object->bar = 'Bar'; - return $object; - })() -] -PHP; + return $object; + })() + ] + PHP; $this->assertExportEquals($expected, $var); } - public function testExportObjectWithCircularReference() + public function testExportObjectWithCircularReference(): void { - $a = new PublicPropertiesOnly; - $b = new PublicPropertiesOnly; + $a = new PublicPropertiesOnly(); + $b = new PublicPropertiesOnly(); $a->foo = $b; $b->foo = $a; @@ -205,8 +360,127 @@ public function testExportObjectWithCircularReference() VarExporter::export([ 'x' => [ - 'y' => $a - ] + 'y' => $a, + ], ]); } + + #[DataProvider('providerExportIndented')] + public function testExportIndented(mixed $var, string $expected, int $options): void + { + $template = <<<'TPL' + public function foo() + { + $data = {{exported}}; + } + TPL; + + $exported = VarExporter::export($var, $options, 1); + $result = str_replace('{{exported}}', $exported, $template); + + self::assertSame($expected, $result); + } + + public static function providerExportIndented(): iterable + { + // Array + $var = ['one' => ['hello', true], 'two' => 2]; + $expected = <<<'PHP' + public function foo() + { + $data = [ + 'one' => [ + 'hello', + true + ], + 'two' => 2 + ]; + } + PHP; + yield [$var, $expected, 0]; + + // Null + $var = null; + $expected = <<<'PHP' + public function foo() + { + $data = null; + } + PHP; + yield [$var, $expected, 0]; + + // Closure + $var = function () { + return 'Hello, world!'; + }; + $expected = <<<'PHP' + public function foo() + { + $data = function () { + return 'Hello, world!'; + }; + } + PHP; + yield [$var, $expected, 0]; + + $foo = 'bar'; + $sub = function () use ($foo) { + return $foo; + }; + $var = function () use ($sub) { + return $sub(); + }; + + $expected = <<<'PHP' + public function foo() + { + $data = function () { + $sub = function () { + $foo = 'bar'; + return $foo; + }; + return $sub(); + }; + } + PHP; + yield [$var, $expected, VarExporter::CLOSURE_SNAPSHOT_USES]; + + $var = function () { + $a = 'Hello, +World!'; + + $b = <<import(__DIR__ . '/vendor/brick/coding-standard/ecs.php'); + + $libRootPath = realpath(__DIR__ . '/../../'); + + $ecsConfig->paths( + [ + $libRootPath . '/src', + $libRootPath . '/tests', + __FILE__, + ], + ); + + $ecsConfig->skip([ + // uses unknown functions etc., let's not touch it + $libRootPath . '/tests/ExportClosureTest.php', + + // tests expect a certain order of class elements + OrderedClassElementsFixer::class => $libRootPath . '/tests/Classes/Hierarchy/*.php', + + // assertEquals() is used intentionally in assertExportEquals() + PhpUnitStrictFixer::class => $libRootPath . 'tests/AbstractTestCase.php', + ]); +};