Compare commits

...

51 Commits
8.0.3 ... main

Author SHA1 Message Date
Sebastian Bergmann 642e2bbda7
Introduce Exporter::hasCustomRepresentationFor() for checking whether an object exporter provides the representation for an object 2026-08-01 09:45:25 +02:00
Sebastian Bergmann 1ff285c572
Consult custom object exporter when exporting a value into a single-line string 2026-08-01 09:27:34 +02:00
Sebastian Bergmann 60d658c5b5
Fix formatting 2026-08-01 09:14:52 +02:00
Sebastian Bergmann 58c91cd888
Document that exporting a value can throw ObjectNotSupportedException 2026-08-01 09:13:57 +02:00
Sebastian Bergmann 792150d027
Accept any object exporter, not just a chain, in Exporter's constructor 2026-08-01 09:11:45 +02:00
Sebastian Bergmann b5aff00a52
Update documentation 2026-08-01 09:09:00 +02:00
Sebastian Bergmann 067b4fde58
Ask custom object exporter for representation of enum before using default representation 2026-08-01 08:57:29 +02:00
Sebastian Bergmann b9ec7d93fe
Prevent duplicate array references and endless recursion when a custom object exporter exports nested values 2026-08-01 08:49:42 +02:00
Sebastian Bergmann 5f9dc382bf
Ask custom object exporter for representation before consulting recursion context 2026-07-30 07:56:57 +02:00
renovate[bot] 7e531fa24d Update dependency tomasvotruba/type-coverage to ^2.3.0 2026-07-30 04:57:56 +00:00
renovate[bot] 4f08958e05 Update dependency phpstan/phpstan to ^2.2.7 2026-07-30 01:28:08 +00:00
renovate[bot] d3ff5fec0e Update dependency phpstan/phpstan to ^2.2.6 2026-07-27 02:14:17 +00:00
renovate[bot] d3cc5ab80b Update actions/checkout action to v7.0.1 2026-07-25 21:50:59 +00:00
renovate[bot] 2f54d7b25f Update dependency phpstan/phpstan-strict-rules to ^2.0.12 2026-07-19 16:09:14 +00:00
Sebastian Bergmann 3f33ba4c67
Introduce ObjectExporter interface and ObjectExporterChain for customizing how objects are exported 2026-07-13 13:48:57 +02:00
Sebastian Bergmann cfaa77c750
Prepare release 2026-07-13 13:35:11 +02:00
renovate[bot] 020a76a804 Update dependency phpunit/phpunit to ^13.2.4 2026-07-08 15:56:21 +00:00
renovate[bot] 80e37628a9 Update dependency phpunit/phpunit to ^13.2.3 2026-07-06 20:51:12 +00:00
renovate[bot] 4864aa2dac Update dependency phpstan/phpstan to ^2.2.5 2026-07-05 13:09:34 +00:00
renovate[bot] 33eb9b136b Update dependency phpstan/phpstan to ^2.2.4 2026-07-03 15:41:22 +00:00
renovate[bot] 17e1967798 Update dependency phpstan/phpstan to ^2.2.3 2026-07-01 00:30:26 +00:00
renovate[bot] 2187505ead Update dependency phpunit/phpunit to ^13.2.2 2026-06-30 19:29:27 +00:00
renovate[bot] 38122ad449 Update actions/cache action to v6.1.0 2026-06-27 02:01:59 +00:00
renovate[bot] 6218f433e8 Update github-actions 2026-06-23 20:53:54 +00:00
renovate[bot] 1b6cb53e74 Update github-actions to v7 2026-06-22 08:13:07 +02:00
renovate[bot] 380e0104f7 Update dependency phpunit/phpunit to ^13.2.1 2026-06-16 05:56:13 +00:00
renovate[bot] f202473cb1 Update github-actions to v2.37.2 2026-06-08 21:49:52 +00:00
renovate[bot] 39cb211d31 Update dependency tomasvotruba/type-coverage to ^2.2.2 2026-06-07 17:38:35 +00:00
renovate[bot] 862a07a930 Update github-actions to v7 2026-06-07 13:15:21 +00:00
renovate[bot] 183b82cc23 Update github-actions to v6.0.2 2026-06-07 08:28:19 +00:00
renovate[bot] 7f308faee2 Update PHPStan packages to ^2.2.2 2026-06-05 15:11:51 +00:00
renovate[bot] a2fd7eabd9 Update dependency phpunit/phpunit to ^13.2.0 2026-06-05 10:43:04 +00:00
renovate[bot] 94a4f8ce9b Update github-actions to v1.0.1 2026-06-04 13:51:03 +00:00
renovate[bot] 40dc091991 Update dependency phpunit/phpunit to ^13.1.14 2026-06-04 13:50:46 +00:00
renovate[bot] 4f8268a02a Update github-actions to v6.0.3 2026-06-02 22:55:11 +00:00
renovate[bot] 8839e93dba Update dependency phpstan/phpstan to ^2.2.1 2026-05-29 00:05:35 +00:00
renovate[bot] a97aec49ff Update dependency phpunit/phpunit to ^13.1.13 2026-05-27 21:27:14 +00:00
renovate[bot] e43b042d47 Update dependency tomasvotruba/type-coverage to ^2.2.1 2026-05-27 05:53:35 +00:00
Sebastian Bergmann 830d67d80f
Also automerge minor updates 2026-05-27 07:51:02 +02:00
renovate[bot] 570a3312f9 Update dependency phpstan/phpstan to ^2.1.56 2026-05-27 02:40:02 +00:00
renovate[bot] 94572e37dc Update dependency phpunit/phpunit to ^13.1.12 2026-05-25 22:15:14 +00:00
Sebastian Bergmann b54c4ddf23
Treat lone LF followed by lone CR as two separate line endings 2026-05-23 06:27:19 +02:00
renovate[bot] fb73a7e90f Update dependency phpunit/phpunit to ^13.1.11 2026-05-21 18:38:36 +00:00
Sebastian Bergmann c0d29a945f
Prepare release 2026-05-21 13:50:56 +02:00
Sebastian Bergmann 78d76615cd
Bump 2026-05-21 13:48:12 +02:00
Sebastian Bergmann 1f94fb33ae
Update ChangeLog 2026-05-21 13:47:24 +02:00
Sebastian Bergmann 4a11e3aea7
Add tests 2026-05-21 13:39:57 +02:00
Sebastian Bergmann c3c99d6629
Make binary string output readable for mostly-printable values 2026-05-20 08:16:59 +02:00
Sebastian Bergmann 41cdd17bf0
Closes #57 2026-05-20 07:48:57 +02:00
Sebastian Bergmann 08b965bdef
Merge branch '7.0'
* 7.0:
  Fix package name
2026-05-20 06:53:00 +02:00
Sebastian Bergmann 7028549592
Fix package name 2026-05-20 06:52:54 +02:00
308 changed files with 17288 additions and 8699 deletions

View File

@ -24,4 +24,4 @@ jobs:
permissions:
pull-requests: write # required to post the API surface diff comment on the PR
steps:
- uses: composer/api-surface-check@c6308838120342a01682bb7f4f5ba058135e1452 # 1.0.0
- uses: composer/api-surface-check@bba9f1cb940df6e02271b1b6158679ac0b3f39d3 # 1.0.1

View File

@ -11,7 +11,7 @@ concurrency:
cancel-in-progress: true
env:
COMPOSER_ROOT_VERSION: 8.0.x-dev
COMPOSER_ROOT_VERSION: 8.2.x-dev
PHP_VERSION: 8.4
permissions:
@ -26,7 +26,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha || github.sha }}
@ -49,7 +49,7 @@ jobs:
git checkout "$BRANCH"
- name: Install PHP
uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: ${{ env.PHP_VERSION }}
coverage: none
@ -66,7 +66,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha || github.sha }}
@ -89,7 +89,7 @@ jobs:
git checkout "$BRANCH"
- name: Install PHP
uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: ${{ env.PHP_VERSION }}
coverage: none
@ -117,7 +117,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha || github.sha }}
@ -140,7 +140,7 @@ jobs:
git checkout "$BRANCH"
- name: Install PHP with extensions
uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: ${{ matrix.php-version }}
coverage: none
@ -153,7 +153,7 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
- name: Cache Composer cache directory
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
@ -176,7 +176,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha || github.sha }}
@ -199,7 +199,7 @@ jobs:
git checkout "$BRANCH"
- name: Install PHP with extensions
uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: ${{ matrix.php-version }}
coverage: xdebug
@ -212,7 +212,7 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
- name: Cache Composer cache directory
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
@ -226,7 +226,7 @@ jobs:
- name: Upload test results to Codecov.io
if: ${{ !cancelled() }}
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
report_type: test_results
@ -235,7 +235,7 @@ jobs:
- name: Upload code coverage data to Codecov.io
if: ${{ !cancelled() }}
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
report_type: coverage

View File

@ -25,12 +25,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install PHP with extensions
uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: 8.3
coverage: none
@ -46,4 +46,4 @@ jobs:
- name: Create release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release create "${RELEASE_TAG}" --title "sebastian/cli-parser ${RELEASE_TAG}" --notes-file release-notes.md
run: gh release create "${RELEASE_TAG}" --title "sebastian/exporter ${RELEASE_TAG}" --notes-file release-notes.md

View File

@ -2,6 +2,27 @@
All notable changes are documented in this file using the [Keep a CHANGELOG](https://keepachangelog.com/) principles.
## [8.2.0] - 2026-08-07
### Added
* `ObjectExporter` interface, `ObjectExporterChain`, and `ExportContext` for customizing how objects are exported (an object exporter can be passed to `Exporter`'s constructor and is consulted before the default representation of an object is used)
* `Exporter::shortenedExport()` and `Exporter::shortenedRecursiveExport()` use the representation provided by an object exporter as well; it is collapsed to a single line and shortened when it is longer than the configured maximum length
* `Exporter::hasCustomRepresentationFor()` for checking whether an object exporter provides the representation for an object
## [8.1.1] - 2026-07-13
### Fixed
* A lone `\n` (LF) followed by a lone `\r` (CR) was incorrectly treated as a single `\n\r` line ending when exporting a string; these are now treated as two separate line endings
## [8.1.0] - 2026-05-21
### Changed
* [#57](https://github.com/sebastianbergmann/exporter/issues/57): Preserve `SplObjectStorage` iterator position instead of resetting to first element
* [#91](https://github.com/sebastianbergmann/exporter/pull/91): Make binary string output readable for mostly-printable values
## [8.0.3] - 2026-05-20
### Fixed
@ -48,6 +69,9 @@ All notable changes are documented in this file using the [Keep a CHANGELOG](htt
* This component is no longer supported on PHP 8.2
[8.2.0]: https://github.com/sebastianbergmann/exporter/compare/8.1.1...main
[8.1.1]: https://github.com/sebastianbergmann/exporter/compare/8.1.0...8.1.1
[8.1.0]: https://github.com/sebastianbergmann/exporter/compare/8.0.3...8.1.0
[8.0.3]: https://github.com/sebastianbergmann/exporter/compare/8.0.2...8.0.3
[8.0.2]: https://github.com/sebastianbergmann/exporter/compare/8.0.1...8.0.2
[8.0.1]: https://github.com/sebastianbergmann/exporter/compare/8.0.0...8.0.1

256
README.md
View File

@ -25,19 +25,19 @@ composer require --dev sebastian/exporter
Exporting:
```php
<?php
<?php declare(strict_types=1);
use SebastianBergmann\Exporter\Exporter;
$exporter = new Exporter;
/*
Exception Object &0000000078de0f0d000000002003a261 (
'message' => ''
'string' => ''
'code' => 0
'file' => '/home/sebastianbergmann/test.php'
'line' => 34
'previous' => null
Exception Object #4 (
'message' => '',
'string' => '',
'code' => 0,
'file' => '/home/sebastianbergmann/test.php',
'line' => 34,
'previous' => null,
)
*/
@ -49,7 +49,7 @@ print $exporter->export(new Exception);
Exporting simple types:
```php
<?php
<?php declare(strict_types=1);
use SebastianBergmann\Exporter\Exporter;
$exporter = new Exporter;
@ -85,64 +85,66 @@ print $exporter->export(chr(0) . chr(1) . chr(2) . chr(3) . chr(4) . chr(5));
Exporting complex types:
```php
<?php
<?php declare(strict_types=1);
use SebastianBergmann\Exporter\Exporter;
$exporter = new Exporter;
/*
Array &0 (
0 => Array &1 (
0 => 1
1 => 2
2 => 3
)
1 => Array &2 (
0 => ''
1 => 0
2 => false
)
)
Array &0 [
0 => Array &1 [
0 => 1,
1 => 2,
2 => 3,
],
1 => Array &2 [
0 => '',
1 => 0,
2 => false,
],
]
*/
print $exporter->export(array(array(1,2,3), array("",0,FALSE)));
print $exporter->export([[1, 2, 3], ['', 0, false]]);
/*
Array &0 (
'self' => Array &1 (
'self' => Array &1
)
)
Array &0 [
'self' => Array &1 [
'self' => Array &1,
],
]
*/
$array = array();
$array = [];
$array['self'] = &$array;
print $exporter->export($array);
/*
stdClass Object &0000000003a66dcc0000000025e723e2 (
'self' => stdClass Object &0000000003a66dcc0000000025e723e2
stdClass Object #4 (
'self' => stdClass Object #4,
)
*/
$obj = new stdClass();
$obj = new stdClass;
$obj->self = $obj;
print $exporter->export($obj);
```
Compact exports:
```php
<?php
<?php declare(strict_types=1);
use SebastianBergmann\Exporter\Exporter;
$exporter = new Exporter;
// Array ()
print $exporter->shortenedExport(array());
// []
print $exporter->shortenedExport([]);
// Array (...)
print $exporter->shortenedExport(array(1,2,3,4,5));
// [...]
print $exporter->shortenedExport([1, 2, 3, 4, 5]);
// stdClass Object ()
print $exporter->shortenedExport(new stdClass);
@ -150,7 +152,7 @@ print $exporter->shortenedExport(new stdClass);
// Exception Object (...)
print $exporter->shortenedExport(new Exception);
// this\nis\na\nsuper\nlong\nstring\nt...\nspace
// 'this\nis\na\nsuper\nlong\nstr...nspace'
print $exporter->shortenedExport(
<<<LONG_STRING
this
@ -173,3 +175,181 @@ space
LONG_STRING
);
```
## Customizing How Objects Are Exported
By default, an object is exported property by property. Implement the `ObjectExporter` interface to control how objects of a type are represented:
```php
<?php declare(strict_types=1);
use SebastianBergmann\Exporter\ExportContext;
use SebastianBergmann\Exporter\Exporter;
use SebastianBergmann\Exporter\ObjectExporter;
final class MoneyExporter implements ObjectExporter
{
public function handles(object $object): bool
{
return $object instanceof Money;
}
public function export(object $object, Exporter $exporter, int $indentation, ExportContext $context): string
{
assert($object instanceof Money);
return sprintf('Money (%d %s)', $object->amount(), $object->currency());
}
}
```
An object exporter is registered by passing it to `Exporter`'s constructor. It is consulted before the default representation of an object is used:
```php
<?php declare(strict_types=1);
use SebastianBergmann\Exporter\Exporter;
$exporter = new Exporter(objectExporter: new MoneyExporter);
// Money (1999 EUR)
print $exporter->export(new Money(1999, 'EUR'));
/*
stdClass Object #6 (
'net' => Money (1999 EUR),
'gross' => Money (2379 EUR),
)
*/
$price = new stdClass;
$price->net = new Money(1999, 'EUR');
$price->gross = new Money(2379, 'EUR');
print $exporter->export($price);
```
`ObjectExporterChain` composes multiple object exporters into a single one. They are consulted in the order in which they are composed into the chain and the first one whose `handles()` method returns `true` is asked for the representation:
```php
<?php declare(strict_types=1);
use SebastianBergmann\Exporter\Exporter;
use SebastianBergmann\Exporter\ObjectExporterChain;
$exporter = new Exporter(
objectExporter: new ObjectExporterChain(
[
new BasketExporter,
new MoneyExporter,
],
),
);
```
Enums are objects and are therefore passed to the object exporters as well. When no object exporter handles an object, the default representation is used for it.
### Repeated Occurrences
When the same object occurs more than once, the default representation is only used for the first occurrence; subsequent occurrences are replaced with a reference to the object:
```php
/*
Array &0 [
0 => Money Object #8 (
'amount' => 1999,
'currency' => 'EUR',
),
1 => Money Object #8,
]
*/
$money = new Money(1999, 'EUR');
print (new Exporter)->export([$money, $money]);
```
An object exporter is responsible for the entire representation of the object it handles. Every occurrence of such an object is therefore exported by it:
```php
/*
Array &0 [
0 => Money (1999 EUR),
1 => Money (1999 EUR),
]
*/
print $exporter->export([$money, $money]);
```
The exception is an object that is (indirectly) nested in itself: it cannot be exported this way without recursing infinitely and is replaced with a reference to the object.
### Exporting Nested Values
An object exporter may use the `Exporter` it is given to export values that are nested in the object it handles. The `ExportContext` it is given must be passed on when it does. Otherwise, the export of these nested values starts over with an empty context and, for instance, assigns references to arrays that are already in use elsewhere in the same export:
```php
<?php declare(strict_types=1);
use SebastianBergmann\Exporter\ExportContext;
use SebastianBergmann\Exporter\Exporter;
use SebastianBergmann\Exporter\ObjectExporter;
final class BasketExporter implements ObjectExporter
{
public function handles(object $object): bool
{
return $object instanceof Basket;
}
public function export(object $object, Exporter $exporter, int $indentation, ExportContext $context): string
{
assert($object instanceof Basket);
return 'Basket ' . $exporter->export($object->items(), $indentation, $context);
}
}
```
```php
/*
Array &0 [
'basket' => Basket Array &1 [
0 => Money (1999 EUR),
],
]
*/
print $exporter->export(['basket' => new Basket([new Money(1999, 'EUR')])]);
```
### Compact Exports
`Exporter::shortenedExport()` and `Exporter::shortenedRecursiveExport()` consult object exporters as well. The representation an object exporter provides is collapsed to a single line and shortened when it is longer than the configured maximum length:
```php
<?php declare(strict_types=1);
use SebastianBergmann\Exporter\Exporter;
$exporter = new Exporter(objectExporter: new MoneyExporter);
// Money (1999 EUR)
print $exporter->shortenedExport(new Money(1999, 'EUR'));
```
Bear in mind that these representations are used where a short, single-line, and stable string is required. The representation of a data set is built using `Exporter::shortenedRecursiveExport()`, for instance, and is part of the name of a test that uses a data provider. An object exporter should therefore provide a representation that is compact and that does not change from one export to the next.
### Custom Representations
`Exporter::hasCustomRepresentationFor()` tells whether an object exporter provides the representation for an object. This is meant for code that renders objects itself and wants to use the representation an object exporter provides when there is one:
```php
<?php declare(strict_types=1);
use SebastianBergmann\Exporter\Exporter;
$exporter = new Exporter(objectExporter: new MoneyExporter);
// true
var_dump($exporter->hasCustomRepresentationFor(new Money(1999, 'EUR')));
// false
var_dump($exporter->hasCustomRepresentationFor(new stdClass));
```
Bear in mind that an object that is (indirectly) nested in itself is replaced with a reference to the object instead of being exported by an object exporter again.

View File

@ -37,6 +37,7 @@
"optimize-autoloader": true,
"sort-packages": true
},
"minimum-stability": "dev",
"prefer-stable": true,
"require": {
"php": ">=8.4",
@ -44,7 +45,7 @@
"sebastian/recursion-context": "^8.0"
},
"require-dev": {
"phpunit/phpunit": "^13.1.10"
"phpunit/phpunit": "^13.3"
},
"autoload": {
"classmap": [
@ -58,7 +59,7 @@
},
"extra": {
"branch-alias": {
"dev-main": "8.0-dev"
"dev-main": "8.2-dev"
}
}
}

View File

@ -36,8 +36,8 @@
"automerge": true
},
{
"description": "Automerge patch updates after they have settled",
"matchUpdateTypes": ["patch", "pin", "digest"],
"description": "Automerge minor and patch updates after they have settled",
"matchUpdateTypes": ["minor", "patch", "pin", "digest"],
"automerge": true
},
{

82
src/ExportContext.php Normal file
View File

@ -0,0 +1,82 @@
<?php declare(strict_types=1);
/*
* This file is part of sebastian/exporter.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace SebastianBergmann\Exporter;
use SebastianBergmann\RecursionContext\Context as RecursionContext;
use SplObjectStorage;
/**
* Carries the state that is shared by all steps of a single export operation.
*
* An implementation of ObjectExporter that exports values nested in the object
* it handles must pass the instance of this class it is given to the Exporter
* it delegates to. Otherwise, the export of these nested values starts over
* with an empty context and, for instance, assigns references to arrays that
* are already in use elsewhere in the same export.
*/
final class ExportContext
{
private RecursionContext $recursionContext;
/**
* @var SplObjectStorage<object, null>
*/
private SplObjectStorage $exportedByObjectExporter;
public function __construct()
{
$this->recursionContext = new RecursionContext;
$this->exportedByObjectExporter = new SplObjectStorage;
}
/**
* @template T of array|object
*
* @param T $value
*
* @param-out T $value
*/
public function add(array|object &$value): int
{
return $this->recursionContext->add($value);
}
/**
* @template T of array|object
*
* @param T $value
*
* @param-out T $value
*/
public function contains(array|object &$value): false|int
{
return $this->recursionContext->contains($value);
}
/**
* Whether the export of an object by an ObjectExporter is in progress.
*
* This is the case when the object is (indirectly) nested in itself.
*/
public function isBeingExportedByObjectExporter(object $object): bool
{
return $this->exportedByObjectExporter->offsetExists($object);
}
public function beginExportByObjectExporter(object $object): void
{
$this->exportedByObjectExporter->offsetSet($object);
}
public function endExportByObjectExporter(object $object): void
{
$this->exportedByObjectExporter->offsetUnset($object);
}
}

View File

@ -28,11 +28,16 @@ use function is_resource;
use function is_string;
use function mb_strlen;
use function mb_substr;
use function ord;
use function preg_match;
use function preg_match_all;
use function preg_replace_callback;
use function spl_object_id;
use function sprintf;
use function str_contains;
use function str_repeat;
use function str_replace;
use function strlen;
use function strpbrk;
use function strtr;
use function var_export;
@ -56,15 +61,17 @@ final readonly class Exporter
* @var positive-int
*/
private int $maxLengthForStrings;
private ?ObjectExporter $objectExporter;
/**
* @param non-negative-int $shortenArraysLongerThan
* @param positive-int $maxLengthForStrings
*/
public function __construct(int $shortenArraysLongerThan = 0, int $maxLengthForStrings = 40)
public function __construct(int $shortenArraysLongerThan = 0, int $maxLengthForStrings = 40, ?ObjectExporter $objectExporter = null)
{
$this->shortenArraysLongerThan = $shortenArraysLongerThan;
$this->maxLengthForStrings = $maxLengthForStrings;
$this->objectExporter = $objectExporter;
}
/**
@ -79,15 +86,23 @@ final readonly class Exporter
* - Strings are always quoted with single quotes
* - Carriage returns and newlines are normalized to \n
* - Recursion and repeated rendering is treated properly
*
* An implementation of ObjectExporter must pass the ExportContext it is
* given to this method when it exports values that are nested in the
* object it handles.
*
* @throws ObjectNotSupportedException
*/
public function export(mixed $value, int $indentation = 0): string
public function export(mixed $value, int $indentation = 0, ?ExportContext $context = null): string
{
return $this->recursiveExport($value, $indentation);
return $this->recursiveExport($value, $indentation, $context);
}
/**
* @param array<mixed> $data
* @param positive-int $maxLengthForStrings
*
* @throws ObjectNotSupportedException
*/
public function shortenedRecursiveExport(array &$data, int $maxLengthForStrings = 40, ?RecursionContext $processed = null): string
{
@ -121,7 +136,13 @@ final readonly class Exporter
* Newlines are replaced by the visible string '\n'.
* Contents of arrays and objects (if any) are replaced by '...'.
*
* The representation a custom object exporter provides for an object is
* used, but it is collapsed to a single line and shortened when it is
* longer than $maxLengthForStrings.
*
* @param positive-int $maxLengthForStrings
*
* @throws ObjectNotSupportedException
*/
public function shortenedExport(mixed $value, int $maxLengthForStrings = 40): string
{
@ -130,13 +151,20 @@ final readonly class Exporter
}
if (is_string($value)) {
$string = str_replace("\n", '', $this->exportString($value));
return $this->shorten($this->exportString($value), $maxLengthForStrings);
}
if (mb_strlen($string) > $maxLengthForStrings) {
return mb_substr($string, 0, $maxLengthForStrings - 10) . '...' . mb_substr($string, -7);
}
if ($this->objectExporter !== null &&
is_object($value) &&
$this->objectExporter->handles($value)) {
$context = new ExportContext;
return $string;
$context->beginExportByObjectExporter($value);
return $this->shorten(
$this->objectExporter->export($value, $this, 0, $context),
$maxLengthForStrings,
);
}
if ($value instanceof BackedEnum) {
@ -174,6 +202,26 @@ final readonly class Exporter
return $this->export($value);
}
/**
* Returns whether a custom object exporter provides the representation
* for an object.
*
* This is intended for code that renders objects itself and wants to use
* the representation a custom object exporter provides when there is one.
*
* Bear in mind that an object that is (indirectly) nested in itself is
* replaced with a reference to the object instead of being exported by a
* custom object exporter again.
*/
public function hasCustomRepresentationFor(object $object): bool
{
if ($this->objectExporter === null) {
return false;
}
return $this->objectExporter->handles($object);
}
/**
* Converts an object to an array containing all of its private, protected
* and public properties.
@ -230,6 +278,12 @@ final readonly class Exporter
// above (fast) mechanism nor with reflection in Zend.
// Format the output similarly to print_r() in this case
if ($value instanceof SplObjectStorage) {
$key = null;
if ($value->valid()) {
$key = $value->key();
}
foreach ($value as $_value) {
$array['Object #' . spl_object_id($_value)] = [
'obj' => $_value,
@ -237,7 +291,9 @@ final readonly class Exporter
];
}
$value->rewind();
if ($key !== null) {
$value->seek($key);
}
}
return $array;
@ -299,6 +355,8 @@ final readonly class Exporter
/**
* @param array<mixed> $data
* @param positive-int $maxLengthForStrings
*
* @throws ObjectNotSupportedException
*/
private function shortenedCountedRecursiveExport(array &$data, RecursionContext $processed, int &$counter, int $maxLengthForStrings): string
{
@ -335,7 +393,10 @@ final readonly class Exporter
return implode(', ', $result);
}
private function recursiveExport(mixed &$value, int $indentation = 0, ?RecursionContext $processed = null): string
/**
* @throws ObjectNotSupportedException
*/
private function recursiveExport(mixed &$value, int $indentation = 0, ?ExportContext $context = null): string
{
if ($value === null) {
return 'null';
@ -362,44 +423,42 @@ final readonly class Exporter
);
}
if ($value instanceof BackedEnum) {
return sprintf(
'%s Enum #%d (%s, %s)',
$value::class,
spl_object_id($value),
$value->name,
$this->export($value->value),
);
}
if ($value instanceof UnitEnum) {
return sprintf(
'%s Enum #%d (%s)',
$value::class,
spl_object_id($value),
$value->name,
);
}
if (is_string($value)) {
return $this->exportString($value);
}
if ($processed === null) {
$processed = new RecursionContext;
if ($context === null) {
$context = new ExportContext;
}
if (is_array($value)) {
return $this->exportArray($value, $processed, $indentation);
return $this->exportArray($value, $context, $indentation);
}
if (is_object($value)) {
return $this->exportObject($value, $processed, $indentation);
return $this->exportObject($value, $context, $indentation);
}
return var_export($value, true);
}
/**
* Collapses a representation to a single line and shortens it when it is
* longer than $maxLengthForStrings.
*
* @param positive-int $maxLengthForStrings
*/
private function shorten(string $string, int $maxLengthForStrings): string
{
$string = str_replace(["\r", "\n"], '', $string);
if (mb_strlen($string) > $maxLengthForStrings) {
return mb_substr($string, 0, $maxLengthForStrings - 10) . '...' . mb_substr($string, -7);
}
return $string;
}
private function exportFloat(float $value): string
{
if (is_nan($value)) {
@ -429,34 +488,58 @@ final readonly class Exporter
private function exportString(string $value): string
{
// Match for most non-printable chars somewhat taking multibyte chars into account
if (preg_match('/[^\x09-\x0d\x1b\x20-\xff]/', $value) === 1) {
$unprintableCount = preg_match_all('/[^\x09-\x0d\x1b\x20-\xff]/', $value);
if ($unprintableCount === false || $unprintableCount === 0) {
return "'" .
strtr(
$value,
[
"\r\n" => '\r\n' . "\n",
"\r" => '\r' . "\n",
"\n" => '\n' . "\n",
],
) .
"'";
}
// A NUL byte or a high ratio of unprintable bytes signals truly
// binary data; keep the compact hex dump in those cases.
if (str_contains($value, "\x00") || ($unprintableCount / strlen($value)) > 0.3) {
return 'Binary String: 0x' . bin2hex($value);
}
return "'" .
strtr(
// Mostly printable: keep printable bytes visible and escape only
// the offending ones inline using PHP-style \xNN escapes.
return 'Binary String: "' .
preg_replace_callback(
'/[\x00-\x1f\x7f"\\\\]/',
static fn (array $m): string => match ($m[0]) {
"\t" => '\t',
"\n" => '\n',
"\r" => '\r',
'"' => '\"',
'\\' => '\\\\',
default => sprintf('\x%02x', ord($m[0])),
},
$value,
[
"\r\n" => '\r\n' . "\n",
"\n\r" => '\n\r' . "\n",
"\r" => '\r' . "\n",
"\n" => '\n' . "\n",
],
) .
"'";
'"';
}
/**
* @param array<mixed> $value
*
* @throws ObjectNotSupportedException
*/
private function exportArray(array &$value, RecursionContext $processed, int $indentation): string
private function exportArray(array &$value, ExportContext $context, int $indentation): string
{
if (($key = $processed->contains($value)) !== false) {
if (($key = $context->contains($value)) !== false) {
return 'Array &' . $key;
}
$array = $value;
$key = $processed->add($value);
$key = $context->add($value);
$values = '';
if (count($array) > 0) {
@ -469,7 +552,7 @@ final readonly class Exporter
$this->recursiveExport($k, $indentation)
. ' => ' .
/** @phpstan-ignore offsetAccess.invalidOffset */
$this->recursiveExport($value[$k], $indentation + 1, $processed)
$this->recursiveExport($value[$k], $indentation + 1, $context)
. ",\n";
}
@ -479,15 +562,68 @@ final readonly class Exporter
return 'Array &' . (string) $key . ' [' . $values . ']';
}
private function exportObject(object $value, RecursionContext $processed, int $indentation): string
/**
* @throws ObjectNotSupportedException
*/
private function exportObject(object $value, ExportContext $context, int $indentation): string
{
$class = $value::class;
if ($processed->contains($value) !== false) {
if ($this->objectExporter !== null) {
// An object that is (indirectly) nested in itself cannot be
// exported by a custom object exporter without recursing
// infinitely and is therefore replaced with a reference to the
// object.
if ($context->isBeingExportedByObjectExporter($value)) {
return $class . ' Object #' . spl_object_id($value);
}
// A custom object exporter is responsible for the entire
// representation of the object it handles. Therefore, it is asked
// for that representation before the recursion context is
// consulted: every occurrence of such an object is exported the
// same way instead of repeated occurrences being replaced with a
// reference to the object.
if ($this->objectExporter->handles($value)) {
$context->beginExportByObjectExporter($value);
try {
return $this->objectExporter->export($value, $this, $indentation, $context);
} finally {
$context->endExportByObjectExporter($value);
}
}
}
// Enums are handled after a custom object exporter has been consulted
// so that the representation of an enum can be customized, but before
// the recursion context is consulted because an enum case is a
// singleton for which a reference to a previous occurrence would be
// less informative than the representation itself.
if ($value instanceof BackedEnum) {
return sprintf(
'%s Enum #%d (%s, %s)',
$class,
spl_object_id($value),
$value->name,
$this->export($value->value),
);
}
if ($value instanceof UnitEnum) {
return sprintf(
'%s Enum #%d (%s)',
$class,
spl_object_id($value),
$value->name,
);
}
if ($context->contains($value) !== false) {
return $class . ' Object #' . spl_object_id($value);
}
$processed->add($value);
$context->add($value);
$array = $this->toArray($value);
$buffer = '';
@ -501,7 +637,7 @@ final readonly class Exporter
. ' ' .
$this->recursiveExport($k, $indentation)
. ' => ' .
$this->recursiveExport($v, $indentation + 1, $processed)
$this->recursiveExport($v, $indentation + 1, $context)
. ",\n";
}

25
src/ObjectExporter.php Normal file
View File

@ -0,0 +1,25 @@
<?php declare(strict_types=1);
/*
* This file is part of sebastian/exporter.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace SebastianBergmann\Exporter;
interface ObjectExporter
{
public function handles(object $object): bool;
/**
* Exports an object this object exporter handles.
*
* The ExportContext must be passed on to Exporter::export() when values
* that are nested in the object are exported using $exporter.
*
* @throws ObjectNotSupportedException
*/
public function export(object $object, Exporter $exporter, int $indentation, ExportContext $context): string;
}

View File

@ -0,0 +1,50 @@
<?php declare(strict_types=1);
/*
* This file is part of sebastian/exporter.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace SebastianBergmann\Exporter;
use function array_any;
final readonly class ObjectExporterChain implements ObjectExporter
{
/**
* @var non-empty-list<ObjectExporter>
*/
private array $exporters;
/**
* @param non-empty-list<ObjectExporter> $exporters
*/
public function __construct(array $exporters)
{
$this->exporters = $exporters;
}
public function handles(object $object): bool
{
return array_any(
$this->exporters,
static fn (ObjectExporter $exporter) => $exporter->handles($object),
);
}
/**
* @throws ObjectNotSupportedException
*/
public function export(object $object, Exporter $exporter, int $indentation, ExportContext $context): string
{
foreach ($this->exporters as $objectExporter) {
if ($objectExporter->handles($object)) {
return $objectExporter->export($object, $exporter, $indentation, $context);
}
}
throw new ObjectNotSupportedException;
}
}

View File

@ -0,0 +1,16 @@
<?php declare(strict_types=1);
/*
* This file is part of sebastian/exporter.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace SebastianBergmann\Exporter;
use Throwable;
interface Exception extends Throwable
{
}

View File

@ -0,0 +1,16 @@
<?php declare(strict_types=1);
/*
* This file is part of sebastian/exporter.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace SebastianBergmann\Exporter;
use RuntimeException;
final class ObjectNotSupportedException extends RuntimeException implements Exception
{
}

View File

@ -30,6 +30,7 @@ use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
use PHPUnit\Framework\Attributes\Small;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use SebastianBergmann\RecursionContext\Context;
@ -37,6 +38,8 @@ use SplObjectStorage;
use stdClass;
#[CoversClass(Exporter::class)]
#[UsesClass(ExportContext::class)]
#[UsesClass(ObjectExporterChain::class)]
#[Small]
final class ExporterTest extends TestCase
{
@ -145,7 +148,9 @@ Array &0 [
EOF,
2,
],
// \n\r and \r is converted to \n
// A real newline is inserted after each line ending so the
// escaped text wraps readably; \r\n stays one break while a lone
// \n followed by a lone \r are two separate line endings.
'export multiline text' => ["this\nis\na\nvery\nvery\nvery\nvery\nvery\nvery\rlong\n\rtext",
<<<'EOF'
'this\n
@ -157,8 +162,27 @@ very\n
very\n
very\n
very\r
long\n\r
long\n
\r
text'
EOF,
0,
],
// A lone \n (Unix) followed by a lone \r (old Mac) are two
// distinct line endings and must each produce their own break.
'lone newline followed by lone carriage return' => ["a\n\rb",
<<<'EOF'
'a\n
\r
b'
EOF,
0,
],
// \r\n (Windows) is a single line ending and stays one break.
'carriage return followed by newline' => ["a\r\nb",
<<<'EOF'
'a\r\n
b'
EOF,
0,
],
@ -180,7 +204,8 @@ very\n
very\n
very\n
very\r
long\n\r
long\n
\r
text',
'object' => stdClass Object #%d (
'foo' => 'bar',
@ -239,6 +264,41 @@ EOF,
'Binary String: 0x0009',
0,
],
'mostly printable string with a single control byte' => [
'Hello' . chr(0x01) . 'World',
'Binary String: "Hello\x01World"',
0,
],
'mostly printable string with several control bytes' => [
'BEGIN' . chr(0x01) . 'DATA' . chr(0x1F) . 'END',
'Binary String: "BEGIN\x01DATA\x1fEND"',
0,
],
'mostly printable string with embedded tab' => [
'Tab' . chr(0x09) . 'Sep' . chr(0x01),
'Binary String: "Tab\tSep\x01"',
0,
],
'mostly printable string with embedded newline' => [
'Line1' . chr(0x0A) . 'Line2' . chr(0x01),
'Binary String: "Line1\nLine2\x01"',
0,
],
'mostly printable string with embedded carriage return' => [
'A' . chr(0x0D) . 'B' . chr(0x01),
'Binary String: "A\rB\x01"',
0,
],
'mixed string with embedded double quote and backslash' => [
'a"b\\c' . chr(0x07) . 'd',
'Binary String: "a\"b\\\\c\x07d"',
0,
],
'mixed string preserves UTF-8 high bytes' => [
'café' . chr(0x01),
'Binary String: "café\x01"',
0,
],
[
'',
"''",
@ -433,7 +493,8 @@ very\n
very\n
very\n
very\r
long\n\r
long\n
\r
text',
'object' => stdClass Object #%d (
'foo' => 'bar',
@ -458,7 +519,8 @@ very\n
very\n
very\n
very\r
long\n\r
long\n
\r
text',
'object' => stdClass Object #%d,
'objectagain' => stdClass Object #%d,
@ -590,6 +652,428 @@ EOF;
$this->assertTrue($reflector->isUninitializedLazyObject($object));
}
public function testObjectCanBeExportedByCustomObjectExporter(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatHandlesEveryObject,
],
),
);
$this->assertSame(
'stdClass (indentation: 0)',
$exporter->export(new stdClass),
);
}
public function testObjectCanBeExportedByCustomObjectExporterThatIsNotComposedIntoChain(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterThatHandlesEveryObject,
);
$this->assertSame(
'stdClass (indentation: 0)',
$exporter->export(new stdClass),
);
}
public function testObjectNestedInArrayCanBeExportedByCustomObjectExporter(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatHandlesEveryObject,
],
),
);
$this->assertSame(
<<<'EOT'
Array &0 [
0 => stdClass (indentation: 1),
]
EOT,
$exporter->export([new stdClass]),
);
}
public function testObjectNestedInObjectCanBeExportedByCustomObjectExporter(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatHandlesObjectsOfSpecificType(ExampleClass::class),
],
),
);
$object = new stdClass;
$object->nested = new ExampleClass('bar');
$this->assertStringMatchesFormat(
<<<'EOT'
stdClass Object #%d (
'nested' => SebastianBergmann\Exporter\ExampleClass handled by custom exporter,
)
EOT,
$exporter->export($object),
);
}
public function testEveryOccurrenceOfObjectHandledByCustomObjectExporterIsExportedByThatObjectExporter(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatHandlesEveryObject,
],
),
);
$object = new stdClass;
$this->assertSame(
<<<'EOT'
Array &0 [
0 => stdClass (indentation: 1),
1 => stdClass (indentation: 1),
]
EOT,
$exporter->export([$object, $object]),
);
}
public function testRepeatedOccurrenceOfObjectNotHandledByCustomObjectExporterIsReplacedWithReferenceToObject(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatHandlesNoObject,
],
),
);
$object = new stdClass;
$this->assertStringMatchesFormat(
<<<'EOT'
Array &0 [
0 => stdClass Object #%d (),
1 => stdClass Object #%d,
]
EOT,
$exporter->export([$object, $object]),
);
}
public function testDefaultExportIsUsedWhenNoCustomObjectExporterHandlesObject(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatHandlesNoObject,
],
),
);
$this->assertStringMatchesFormat(
'stdClass Object #%d ()',
$exporter->export(new stdClass),
);
}
public function testEnumCanBeExportedByCustomObjectExporter(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatHandlesEveryObject,
],
),
);
$this->assertSame(
'SebastianBergmann\Exporter\ExampleEnum (indentation: 0)',
$exporter->export(ExampleEnum::Value),
);
$this->assertSame(
'SebastianBergmann\Exporter\ExampleStringBackedEnum (indentation: 0)',
$exporter->export(ExampleStringBackedEnum::Value),
);
}
public function testDefaultExportIsUsedForEnumWhenNoCustomObjectExporterHandlesIt(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatHandlesNoObject,
],
),
);
$this->assertStringMatchesFormat(
'SebastianBergmann\Exporter\ExampleEnum Enum #%d (Value)',
$exporter->export(ExampleEnum::Value),
);
$this->assertStringMatchesFormat(
'SebastianBergmann\Exporter\ExampleStringBackedEnum Enum #%d (Value, \'value\')',
$exporter->export(ExampleStringBackedEnum::Value),
);
}
public function testShortenedExportUsesRepresentationProvidedByCustomObjectExporter(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterThatHandlesEveryObject,
);
$this->assertSame(
'stdClass (indentation: 0)',
$exporter->shortenedExport(new stdClass),
);
$this->assertSame(
'SebastianBergmann\Exporter\ExampleEnum (indentation: 0)',
$exporter->shortenedExport(ExampleEnum::Value, 80),
);
}
public function testShortenedExportCollapsesRepresentationProvidedByCustomObjectExporterToSingleLine(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterThatReturnsGivenRepresentation("first\nsecond"),
);
$this->assertSame(
'firstsecond',
$exporter->shortenedExport(new stdClass),
);
}
public function testShortenedExportShortensRepresentationProvidedByCustomObjectExporter(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterThatReturnsGivenRepresentation(str_repeat('a', 41)),
);
$this->assertSame(
str_repeat('a', 30) . '...' . str_repeat('a', 7),
$exporter->shortenedExport(new stdClass),
);
}
public function testShortenedRecursiveExportUsesRepresentationProvidedByCustomObjectExporter(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterThatHandlesEveryObject,
);
$data = [new stdClass, 'foo'];
$this->assertSame(
"stdClass (indentation: 0), 'foo'",
$exporter->shortenedRecursiveExport($data),
);
}
public function testShortenedExportDoesNotRecurseInfinitelyForObjectNestedInItself(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterThatExportsNestedValues,
);
$node = new Node;
$node->children = [$node];
$this->assertStringMatchesFormat(
'Node(Array &0 [ 0 => SebastianBergmann\Exporter\Node Object #%d,])',
$exporter->shortenedExport($node, 200),
);
}
public function testShortenedExportUsesDefaultRepresentationWhenNoCustomObjectExporterHandlesObject(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterThatHandlesNoObject,
);
$this->assertSame(
'stdClass Object ()',
$exporter->shortenedExport(new stdClass),
);
}
public function testKnowsThatCustomObjectExporterProvidesRepresentationForObjectItHandles(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatHandlesObjectsOfSpecificType(ExampleClass::class),
],
),
);
$this->assertTrue($exporter->hasCustomRepresentationFor(new ExampleClass('bar')));
}
public function testKnowsThatCustomObjectExporterDoesNotProvideRepresentationForObjectItDoesNotHandle(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatHandlesObjectsOfSpecificType(ExampleClass::class),
],
),
);
$this->assertFalse($exporter->hasCustomRepresentationFor(new stdClass));
}
public function testKnowsThatNoCustomObjectExporterProvidesRepresentationForObjectWhenNoneIsConfigured(): void
{
$this->assertFalse((new Exporter)->hasCustomRepresentationFor(new stdClass));
}
public function testCustomObjectExporterCanExportValuesNestedInObjectItHandles(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatExportsNestedValues,
],
),
);
$this->assertSame(
<<<'EOT'
Node(Array &0 [
'key' => 'value',
])
EOT,
$exporter->export(new Node(['key' => 'value'])),
);
}
public function testArrayExportedByCustomObjectExporterDoesNotReuseReferenceToOtherArray(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatExportsNestedValues,
],
),
);
$this->assertSame(
<<<'EOT'
Array &0 [
0 => Node(Array &1 [
'key' => 'value',
]),
]
EOT,
$exporter->export([new Node(['key' => 'value'])]),
);
}
public function testObjectNestedInItselfIsNotExportedByCustomObjectExporterAgain(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatExportsNestedValues,
],
),
);
$node = new Node;
$node->children = [$node];
$this->assertStringMatchesFormat(
<<<'EOT'
Node(Array &0 [
0 => SebastianBergmann\Exporter\Node Object #%d,
])
EOT,
$exporter->export($node),
);
}
public function testObjectsNestedInEachOtherAreNotExportedByCustomObjectExporterAgain(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatExportsNestedValues,
],
),
);
$first = new Node;
$second = new Node;
$first->children = [$second];
$second->children = [$first];
$this->assertStringMatchesFormat(
<<<'EOT'
Node(Array &0 [
0 => Node(Array &1 [
0 => SebastianBergmann\Exporter\Node Object #%d,
]),
])
EOT,
$exporter->export($first),
);
}
private function trimNewline(string $string): string
{
return (string) preg_replace('/[ ]*\n/', "\n", $string);

View File

@ -0,0 +1,80 @@
<?php declare(strict_types=1);
/*
* This file is part of sebastian/exporter.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace SebastianBergmann\Exporter;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Small;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
use stdClass;
#[CoversClass(ObjectExporterChain::class)]
#[UsesClass(Exporter::class)]
#[UsesClass(ExportContext::class)]
#[Small]
final class ObjectExporterChainTest extends TestCase
{
public function testHandlesObjectWhenAtLeastOneComposedObjectExporterHandlesIt(): void
{
$chain = new ObjectExporterChain(
[
new ObjectExporterThatHandlesNoObject,
new ObjectExporterThatHandlesEveryObject,
],
);
$this->assertTrue($chain->handles(new stdClass));
}
public function testDoesNotHandleObjectWhenNoComposedObjectExporterHandlesIt(): void
{
$chain = new ObjectExporterChain(
[
new ObjectExporterThatHandlesNoObject,
],
);
$this->assertFalse($chain->handles(new stdClass));
}
public function testDelegatesExportingToFirstComposedObjectExporterThatHandlesObject(): void
{
$chain = new ObjectExporterChain(
[
new ObjectExporterThatHandlesNoObject,
new ObjectExporterThatHandlesObjectsOfSpecificType(stdClass::class),
new ObjectExporterThatHandlesEveryObject,
],
);
$this->assertSame(
'stdClass handled by custom exporter',
$chain->export(new stdClass, new Exporter, 0, new ExportContext),
);
$this->assertSame(
ExampleClass::class . ' (indentation: 0)',
$chain->export(new ExampleClass('foo'), new Exporter, 0, new ExportContext),
);
}
public function testCannotExportObjectWhenNoComposedObjectExporterHandlesIt(): void
{
$chain = new ObjectExporterChain(
[
new ObjectExporterThatHandlesNoObject,
],
);
$this->expectException(ObjectNotSupportedException::class);
$chain->export(new stdClass, new Exporter, 0, new ExportContext);
}
}

29
tests/_fixture/Node.php Normal file
View File

@ -0,0 +1,29 @@
<?php declare(strict_types=1);
/*
* This file is part of sebastian/exporter.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace SebastianBergmann\Exporter;
/*
* Helper to test export of objects that are nested in objects.
*/
final class Node
{
/**
* @var array<mixed>
*/
public array $children;
/**
* @param array<mixed> $children
*/
public function __construct(array $children = [])
{
$this->children = $children;
}
}

View File

@ -0,0 +1,31 @@
<?php declare(strict_types=1);
/*
* This file is part of sebastian/exporter.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace SebastianBergmann\Exporter;
use function assert;
use function sprintf;
final readonly class ObjectExporterThatExportsNestedValues implements ObjectExporter
{
public function handles(object $object): bool
{
return $object instanceof Node;
}
public function export(object $object, Exporter $exporter, int $indentation, ExportContext $context): string
{
assert($object instanceof Node);
return sprintf(
'Node(%s)',
$exporter->export($object->children, $indentation, $context),
);
}
}

View File

@ -0,0 +1,29 @@
<?php declare(strict_types=1);
/*
* This file is part of sebastian/exporter.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace SebastianBergmann\Exporter;
use function sprintf;
final readonly class ObjectExporterThatHandlesEveryObject implements ObjectExporter
{
public function handles(object $object): bool
{
return true;
}
public function export(object $object, Exporter $exporter, int $indentation, ExportContext $context): string
{
return sprintf(
'%s (indentation: %d)',
$object::class,
$indentation,
);
}
}

View File

@ -0,0 +1,23 @@
<?php declare(strict_types=1);
/*
* This file is part of sebastian/exporter.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace SebastianBergmann\Exporter;
final readonly class ObjectExporterThatHandlesNoObject implements ObjectExporter
{
public function handles(object $object): bool
{
return false;
}
public function export(object $object, Exporter $exporter, int $indentation, ExportContext $context): string
{
throw new ObjectNotSupportedException;
}
}

View File

@ -0,0 +1,41 @@
<?php declare(strict_types=1);
/*
* This file is part of sebastian/exporter.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace SebastianBergmann\Exporter;
use function sprintf;
final readonly class ObjectExporterThatHandlesObjectsOfSpecificType implements ObjectExporter
{
/**
* @var class-string
*/
private string $type;
/**
* @param class-string $type
*/
public function __construct(string $type)
{
$this->type = $type;
}
public function handles(object $object): bool
{
return $object instanceof $this->type;
}
public function export(object $object, Exporter $exporter, int $indentation, ExportContext $context): string
{
return sprintf(
'%s handled by custom exporter',
$object::class,
);
}
}

View File

@ -0,0 +1,30 @@
<?php declare(strict_types=1);
/*
* This file is part of sebastian/exporter.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace SebastianBergmann\Exporter;
final readonly class ObjectExporterThatReturnsGivenRepresentation implements ObjectExporter
{
private string $representation;
public function __construct(string $representation)
{
$this->representation = $representation;
}
public function handles(object $object): bool
{
return true;
}
public function export(object $object, Exporter $exporter, int $indentation, ExportContext $context): string
{
return $this->representation;
}
}

View File

@ -1,9 +1,9 @@
{
"require-dev": {
"phpstan/phpstan": "^2.1.55",
"phpstan/phpstan": "^2.2.7",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan-strict-rules": "^2.0.11",
"tomasvotruba/type-coverage": "^2.1.0",
"phpstan/phpstan-strict-rules": "^2.0.12",
"tomasvotruba/type-coverage": "^2.3.0",
"ergebnis/phpstan-rules": "^2.13.1"
},
"config": {

View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "4a2ebaa20a53e09eb1ad32a1d01b318d",
"content-hash": "3579343022b2a28c7195fe30b1752bfb",
"packages": [],
"packages-dev": [
{
@ -80,97 +80,6 @@
},
"time": "2026-01-27T17:13:06+00:00"
},
{
"name": "nette/utils",
"version": "v4.1.4",
"source": {
"type": "git",
"url": "https://github.com/nette/utils.git",
"reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
"reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
"shasum": ""
},
"require": {
"php": "8.2 - 8.5"
},
"conflict": {
"nette/finder": "<3",
"nette/schema": "<1.2.2"
},
"require-dev": {
"jetbrains/phpstorm-attributes": "^1.2",
"nette/phpstan-rules": "^1.0",
"nette/tester": "^2.5",
"phpstan/extension-installer": "^1.4@stable",
"phpstan/phpstan": "^2.1@stable",
"tracy/tracy": "^2.9"
},
"suggest": {
"ext-gd": "to use Image",
"ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()",
"ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()",
"ext-json": "to use Nette\\Utils\\Json",
"ext-mbstring": "to use Strings::lower() etc...",
"ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "4.1-dev"
}
},
"autoload": {
"psr-4": {
"Nette\\": "src"
},
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause",
"GPL-2.0-only",
"GPL-3.0-only"
],
"authors": [
{
"name": "David Grudl",
"homepage": "https://davidgrudl.com"
},
{
"name": "Nette Community",
"homepage": "https://nette.org/contributors"
}
],
"description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.",
"homepage": "https://nette.org",
"keywords": [
"array",
"core",
"datetime",
"images",
"json",
"nette",
"paginator",
"password",
"slugify",
"string",
"unicode",
"utf-8",
"utility",
"validation"
],
"support": {
"issues": "https://github.com/nette/utils/issues",
"source": "https://github.com/nette/utils/tree/v4.1.4"
},
"time": "2026-05-11T20:49:54+00:00"
},
{
"name": "phpstan/extension-installer",
"version": "1.4.3",
@ -221,11 +130,11 @@
},
{
"name": "phpstan/phpstan",
"version": "2.1.55",
"version": "2.2.7",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/9eaac3826ed5e9b8427350a43cac825eeca3f566",
"reference": "9eaac3826ed5e9b8427350a43cac825eeca3f566",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/692db47b9dddb0487934e5236e77d48594aef921",
"reference": "692db47b9dddb0487934e5236e77d48594aef921",
"shasum": ""
},
"require": {
@ -248,6 +157,17 @@
"license": [
"MIT"
],
"authors": [
{
"name": "Ondřej Mirtes"
},
{
"name": "Markus Staab"
},
{
"name": "Vincent Langlet"
}
],
"description": "PHPStan - PHP Static Analysis Tool",
"keywords": [
"dev",
@ -270,31 +190,32 @@
"type": "github"
}
],
"time": "2026-05-18T11:57:34+00:00"
"time": "2026-07-29T17:39:32+00:00"
},
{
"name": "phpstan/phpstan-strict-rules",
"version": "2.0.11",
"version": "2.0.12",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-strict-rules.git",
"reference": "9b000a578b85b32945b358b172c7b20e91189024"
"reference": "2bc5ae19ae965663b62ac907ee6342c3903ec93b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/9b000a578b85b32945b358b172c7b20e91189024",
"reference": "9b000a578b85b32945b358b172c7b20e91189024",
"url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/2bc5ae19ae965663b62ac907ee6342c3903ec93b",
"reference": "2bc5ae19ae965663b62ac907ee6342c3903ec93b",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0",
"phpstan/phpstan": "^2.1.39"
"phpstan/phpstan": "^2.1.52"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^9.6"
"phpunit/phpunit": "^9.6",
"shipmonk/name-collision-detector": "^2.1"
},
"type": "phpstan-extension",
"extra": {
@ -319,39 +240,52 @@
],
"support": {
"issues": "https://github.com/phpstan/phpstan-strict-rules/issues",
"source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.11"
"source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.12"
},
"time": "2026-05-02T06:54:10+00:00"
"time": "2026-07-19T07:24:06+00:00"
},
{
"name": "tomasvotruba/type-coverage",
"version": "2.1.0",
"version": "2.3.0",
"source": {
"type": "git",
"url": "https://github.com/TomasVotruba/type-coverage.git",
"reference": "468354b3964120806a69890cbeb3fcf005876391"
"reference": "7ba48c1bf3bbb7ffe99caacc7017bae5ee72d644"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/TomasVotruba/type-coverage/zipball/468354b3964120806a69890cbeb3fcf005876391",
"reference": "468354b3964120806a69890cbeb3fcf005876391",
"url": "https://api.github.com/repos/TomasVotruba/type-coverage/zipball/7ba48c1bf3bbb7ffe99caacc7017bae5ee72d644",
"reference": "7ba48c1bf3bbb7ffe99caacc7017bae5ee72d644",
"shasum": ""
},
"require": {
"nette/utils": "^3.2 || ^4.0",
"php": "^7.4 || ^8.0",
"phpstan/phpstan": "^2.0"
"php": "^8.4",
"phpstan/phpstan": "^2.2",
"webmozart/assert": "^1.11 || ^2.1"
},
"require-dev": {
"phpstan/extension-installer": "^1.4",
"phpunit/phpunit": "^13.2",
"rector/jack": "^1.0",
"rector/rector": "^2.5",
"shipmonk/composer-dependency-analyser": "^1.8",
"symfony/dom-crawler": "^8.1",
"symplify/easy-coding-standard": "^13.2",
"tomasvotruba/unused-public": "^2.2",
"tracy/tracy": "^2.12"
},
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"config/extension.neon"
"config/extension.neon",
"packages/type-perfect/config/extension.neon"
]
}
},
"autoload": {
"psr-4": {
"Rector\\TypePerfect\\": "packages/type-perfect/src",
"TomasVotruba\\TypeCoverage\\": "src"
}
},
@ -366,7 +300,7 @@
],
"support": {
"issues": "https://github.com/TomasVotruba/type-coverage/issues",
"source": "https://github.com/TomasVotruba/type-coverage/tree/2.1.0"
"source": "https://github.com/TomasVotruba/type-coverage/tree/2.3.0"
},
"funding": [
{
@ -378,7 +312,73 @@
"type": "github"
}
],
"time": "2025-12-05T16:38:02+00:00"
"time": "2026-07-29T20:32:37+00:00"
},
{
"name": "webmozart/assert",
"version": "2.4.1",
"source": {
"type": "git",
"url": "https://github.com/webmozarts/assert.git",
"reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70",
"reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-date": "*",
"ext-filter": "*",
"php": "^8.2"
},
"suggest": {
"ext-intl": "",
"ext-simplexml": "",
"ext-spl": ""
},
"type": "library",
"extra": {
"psalm": {
"pluginClass": "Webmozart\\Assert\\PsalmPlugin"
},
"branch-alias": {
"dev-master": "2.0-dev",
"dev-feature/2-0": "2.0-dev"
}
},
"autoload": {
"psr-4": {
"Webmozart\\Assert\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Bernhard Schussek",
"email": "bschussek@gmail.com"
},
{
"name": "Woody Gilk",
"email": "woody.gilk@gmail.com"
}
],
"description": "Assertions to validate method input/output with nice error messages.",
"keywords": [
"assert",
"check",
"validate"
],
"support": {
"issues": "https://github.com/webmozarts/assert/issues",
"source": "https://github.com/webmozarts/assert/tree/2.4.1"
},
"time": "2026-06-15T15:31:57+00:00"
}
],
"aliases": [],

View File

@ -76,100 +76,6 @@
},
"install-path": "../ergebnis/phpstan-rules"
},
{
"name": "nette/utils",
"version": "v4.1.4",
"version_normalized": "4.1.4.0",
"source": {
"type": "git",
"url": "https://github.com/nette/utils.git",
"reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
"reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
"shasum": ""
},
"require": {
"php": "8.2 - 8.5"
},
"conflict": {
"nette/finder": "<3",
"nette/schema": "<1.2.2"
},
"require-dev": {
"jetbrains/phpstorm-attributes": "^1.2",
"nette/phpstan-rules": "^1.0",
"nette/tester": "^2.5",
"phpstan/extension-installer": "^1.4@stable",
"phpstan/phpstan": "^2.1@stable",
"tracy/tracy": "^2.9"
},
"suggest": {
"ext-gd": "to use Image",
"ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()",
"ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()",
"ext-json": "to use Nette\\Utils\\Json",
"ext-mbstring": "to use Strings::lower() etc...",
"ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()"
},
"time": "2026-05-11T20:49:54+00:00",
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "4.1-dev"
}
},
"installation-source": "dist",
"autoload": {
"psr-4": {
"Nette\\": "src"
},
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause",
"GPL-2.0-only",
"GPL-3.0-only"
],
"authors": [
{
"name": "David Grudl",
"homepage": "https://davidgrudl.com"
},
{
"name": "Nette Community",
"homepage": "https://nette.org/contributors"
}
],
"description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.",
"homepage": "https://nette.org",
"keywords": [
"array",
"core",
"datetime",
"images",
"json",
"nette",
"paginator",
"password",
"slugify",
"string",
"unicode",
"utf-8",
"utility",
"validation"
],
"support": {
"issues": "https://github.com/nette/utils/issues",
"source": "https://github.com/nette/utils/tree/v4.1.4"
},
"install-path": "../nette/utils"
},
{
"name": "phpstan/extension-installer",
"version": "1.4.3",
@ -223,12 +129,12 @@
},
{
"name": "phpstan/phpstan",
"version": "2.1.55",
"version_normalized": "2.1.55.0",
"version": "2.2.7",
"version_normalized": "2.2.7.0",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/9eaac3826ed5e9b8427350a43cac825eeca3f566",
"reference": "9eaac3826ed5e9b8427350a43cac825eeca3f566",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/692db47b9dddb0487934e5236e77d48594aef921",
"reference": "692db47b9dddb0487934e5236e77d48594aef921",
"shasum": ""
},
"require": {
@ -237,7 +143,7 @@
"conflict": {
"phpstan/phpstan-shim": "*"
},
"time": "2026-05-18T11:57:34+00:00",
"time": "2026-07-29T17:39:32+00:00",
"bin": [
"phpstan",
"phpstan.phar"
@ -253,6 +159,17 @@
"license": [
"MIT"
],
"authors": [
{
"name": "Ondřej Mirtes"
},
{
"name": "Markus Staab"
},
{
"name": "Vincent Langlet"
}
],
"description": "PHPStan - PHP Static Analysis Tool",
"keywords": [
"dev",
@ -279,30 +196,31 @@
},
{
"name": "phpstan/phpstan-strict-rules",
"version": "2.0.11",
"version_normalized": "2.0.11.0",
"version": "2.0.12",
"version_normalized": "2.0.12.0",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-strict-rules.git",
"reference": "9b000a578b85b32945b358b172c7b20e91189024"
"reference": "2bc5ae19ae965663b62ac907ee6342c3903ec93b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/9b000a578b85b32945b358b172c7b20e91189024",
"reference": "9b000a578b85b32945b358b172c7b20e91189024",
"url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/2bc5ae19ae965663b62ac907ee6342c3903ec93b",
"reference": "2bc5ae19ae965663b62ac907ee6342c3903ec93b",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0",
"phpstan/phpstan": "^2.1.39"
"phpstan/phpstan": "^2.1.52"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^9.6"
"phpunit/phpunit": "^9.6",
"shipmonk/name-collision-detector": "^2.1"
},
"time": "2026-05-02T06:54:10+00:00",
"time": "2026-07-19T07:24:06+00:00",
"type": "phpstan-extension",
"extra": {
"phpstan": {
@ -327,42 +245,55 @@
],
"support": {
"issues": "https://github.com/phpstan/phpstan-strict-rules/issues",
"source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.11"
"source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.12"
},
"install-path": "../phpstan/phpstan-strict-rules"
},
{
"name": "tomasvotruba/type-coverage",
"version": "2.1.0",
"version_normalized": "2.1.0.0",
"version": "2.3.0",
"version_normalized": "2.3.0.0",
"source": {
"type": "git",
"url": "https://github.com/TomasVotruba/type-coverage.git",
"reference": "468354b3964120806a69890cbeb3fcf005876391"
"reference": "7ba48c1bf3bbb7ffe99caacc7017bae5ee72d644"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/TomasVotruba/type-coverage/zipball/468354b3964120806a69890cbeb3fcf005876391",
"reference": "468354b3964120806a69890cbeb3fcf005876391",
"url": "https://api.github.com/repos/TomasVotruba/type-coverage/zipball/7ba48c1bf3bbb7ffe99caacc7017bae5ee72d644",
"reference": "7ba48c1bf3bbb7ffe99caacc7017bae5ee72d644",
"shasum": ""
},
"require": {
"nette/utils": "^3.2 || ^4.0",
"php": "^7.4 || ^8.0",
"phpstan/phpstan": "^2.0"
"php": "^8.4",
"phpstan/phpstan": "^2.2",
"webmozart/assert": "^1.11 || ^2.1"
},
"time": "2025-12-05T16:38:02+00:00",
"require-dev": {
"phpstan/extension-installer": "^1.4",
"phpunit/phpunit": "^13.2",
"rector/jack": "^1.0",
"rector/rector": "^2.5",
"shipmonk/composer-dependency-analyser": "^1.8",
"symfony/dom-crawler": "^8.1",
"symplify/easy-coding-standard": "^13.2",
"tomasvotruba/unused-public": "^2.2",
"tracy/tracy": "^2.12"
},
"time": "2026-07-29T20:32:37+00:00",
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"config/extension.neon"
"config/extension.neon",
"packages/type-perfect/config/extension.neon"
]
}
},
"installation-source": "dist",
"autoload": {
"psr-4": {
"Rector\\TypePerfect\\": "packages/type-perfect/src",
"TomasVotruba\\TypeCoverage\\": "src"
}
},
@ -377,7 +308,7 @@
],
"support": {
"issues": "https://github.com/TomasVotruba/type-coverage/issues",
"source": "https://github.com/TomasVotruba/type-coverage/tree/2.1.0"
"source": "https://github.com/TomasVotruba/type-coverage/tree/2.3.0"
},
"funding": [
{
@ -390,15 +321,84 @@
}
],
"install-path": "../tomasvotruba/type-coverage"
},
{
"name": "webmozart/assert",
"version": "2.4.1",
"version_normalized": "2.4.1.0",
"source": {
"type": "git",
"url": "https://github.com/webmozarts/assert.git",
"reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70",
"reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-date": "*",
"ext-filter": "*",
"php": "^8.2"
},
"suggest": {
"ext-intl": "",
"ext-simplexml": "",
"ext-spl": ""
},
"time": "2026-06-15T15:31:57+00:00",
"type": "library",
"extra": {
"psalm": {
"pluginClass": "Webmozart\\Assert\\PsalmPlugin"
},
"branch-alias": {
"dev-master": "2.0-dev",
"dev-feature/2-0": "2.0-dev"
}
},
"installation-source": "dist",
"autoload": {
"psr-4": {
"Webmozart\\Assert\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Bernhard Schussek",
"email": "bschussek@gmail.com"
},
{
"name": "Woody Gilk",
"email": "woody.gilk@gmail.com"
}
],
"description": "Assertions to validate method input/output with nice error messages.",
"keywords": [
"assert",
"check",
"validate"
],
"support": {
"issues": "https://github.com/webmozarts/assert/issues",
"source": "https://github.com/webmozarts/assert/tree/2.4.1"
},
"install-path": "../webmozart/assert"
}
],
"dev": true,
"dev-package-names": [
"ergebnis/phpstan-rules",
"nette/utils",
"phpstan/extension-installer",
"phpstan/phpstan",
"phpstan/phpstan-strict-rules",
"tomasvotruba/type-coverage"
"tomasvotruba/type-coverage",
"webmozart/assert"
]
}

View File

@ -3,7 +3,7 @@
'name' => '__root__',
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => '249d162f1ad432728c616bf1d50827ab15780b9c',
'reference' => '4f08958e05d5bfc330b2f0b05252a7b7ddbe0558',
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@ -13,7 +13,7 @@
'__root__' => array(
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => '249d162f1ad432728c616bf1d50827ab15780b9c',
'reference' => '4f08958e05d5bfc330b2f0b05252a7b7ddbe0558',
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@ -28,15 +28,6 @@
'aliases' => array(),
'dev_requirement' => true,
),
'nette/utils' => array(
'pretty_version' => 'v4.1.4',
'version' => '4.1.4.0',
'reference' => '7da6c396d7ebe142bc857c20479d5e70a5e1aac7',
'type' => 'library',
'install_path' => __DIR__ . '/../nette/utils',
'aliases' => array(),
'dev_requirement' => true,
),
'phpstan/extension-installer' => array(
'pretty_version' => '1.4.3',
'version' => '1.4.3.0',
@ -47,31 +38,40 @@
'dev_requirement' => true,
),
'phpstan/phpstan' => array(
'pretty_version' => '2.1.55',
'version' => '2.1.55.0',
'reference' => '9eaac3826ed5e9b8427350a43cac825eeca3f566',
'pretty_version' => '2.2.7',
'version' => '2.2.7.0',
'reference' => '692db47b9dddb0487934e5236e77d48594aef921',
'type' => 'library',
'install_path' => __DIR__ . '/../phpstan/phpstan',
'aliases' => array(),
'dev_requirement' => true,
),
'phpstan/phpstan-strict-rules' => array(
'pretty_version' => '2.0.11',
'version' => '2.0.11.0',
'reference' => '9b000a578b85b32945b358b172c7b20e91189024',
'pretty_version' => '2.0.12',
'version' => '2.0.12.0',
'reference' => '2bc5ae19ae965663b62ac907ee6342c3903ec93b',
'type' => 'phpstan-extension',
'install_path' => __DIR__ . '/../phpstan/phpstan-strict-rules',
'aliases' => array(),
'dev_requirement' => true,
),
'tomasvotruba/type-coverage' => array(
'pretty_version' => '2.1.0',
'version' => '2.1.0.0',
'reference' => '468354b3964120806a69890cbeb3fcf005876391',
'pretty_version' => '2.3.0',
'version' => '2.3.0.0',
'reference' => '7ba48c1bf3bbb7ffe99caacc7017bae5ee72d644',
'type' => 'phpstan-extension',
'install_path' => __DIR__ . '/../tomasvotruba/type-coverage',
'aliases' => array(),
'dev_requirement' => true,
),
'webmozart/assert' => array(
'pretty_version' => '2.4.1',
'version' => '2.4.1.0',
'reference' => '2ccb7c2e821038c03a3e6e1700c570c158c55f70',
'type' => 'library',
'install_path' => __DIR__ . '/../webmozart/assert',
'aliases' => array(),
'dev_requirement' => true,
),
),
);

View File

@ -1,11 +0,0 @@
<?php declare(strict_types=1);
namespace PHPSTORM_META;
override(\Nette\Utils\Arrays::get(0), elementType(0));
override(\Nette\Utils\Arrays::getRef(0), elementType(0));
override(\Nette\Utils\Arrays::grep(0), type(0));
override(\Nette\Utils\Arrays::toObject(0), type(1));
expectedArguments(\Nette\Utils\Image::resize(), 2, \Nette\Utils\Image::ShrinkOnly, \Nette\Utils\Image::Stretch, \Nette\Utils\Image::OrSmaller, \Nette\Utils\Image::OrBigger, \Nette\Utils\Image::Cover);
expectedArguments(\Nette\Utils\Image::calculateSize(), 4, \Nette\Utils\Image::ShrinkOnly, \Nette\Utils\Image::Stretch, \Nette\Utils\Image::OrSmaller, \Nette\Utils\Image::OrBigger, \Nette\Utils\Image::Cover);

View File

@ -1,61 +0,0 @@
{
"name": "nette/utils",
"description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.",
"keywords": ["nette", "images", "json", "password", "validation", "utility", "string", "array", "core", "slugify", "utf-8", "unicode", "paginator", "datetime"],
"homepage": "https://nette.org",
"license": ["BSD-3-Clause", "GPL-2.0-only", "GPL-3.0-only"],
"authors": [
{
"name": "David Grudl",
"homepage": "https://davidgrudl.com"
},
{
"name": "Nette Community",
"homepage": "https://nette.org/contributors"
}
],
"require": {
"php": "8.2 - 8.5"
},
"require-dev": {
"nette/tester": "^2.5",
"tracy/tracy": "^2.9",
"phpstan/phpstan": "^2.1@stable",
"phpstan/extension-installer": "^1.4@stable",
"nette/phpstan-rules": "^1.0",
"jetbrains/phpstorm-attributes": "^1.2"
},
"conflict": {
"nette/finder": "<3",
"nette/schema": "<1.2.2"
},
"suggest": {
"ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()",
"ext-json": "to use Nette\\Utils\\Json",
"ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()",
"ext-mbstring": "to use Strings::lower() etc...",
"ext-gd": "to use Image",
"ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()"
},
"autoload": {
"classmap": ["src/"],
"psr-4": {
"Nette\\": "src"
}
},
"minimum-stability": "dev",
"scripts": {
"phpstan": "phpstan analyse",
"tester": "tester tests -s"
},
"extra": {
"branch-alias": {
"dev-master": "4.1-dev"
}
},
"config": {
"allow-plugins": {
"phpstan/extension-installer": true
}
}
}

View File

@ -1,60 +0,0 @@
Licenses
========
Good news! You may use Nette Framework under the terms of either
the New BSD License or the GNU General Public License (GPL) version 2 or 3.
The BSD License is recommended for most projects. It is easy to understand and it
places almost no restrictions on what you can do with the framework. If the GPL
fits better to your project, you can use the framework under this license.
You don't have to notify anyone which license you are using. You can freely
use Nette Framework in commercial projects as long as the copyright header
remains intact.
Please be advised that the name "Nette Framework" is a protected trademark and its
usage has some limitations. So please do not use word "Nette" in the name of your
project or top-level domain, and choose a name that stands on its own merits.
If your stuff is good, it will not take long to establish a reputation for yourselves.
New BSD License
---------------
Copyright (c) 2004, 2014 David Grudl (https://davidgrudl.com)
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of "Nette Framework" nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
This software is provided by the copyright holders and contributors "as is" and
any express or implied warranties, including, but not limited to, the implied
warranties of merchantability and fitness for a particular purpose are
disclaimed. In no event shall the copyright owner or contributors be liable for
any direct, indirect, incidental, special, exemplary, or consequential damages
(including, but not limited to, procurement of substitute goods or services;
loss of use, data, or profits; or business interruption) however caused and on
any theory of liability, whether in contract, strict liability, or tort
(including negligence or otherwise) arising in any way out of the use of this
software, even if advised of the possibility of such damage.
GNU General Public License
--------------------------
GPL licenses are very very long, so instead of including them here we offer
you URLs with full text:
- [GPL version 2](http://www.gnu.org/licenses/gpl-2.0.html)
- [GPL version 3](http://www.gnu.org/licenses/gpl-3.0.html)

View File

@ -1,55 +0,0 @@
[![Nette Utils](https://github.com/nette/utils/assets/194960/c33fdb74-0652-4cad-ac6e-c1ce0d29e32a)](https://doc.nette.org/en/utils)
[![Downloads this Month](https://img.shields.io/packagist/dm/nette/utils.svg)](https://packagist.org/packages/nette/utils)
[![Tests](https://github.com/nette/utils/workflows/Tests/badge.svg?branch=master)](https://github.com/nette/utils/actions)
[![Coverage Status](https://coveralls.io/repos/github/nette/utils/badge.svg?branch=master)](https://coveralls.io/github/nette/utils?branch=master)
[![Latest Stable Version](https://poser.pugx.org/nette/utils/v/stable)](https://github.com/nette/utils/releases)
[![License](https://img.shields.io/badge/license-New%20BSD-blue.svg)](https://github.com/nette/utils/blob/master/license.md)
Introduction
------------
In package nette/utils you will find a set of useful classes for everyday use:
✅ [Arrays](https://doc.nette.org/utils/arrays)<br>
✅ [Callback](https://doc.nette.org/utils/callback) - PHP callbacks<br>
✅ [Filesystem](https://doc.nette.org/utils/filesystem) - copying, renaming, …<br>
✅ [Finder](https://doc.nette.org/utils/finder) - finds files and directories<br>
✅ [Floats](https://doc.nette.org/utils/floats) - floating point numbers<br>
✅ [Helper Functions](https://doc.nette.org/utils/helpers)<br>
✅ [HTML elements](https://doc.nette.org/utils/html-elements) - generate HTML<br>
✅ [Images](https://doc.nette.org/utils/images) - crop, resize, rotate images<br>
✅ [Iterables](https://doc.nette.org/utils/iterables) <br>
✅ [JSON](https://doc.nette.org/utils/json) - encoding and decoding<br>
✅ [Generating Random Strings](https://doc.nette.org/utils/random)<br>
✅ [Paginator](https://doc.nette.org/utils/paginator) - pagination math<br>
✅ [PHP Reflection](https://doc.nette.org/utils/reflection)<br>
✅ [Strings](https://doc.nette.org/utils/strings) - useful text functions<br>
✅ [SmartObject](https://doc.nette.org/utils/smartobject) - PHP object enhancements<br>
✅ [Type](https://doc.nette.org/utils/type) - PHP data type<br>
✅ [Validation](https://doc.nette.org/utils/validators) - validate inputs<br>
 <!---->
Installation
------------
The recommended way to install is via Composer:
```
composer require nette/utils
```
Nette Utils 4.1 is compatible with PHP 8.2 to 8.5.
 <!---->
[Support Me](https://github.com/sponsors/dg)
--------------------------------------------
Do you like Nette Utils? Are you looking forward to the new features?
[![Buy me a coffee](https://files.nette.org/icons/donation-3.svg)](https://github.com/sponsors/dg)
Thank you!

View File

@ -1,23 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette;
/**
* Represents object convertible to HTML string.
*/
interface HtmlStringable
{
/**
* Returns string in HTML format.
*/
function __toString(): string;
}
interface_exists(Utils\IHtmlString::class);

View File

@ -1,133 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Iterators;
use Nette;
/**
* Enhanced caching iterator with first/last/counter tracking.
*
* @template TKey
* @template TValue
* @extends \CachingIterator<TKey, TValue, \Iterator<TKey, TValue>>
* @property-read bool $first
* @property-read bool $last
* @property-read bool $empty
* @property-read bool $odd
* @property-read bool $even
* @property-read int $counter
* @property-read TKey $nextKey
* @property-read TValue $nextValue
*/
class CachingIterator extends \CachingIterator implements \Countable
{
use Nette\SmartObject;
private int $counter = 0;
/** @param iterable<TKey, TValue>|\stdClass $iterable */
public function __construct(iterable|\stdClass $iterable)
{
$iterable = $iterable instanceof \stdClass
? new \ArrayIterator((array) $iterable)
: Nette\Utils\Iterables::toIterator($iterable);
parent::__construct($iterable, 0);
}
/**
* Is the current element the first one?
*/
public function isFirst(?int $gridWidth = null): bool
{
return $this->counter === 1 || ($gridWidth && $this->counter !== 0 && (($this->counter - 1) % $gridWidth) === 0);
}
/**
* Is the current element the last one?
*/
public function isLast(?int $gridWidth = null): bool
{
return !$this->hasNext() || ($gridWidth && ($this->counter % $gridWidth) === 0);
}
public function isEmpty(): bool
{
return $this->counter === 0;
}
public function isOdd(): bool
{
return $this->counter % 2 === 1;
}
public function isEven(): bool
{
return $this->counter % 2 === 0;
}
public function getCounter(): int
{
return $this->counter;
}
public function count(): int
{
$inner = $this->getInnerIterator();
if ($inner instanceof \Countable) {
return $inner->count();
} else {
throw new Nette\NotSupportedException('Iterator is not countable.');
}
}
/**
* Forwards to the next element.
*/
public function next(): void
{
parent::next();
if (parent::valid()) {
$this->counter++;
}
}
/**
* Rewinds the Iterator.
*/
public function rewind(): void
{
parent::rewind();
$this->counter = parent::valid() ? 1 : 0;
}
/** @return TKey */
public function getNextKey(): mixed
{
return $this->getInnerIterator()->key();
}
/** @return TValue */
public function getNextValue(): mixed
{
return $this->getInnerIterator()->current();
}
}

View File

@ -1,30 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Iterators;
/**
* @deprecated use Nette\Utils\Iterables::map()
*/
class Mapper extends \IteratorIterator
{
private \Closure $callback;
public function __construct(\Traversable $iterator, callable $callback)
{
parent::__construct($iterator);
$this->callback = $callback(...);
}
public function current(): mixed
{
return ($this->callback)(parent::current(), parent::key());
}
}

View File

@ -1,141 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette;
use Nette\Utils\ObjectHelpers;
/**
* Strict class for better experience.
* - 'did you mean' hints
* - access to undeclared members throws exceptions
* - support for @property annotations
* - support for calling event handlers stored in $onEvent via onEvent()
*/
trait SmartObject
{
/**
* @param mixed[] $args
* @return mixed
* @throws MemberAccessException
*/
public function __call(string $name, array $args)
{
$class = static::class;
if (ObjectHelpers::hasProperty($class, $name) === 'event') { // calling event handlers
$handlers = $this->$name ?? null;
if (is_iterable($handlers)) {
foreach ($handlers as $handler) {
$handler(...$args);
}
} elseif ($handlers !== null) {
throw new UnexpectedValueException("Property $class::$$name must be iterable or null, " . get_debug_type($handlers) . ' given.');
}
return null;
}
ObjectHelpers::strictCall($class, $name);
}
/**
* @param mixed[] $args
* @return never
* @throws MemberAccessException
*/
public static function __callStatic(string $name, array $args)
{
ObjectHelpers::strictStaticCall(static::class, $name);
}
/**
* @return mixed
* @throws MemberAccessException if the property is not defined.
*/
public function &__get(string $name)
{
$class = static::class;
if ($prop = ObjectHelpers::getMagicProperties($class)[$name] ?? null) { // property getter
if (!($prop & 0b0001)) {
throw new MemberAccessException("Cannot read a write-only property $class::\$$name.");
}
$m = ($prop & 0b0010 ? 'get' : 'is') . ucfirst($name);
if ($prop & 0b10000) {
$trace = debug_backtrace(0, 1)[0]; // suppose this method is called from __call()
$loc = isset($trace['file'], $trace['line'])
? " in $trace[file] on line $trace[line]"
: '';
trigger_error("Property $class::\$$name is deprecated, use $class::$m() method$loc.", E_USER_DEPRECATED);
}
if ($prop & 0b0100) { // return by reference
return $this->$m();
} else {
$val = $this->$m();
return $val;
}
} else {
ObjectHelpers::strictGet($class, $name);
}
}
/**
* @throws MemberAccessException if the property is not defined or is read-only
*/
public function __set(string $name, mixed $value): void
{
$class = static::class;
if (ObjectHelpers::hasProperty($class, $name)) { // unsetted property
$this->$name = $value;
} elseif ($prop = ObjectHelpers::getMagicProperties($class)[$name] ?? null) { // property setter
if (!($prop & 0b1000)) {
throw new MemberAccessException("Cannot write to a read-only property $class::\$$name.");
}
$m = 'set' . ucfirst($name);
if ($prop & 0b10000) {
$trace = debug_backtrace(0, 1)[0]; // suppose this method is called from __call()
$loc = isset($trace['file'], $trace['line'])
? " in $trace[file] on line $trace[line]"
: '';
trigger_error("Property $class::\$$name is deprecated, use $class::$m() method$loc.", E_USER_DEPRECATED);
}
$this->$m($value);
} else {
ObjectHelpers::strictSet($class, $name);
}
}
/**
* @throws MemberAccessException
*/
public function __unset(string $name): void
{
$class = static::class;
if (!ObjectHelpers::hasProperty($class, $name)) {
throw new MemberAccessException("Cannot unset the property $class::\$$name.");
}
}
public function __isset(string $name): bool
{
return isset(ObjectHelpers::getMagicProperties(static::class)[$name]);
}
}

View File

@ -1,22 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette;
/**
* Prevents instantiation.
*/
trait StaticClass
{
/**
* Class is static and cannot be instantiated.
*/
private function __construct()
{
}
}

View File

@ -1,23 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Localization;
/**
* Translation provider.
*/
interface Translator
{
/**
* Translates the given string.
*/
function translate(string|\Stringable $message, mixed ...$parameters): string|\Stringable;
}
interface_exists(ITranslator::class);

View File

@ -1,100 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette;
use function count, is_array, is_scalar, sprintf;
/**
* Array-like object with property access.
* @template T
* @implements \IteratorAggregate<array-key, T>
* @implements \ArrayAccess<array-key, T>
*/
class ArrayHash extends \stdClass implements \ArrayAccess, \Countable, \IteratorAggregate
{
/**
* Transforms array to ArrayHash.
* @param array<T> $array
*/
public static function from(array $array, bool $recursive = true): static
{
$obj = new static;
foreach ($array as $key => $value) {
$obj->$key = $recursive && is_array($value)
? static::from($value)
: $value;
}
return $obj;
}
/**
* @return \Iterator<array-key, T>
*/
public function &getIterator(): \Iterator
{
foreach ((array) $this as $key => $foo) {
yield $key => $this->$key;
}
}
public function count(): int
{
return count((array) $this);
}
/**
* Replaces or appends an item.
* @param array-key $key
* @param T $value
*/
public function offsetSet($key, $value): void
{
if (!is_scalar($key)) { // prevents null
throw new Nette\InvalidArgumentException(sprintf('Key must be either a string or an integer, %s given.', get_debug_type($key)));
}
$this->$key = $value;
}
/**
* Returns an item.
* @param array-key $key
* @return T
*/
public function offsetGet($key): mixed
{
return $this->$key;
}
/**
* Determines whether an item exists.
* @param array-key $key
*/
public function offsetExists($key): bool
{
return isset($this->$key);
}
/**
* Removes the element from this list.
* @param array-key $key
*/
public function offsetUnset($key): void
{
unset($this->$key);
}
}

View File

@ -1,130 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette;
use function array_slice, array_splice, count, is_int;
/**
* Generic list with integer indices.
* @template T
* @implements \IteratorAggregate<int, T>
* @implements \ArrayAccess<int, T>
*/
class ArrayList implements \ArrayAccess, \Countable, \IteratorAggregate
{
/** @var list<T> */
private array $list = [];
/**
* Transforms array to ArrayList.
* @param list<T> $array
*/
public static function from(array $array): static
{
if (!Arrays::isList($array)) {
throw new Nette\InvalidArgumentException('Array is not valid list.');
}
$obj = new static;
$obj->list = $array;
return $obj;
}
/**
* @return \Iterator<int, T>
*/
public function &getIterator(): \Iterator
{
foreach ($this->list as &$item) {
yield $item;
}
}
public function count(): int
{
return count($this->list);
}
/**
* Replaces or appends an item.
* @param ?int $index
* @param T $value
* @throws Nette\OutOfRangeException
*/
public function offsetSet($index, $value): void
{
if ($index === null) {
$this->list[] = $value;
} elseif (!is_int($index) || $index < 0 || $index >= count($this->list)) {
throw new Nette\OutOfRangeException('Offset invalid or out of range');
} else {
$this->list[$index] = $value;
}
}
/**
* Returns an item.
* @param int $index
* @return T
* @throws Nette\OutOfRangeException
*/
public function offsetGet($index): mixed
{
if (!is_int($index) || $index < 0 || $index >= count($this->list)) {
throw new Nette\OutOfRangeException('Offset invalid or out of range');
}
return $this->list[$index];
}
/**
* Determines whether an item exists.
* @param int $index
*/
public function offsetExists($index): bool
{
return is_int($index) && $index >= 0 && $index < count($this->list);
}
/**
* Removes the element at the specified position in this list.
* @param int $index
* @throws Nette\OutOfRangeException
*/
public function offsetUnset($index): void
{
if (!is_int($index) || $index < 0 || $index >= count($this->list)) {
throw new Nette\OutOfRangeException('Offset invalid or out of range');
}
array_splice($this->list, $index, 1);
}
/**
* Prepends an item.
* @param T $value
*/
public function prepend(mixed $value): void
{
$first = array_slice($this->list, 0, 1);
$this->offsetSet(0, $value);
array_splice($this->list, 1, 0, $first);
}
}

View File

@ -1,571 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use JetBrains\PhpStorm\Language;
use Nette;
use function array_combine, array_intersect_key, array_is_list, array_key_exists, array_key_first, array_key_last, array_keys, array_reverse, array_search, array_slice, array_walk_recursive, count, func_num_args, in_array, is_array, is_int, is_object, key, preg_split;
use const PREG_GREP_INVERT, PREG_SPLIT_DELIM_CAPTURE, PREG_SPLIT_NO_EMPTY;
/**
* Array manipulation utilities.
*/
class Arrays
{
use Nette\StaticClass;
/**
* Returns item from array. If it does not exist, it throws an exception, unless a default value is set.
* @template T
* @param array<T> $array
* @param array-key|array-key[] $key
* @param ?T $default
* @return ?T
* @throws Nette\InvalidArgumentException if item does not exist and default value is not provided
*/
public static function get(array $array, string|int|array $key, mixed $default = null): mixed
{
foreach (is_array($key) ? $key : [$key] as $k) {
if (is_array($array) && array_key_exists($k, $array)) {
$array = $array[$k];
} else {
if (func_num_args() < 3) {
throw new Nette\InvalidArgumentException("Missing item '$k'.");
}
return $default;
}
}
return $array;
}
/**
* Returns reference to array item. If the index does not exist, new one is created with value null.
* @template T
* @param array<T> $array
* @param array-key|array-key[] $key
* @return ?T
* @throws Nette\InvalidArgumentException if traversed item is not an array
*/
public static function &getRef(array &$array, string|int|array $key): mixed
{
foreach (is_array($key) ? $key : [$key] as $k) {
if (is_array($array) || $array === null) {
$array = &$array[$k];
} else {
throw new Nette\InvalidArgumentException('Traversed item is not an array.');
}
}
return $array;
}
/**
* Recursively merges two arrays. Useful for merging tree structures. Behaves like the + operator:
* key/value pairs from the second array are added to the first, with the first array's values taking
* precedence on key collisions. Nested arrays are merged recursively instead of replaced.
* @template T1
* @template T2
* @param array<T1> $array1
* @param array<T2> $array2
* @return array<T1|T2|array<mixed>>
*/
public static function mergeTree(array $array1, array $array2): array
{
$res = $array1 + $array2;
foreach (array_intersect_key($array1, $array2) as $k => $v) {
if (is_array($v) && is_array($array2[$k])) {
$res[$k] = self::mergeTree($v, $array2[$k]);
}
}
return $res;
}
/**
* Returns zero-indexed position of given array key. Returns null if key is not found.
* @param array<mixed> $array
*/
public static function getKeyOffset(array $array, string|int $key): ?int
{
return Helpers::falseToNull(array_search(self::toKey($key), array_keys($array), strict: true));
}
/**
* @param array<mixed> $array
* @deprecated use getKeyOffset()
*/
public static function searchKey(array $array, string|int $key): ?int
{
return self::getKeyOffset($array, $key);
}
/**
* Tests an array for the presence of value.
* @param array<mixed> $array
*/
public static function contains(array $array, mixed $value): bool
{
return in_array($value, $array, strict: true);
}
/**
* Returns the first item (matching the specified predicate if given). If there is no such item, it returns result of invoking $else or null.
* @template K of int|string
* @template V
* @template E
* @param array<K, V> $array
* @param ?callable(V, K, array<K, V>): bool $predicate
* @param ?callable(): E $else
* @return ($else is null ? ?V : V|E)
*/
public static function first(array $array, ?callable $predicate = null, ?callable $else = null): mixed
{
$key = self::firstKey($array, $predicate);
return $key === null
? ($else ? $else() : null)
: $array[$key];
}
/**
* Returns the last item (matching the specified predicate if given). If there is no such item, it returns result of invoking $else or null.
* @template K of int|string
* @template V
* @template E
* @param array<K, V> $array
* @param ?callable(V, K, array<K, V>): bool $predicate
* @param ?callable(): E $else
* @return ($else is null ? ?V : V|E)
*/
public static function last(array $array, ?callable $predicate = null, ?callable $else = null): mixed
{
$key = self::lastKey($array, $predicate);
return $key === null
? ($else ? $else() : null)
: $array[$key];
}
/**
* Returns the key of first item (matching the specified predicate if given) or null if there is no such item.
* @template K of int|string
* @template V
* @param array<K, V> $array
* @param ?callable(V, K, array<K, V>): bool $predicate
* @return ?K
*/
public static function firstKey(array $array, ?callable $predicate = null): int|string|null
{
if (!$predicate) {
return array_key_first($array);
}
foreach ($array as $k => $v) {
if ($predicate($v, $k, $array)) {
return $k;
}
}
return null;
}
/**
* Returns the key of last item (matching the specified predicate if given) or null if there is no such item.
* @template K of int|string
* @template V
* @param array<K, V> $array
* @param ?callable(V, K, array<K, V>): bool $predicate
* @return ?K
*/
public static function lastKey(array $array, ?callable $predicate = null): int|string|null
{
return $predicate
? self::firstKey(array_reverse($array, preserve_keys: true), $predicate)
: array_key_last($array);
}
/**
* Inserts the contents of the $inserted array into the $array immediately before the $key.
* If $key is null (or does not exist), it is inserted at the beginning.
* @param array<mixed> $array
* @param array<mixed> $inserted
*/
public static function insertBefore(array &$array, string|int|null $key, array $inserted): void
{
$offset = $key === null ? 0 : (int) self::getKeyOffset($array, $key);
$array = array_slice($array, 0, $offset, preserve_keys: true)
+ $inserted
+ array_slice($array, $offset, count($array), preserve_keys: true);
}
/**
* Inserts the contents of the $inserted array into the $array immediately after the $key.
* If $key is null (or does not exist), it is inserted at the end.
* @param array<mixed> $array
* @param array<mixed> $inserted
*/
public static function insertAfter(array &$array, string|int|null $key, array $inserted): void
{
if ($key === null || ($offset = self::getKeyOffset($array, $key)) === null) {
$offset = count($array) - 1;
}
$array = array_slice($array, 0, $offset + 1, preserve_keys: true)
+ $inserted
+ array_slice($array, $offset + 1, count($array), preserve_keys: true);
}
/**
* Renames key in array.
* @param array<mixed> $array
*/
public static function renameKey(array &$array, string|int $oldKey, string|int $newKey): bool
{
$offset = self::getKeyOffset($array, $oldKey);
if ($offset === null) {
return false;
}
$val = &$array[$oldKey];
$keys = array_keys($array);
$keys[$offset] = $newKey;
$array = array_combine($keys, $array);
$array[$newKey] = &$val;
return true;
}
/**
* Returns only those array items, which matches a regular expression $pattern.
* @param string[] $array
* @return string[]
*/
public static function grep(
array $array,
#[Language('RegExp')]
string $pattern,
bool|int $invert = false,
): array
{
$flags = $invert ? PREG_GREP_INVERT : 0;
return Strings::pcre('preg_grep', [$pattern, $array, $flags]);
}
/**
* Transforms multidimensional array to flat array.
* @param array<mixed> $array
* @return array<mixed>
*/
public static function flatten(array $array, bool $preserveKeys = false): array
{
$res = [];
$cb = $preserveKeys
? function ($v, $k) use (&$res): void { $res[$k] = $v; }
: function ($v) use (&$res): void { $res[] = $v; };
array_walk_recursive($array, $cb);
return $res;
}
/**
* Checks if the array is indexed in ascending order of numeric keys from zero, a.k.a list.
* @return ($value is list ? true : false)
*/
public static function isList(mixed $value): bool
{
return is_array($value) && array_is_list($value);
}
/**
* Transforms a flat array of rows into an associative tree using a path expression like 'field|field[]field->field=field'.
* @param array<mixed> $array
* @param string|list<string> $path
* @return array<mixed>|\stdClass
*/
public static function associate(array $array, string|array $path): array|\stdClass
{
$parts = is_array($path)
? $path
: preg_split('#(\[\]|->|=|\|)#', $path, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
if (!$parts || $parts === ['->'] || $parts[0] === '=' || $parts[0] === '|') {
throw new Nette\InvalidArgumentException("Invalid path '" . (is_array($path) ? implode('', $path) : $path) . "'.");
}
$res = $parts[0] === '->' ? new \stdClass : [];
foreach ($array as $rowOrig) {
$row = (array) $rowOrig;
$x = &$res;
for ($i = 0; $i < count($parts); $i++) {
$part = $parts[$i];
if ($part === '[]') {
$x = &$x[];
} elseif ($part === '=') {
if (isset($parts[++$i])) {
$x = $row[$parts[$i]];
$row = null;
}
break; // '=' is always the final operation
} elseif ($part === '->') {
if (isset($parts[++$i])) {
if ($x === null) {
$x = new \stdClass;
}
$x = &$x->{$row[$parts[$i]]};
} else {
$row = is_object($rowOrig) ? $rowOrig : (object) $row;
}
} elseif ($part !== '|') {
$x = &$x[(string) $row[$part]];
}
}
if ($x === null) {
$x = $row;
}
}
return $res;
}
/**
* Converts array to associative: items with numeric keys are converted to keys, with $filling as their value.
* @param array<mixed> $array
* @return array<string, mixed>
*/
public static function normalize(array $array, mixed $filling = null): array
{
$res = [];
foreach ($array as $k => $v) {
$res[is_int($k) ? $v : $k] = is_int($k) ? $filling : $v;
}
return $res;
}
/**
* Returns and removes the value of an item from an array. If it does not exist, it throws an exception,
* or returns $default, if provided.
* @template T
* @param array<T> $array
* @param ?T $default
* @return ?T
* @throws Nette\InvalidArgumentException if item does not exist and default value is not provided
*/
public static function pick(array &$array, string|int $key, mixed $default = null): mixed
{
if (array_key_exists($key, $array)) {
$value = $array[$key];
unset($array[$key]);
return $value;
} elseif (func_num_args() < 3) {
throw new Nette\InvalidArgumentException("Missing item '$key'.");
} else {
return $default;
}
}
/**
* Tests whether at least one element in the array passes the test implemented by the provided function.
* @template K of int|string
* @template V
* @param array<K, V> $array
* @param callable(V, K, array<K, V>): bool $predicate
*/
public static function some(iterable $array, callable $predicate): bool
{
foreach ($array as $k => $v) {
if ($predicate($v, $k, $array)) {
return true;
}
}
return false;
}
/**
* Tests whether all elements in the array pass the test implemented by the provided function.
* @template K of int|string
* @template V
* @param array<K, V> $array
* @param callable(V, K, array<K, V>): bool $predicate
*/
public static function every(iterable $array, callable $predicate): bool
{
foreach ($array as $k => $v) {
if (!$predicate($v, $k, $array)) {
return false;
}
}
return true;
}
/**
* Returns a new array containing all key-value pairs matching the given $predicate.
* @template K of int|string
* @template V
* @param array<K, V> $array
* @param callable(V, K, array<K, V>): bool $predicate
* @return array<K, V>
*/
public static function filter(array $array, callable $predicate): array
{
$res = [];
foreach ($array as $k => $v) {
if ($predicate($v, $k, $array)) {
$res[$k] = $v;
}
}
return $res;
}
/**
* Returns an array containing the original keys and results of applying the given transform function to each element.
* @template K of int|string
* @template V
* @template R
* @param array<K, V> $array
* @param callable(V, K, array<K, V>): R $transformer
* @return array<K, R>
*/
public static function map(iterable $array, callable $transformer): array
{
$res = [];
foreach ($array as $k => $v) {
$res[$k] = $transformer($v, $k, $array);
}
return $res;
}
/**
* Returns an array containing new keys and values generated by applying the given transform function to each element.
* If the function returns null, the element is skipped.
* @template K of int|string
* @template V
* @template ResK of int|string
* @template ResV
* @param array<K, V> $array
* @param callable(V, K, array<K, V>): ?array{ResK, ResV} $transformer
* @return array<ResK, ResV>
*/
public static function mapWithKeys(array $array, callable $transformer): array
{
$res = [];
foreach ($array as $k => $v) {
$pair = $transformer($v, $k, $array);
if ($pair) {
$res[$pair[0]] = $pair[1];
}
}
return $res;
}
/**
* Invokes all callbacks and returns array of results.
* @param iterable<callable> $callbacks
* @return array<mixed>
*/
public static function invoke(iterable $callbacks, mixed ...$args): array
{
$res = [];
foreach ($callbacks as $k => $cb) {
$res[$k] = $cb(...$args);
}
return $res;
}
/**
* Invokes method on every object in an array and returns array of results.
* @param iterable<object> $objects
* @return array<mixed>
*/
public static function invokeMethod(iterable $objects, string $method, mixed ...$args): array
{
$res = [];
foreach ($objects as $k => $obj) {
$res[$k] = $obj->$method(...$args);
}
return $res;
}
/**
* Copies the elements of the $array array to the $object object and then returns it.
* @template T of object
* @param iterable<mixed> $array
* @param T $object
* @return T
*/
public static function toObject(iterable $array, object $object): object
{
foreach ($array as $k => $v) {
$object->$k = $v;
}
return $object;
}
/**
* Converts value to array key.
*/
public static function toKey(mixed $value): int|string
{
return key(@[$value => null]);
}
/**
* Returns a copy of $array where every item is cast to string and wrapped with $prefix and $suffix.
* @param string[] $array
* @return string[]
*/
public static function wrap(array $array, string $prefix = '', string $suffix = ''): array
{
$res = [];
foreach ($array as $k => $v) {
$res[$k] = $prefix . $v . $suffix;
}
return $res;
}
}

View File

@ -1,140 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette;
use function explode, func_get_args, ini_get, is_array, is_callable, is_object, is_string, preg_replace, restore_error_handler, set_error_handler, sprintf, str_contains, str_ends_with;
/**
* PHP callable tools.
*/
final class Callback
{
use Nette\StaticClass;
/**
* Invokes internal PHP function with own error handler.
* @param callable-string $function
* @param list<mixed> $args
* @param callable(string, int): (bool|void|null) $onError
*/
public static function invokeSafe(string $function, array $args, callable $onError): mixed
{
$prev = set_error_handler(function (int $severity, string $message, string $file, int $line) use ($onError, &$prev, $function): bool {
if ($file === __FILE__) {
$msg = ini_get('html_errors')
? Html::htmlToText($message)
: $message;
$msg = (string) preg_replace("#^$function\\(.*?\\): #", '', $msg);
if ($onError($msg, $severity) !== false) {
return true;
}
}
return $prev ? $prev(...func_get_args()) !== false : false;
});
try {
return $function(...$args);
} finally {
restore_error_handler();
}
}
/**
* Checks that $callable is a valid PHP callback and returns it. With $syntax set to true, only verifies
* the structural validity without checking whether the class or method actually exists.
* @return callable
* @throws Nette\InvalidArgumentException
*/
public static function check(mixed $callable, bool $syntax = false): mixed
{
if (!is_callable($callable, $syntax)) {
throw new Nette\InvalidArgumentException(
$syntax
? 'Given value is not a callable type.'
: sprintf("Callback '%s' is not callable.", self::toString($callable)),
);
}
return $callable;
}
/**
* Converts PHP callback to textual form. Class or method may not exists.
*/
public static function toString(mixed $callable): string
{
if ($callable instanceof \Closure) {
$inner = self::unwrap($callable);
return '{closure' . ($inner instanceof \Closure ? '}' : ' ' . self::toString($inner) . '}');
} else {
is_callable(is_object($callable) ? [$callable, '__invoke'] : $callable, true, $textual);
return $textual;
}
}
/**
* Returns reflection for method or function used in PHP callback.
* @param callable $callable type check is escalated to ReflectionException
* @throws \ReflectionException if callback is not valid
*/
public static function toReflection(mixed $callable): \ReflectionMethod|\ReflectionFunction
{
if ($callable instanceof \Closure) {
$callable = self::unwrap($callable);
}
if (is_string($callable) && str_contains($callable, '::')) {
return new ReflectionMethod(...explode('::', $callable, 2));
} elseif (is_array($callable)) {
return new ReflectionMethod($callable[0], $callable[1]);
} elseif (is_object($callable) && !$callable instanceof \Closure) {
return new ReflectionMethod($callable, '__invoke');
} else {
assert($callable instanceof \Closure || is_string($callable));
return new \ReflectionFunction($callable);
}
}
/**
* Checks whether PHP callback is function or static method.
*/
public static function isStatic(callable $callable): bool
{
return is_string(is_array($callable) ? $callable[0] : $callable);
}
/**
* Unwraps closure created by Closure::fromCallable().
* @return callable|array{object|class-string, string}|string
*/
public static function unwrap(\Closure $closure): callable|array|string
{
$r = new \ReflectionFunction($closure);
$class = $r->getClosureScopeClass()?->name;
if (str_ends_with($r->name, '}')) {
return $closure;
} elseif (($obj = $r->getClosureThis()) && $obj::class === $class) {
return [$obj, $r->name];
} elseif ($class) {
return [$class, $r->name];
} else {
return $r->name;
}
}
}

View File

@ -1,207 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use function array_merge, checkdate, implode, is_numeric, is_string, preg_replace_callback, sprintf, time, trim;
/**
* Extends PHP's DateTime with strict validation and additional factory methods.
*/
class DateTime extends \DateTime implements \JsonSerializable
{
/** minute in seconds */
public const MINUTE = 60;
/** hour in seconds */
public const HOUR = 60 * self::MINUTE;
/** day in seconds */
public const DAY = 24 * self::HOUR;
/** week in seconds */
public const WEEK = 7 * self::DAY;
/** average month in seconds */
public const MONTH = 2_629_800;
/** average year in seconds */
public const YEAR = 31_557_600;
/**
* Creates a DateTime object from a string, UNIX timestamp, or other DateTimeInterface object.
* @throws \Exception if the date and time are not valid.
*/
public static function from(string|int|\DateTimeInterface|null $time): static
{
if ($time instanceof \DateTimeInterface) {
return static::createFromInterface($time);
} elseif (is_numeric($time)) {
if ($time <= self::YEAR) {
$time += time();
}
return (new static)->setTimestamp((int) $time);
} else { // textual or null
return new static((string) $time);
}
}
/**
* Creates DateTime object.
* @throws \Exception if the date and time are not valid.
*/
public static function fromParts(
int $year,
int $month,
int $day,
int $hour = 0,
int $minute = 0,
float $second = 0.0,
): static
{
$sec = (int) floor($second);
return (new static(''))
->setDate($year, $month, $day)
->setTime($hour, $minute, $sec, (int) round(($second - $sec) * 1e6));
}
/**
* Returns a new DateTime object formatted according to the specified format.
*/
public static function createFromFormat(
string $format,
string $datetime,
string|\DateTimeZone|null $timezone = null,
): static|false
{
if (is_string($timezone)) {
$timezone = new \DateTimeZone($timezone);
}
$date = parent::createFromFormat($format, $datetime, $timezone);
return $date ? static::from($date) : false;
}
public function __construct(string $datetime = 'now', ?\DateTimeZone $timezone = null)
{
$this->apply($datetime, $timezone, true);
}
public function modify(string $modifier): static
{
$this->apply($modifier);
return $this;
}
public function setDate(int $year, int $month, int $day): static
{
if (!checkdate($month, $day, $year)) {
throw new \Exception(sprintf('The date %04d-%02d-%02d is not valid.', $year, $month, $day));
}
return parent::setDate($year, $month, $day);
}
public function setTime(int $hour, int $minute, int $second = 0, int $microsecond = 0): static
{
if (
$hour < 0 || $hour > 23
|| $minute < 0 || $minute > 59
|| $second < 0 || $second >= 60
|| $microsecond < 0 || $microsecond >= 1_000_000
) {
throw new \Exception(sprintf('The time %02d:%02d:%08.5F is not valid.', $hour, $minute, $second + $microsecond / 1_000_000));
}
return parent::setTime($hour, $minute, $second, $microsecond);
}
/**
* Converts a relative time string (e.g. '10 minut') to seconds.
*/
public static function relativeToSeconds(string $relativeTime): int
{
return (new self('@0 ' . $relativeTime))
->getTimestamp();
}
private function apply(string $datetime, ?\DateTimeZone $timezone = null, bool $ctr = false): void
{
$relPart = '';
$absPart = preg_replace_callback(
'/[+-]?\s*\d+\s+((microsecond|millisecond|[mµu]sec)s?|[mµ]s|sec(ond)?s?|min(ute)?s?|hours?)(\s+ago)?\b/iu',
function ($m) use (&$relPart) {
$relPart .= $m[0] . ' ';
return '';
},
$datetime,
);
if ($ctr) {
parent::__construct($absPart, $timezone);
$this->handleErrors($datetime);
} elseif (trim($absPart)) {
parent::modify($absPart) && $this->handleErrors($datetime);
}
if ($relPart) {
$timezone ??= $this->getTimezone();
$this->setTimezone(new \DateTimeZone('UTC'));
parent::modify($relPart) && $this->handleErrors($datetime);
$this->setTimezone($timezone);
}
}
/**
* Returns JSON representation in ISO 8601 (used by JavaScript).
*/
public function jsonSerialize(): string
{
return $this->format('c');
}
/**
* Returns the date and time in the format 'Y-m-d H:i:s'.
*/
public function __toString(): string
{
return $this->format('Y-m-d H:i:s');
}
/**
* Returns a modified copy of the object. Use (clone $dt)->modify(...) for better type safety.
*/
public function modifyClone(string $modify = ''): static
{
$dolly = clone $this;
return $modify ? $dolly->modify($modify) : $dolly;
}
private function handleErrors(string $value): void
{
$errors = self::getLastErrors();
$errors = array_merge($errors['errors'] ?? [], $errors['warnings'] ?? []);
if ($errors) {
throw new \Exception(implode(', ', $errors) . " '$value'");
}
}
}

View File

@ -1,66 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette;
use const DIRECTORY_SEPARATOR;
/**
* Represents the file or directory returned by the Finder.
* @internal do not create instances directly
*/
final class FileInfo extends \SplFileInfo
{
public function __construct(
string $file,
private readonly string $relativePath = '',
) {
parent::__construct($file);
$this->setInfoClass(self::class);
}
/**
* Returns the relative directory path.
*/
public function getRelativePath(): string
{
return $this->relativePath;
}
/**
* Returns the relative path including file name.
*/
public function getRelativePathname(): string
{
return ($this->relativePath === '' ? '' : $this->relativePath . DIRECTORY_SEPARATOR)
. $this->getBasename();
}
/**
* Returns the contents of the file.
* @throws Nette\IOException
*/
public function read(): string
{
return FileSystem::read($this->getPathname());
}
/**
* Writes the contents to the file.
* @throws Nette\IOException
*/
public function write(string $content): void
{
FileSystem::write($this->getPathname(), $content);
}
}

View File

@ -1,355 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette;
use function array_pop, chmod, decoct, dirname, end, fclose, file_exists, file_get_contents, file_put_contents, fopen, implode, is_dir, is_file, is_link, mkdir, preg_match, preg_split, realpath, rename, rmdir, rtrim, sprintf, str_replace, stream_copy_to_stream, stream_is_local, strtr;
use const DIRECTORY_SEPARATOR;
/**
* File system tool.
*/
final class FileSystem
{
/**
* Creates a directory if it does not exist, including parent directories.
* @throws Nette\IOException on error occurred
*/
public static function createDir(string $dir, int $mode = 0o777): void
{
if (!is_dir($dir) && !@mkdir($dir, $mode, recursive: true) && !is_dir($dir)) { // @ - dir may already exist
throw new Nette\IOException(sprintf(
"Unable to create directory '%s' with mode %s. %s",
self::normalizePath($dir),
decoct($mode),
Helpers::getLastError(),
));
}
}
/**
* Copies a file or an entire directory. Overwrites existing files and directories by default.
* @throws Nette\IOException on error occurred
* @throws Nette\InvalidStateException if $overwrite is set to false and destination already exists
*/
public static function copy(string $origin, string $target, bool $overwrite = true): void
{
if (stream_is_local($origin) && !file_exists($origin)) {
throw new Nette\IOException(sprintf("File or directory '%s' not found.", self::normalizePath($origin)));
} elseif (!$overwrite && file_exists($target)) {
throw new Nette\InvalidStateException(sprintf("File or directory '%s' already exists.", self::normalizePath($target)));
} elseif (is_dir($origin)) {
static::createDir($target);
foreach (new \FilesystemIterator($target) as $item) {
\assert($item instanceof \SplFileInfo);
static::delete($item->getPathname());
}
foreach ($iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($origin, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST) as $item) {
if ($item->isDir()) {
static::createDir($target . '/' . $iterator->getSubPathname());
} else {
static::copy($item->getPathname(), $target . '/' . $iterator->getSubPathname());
}
}
} else {
static::createDir(dirname($target));
if (@stream_copy_to_stream(static::open($origin, 'rb'), static::open($target, 'wb')) === false) { // @ is escalated to exception
throw new Nette\IOException(sprintf(
"Unable to copy file '%s' to '%s'. %s",
self::normalizePath($origin),
self::normalizePath($target),
Helpers::getLastError(),
));
}
}
}
/**
* Opens file and returns resource.
* @return resource
* @throws Nette\IOException on error occurred
*/
public static function open(string $path, string $mode)
{
$f = @fopen($path, $mode); // @ is escalated to exception
if (!$f) {
throw new Nette\IOException(sprintf(
"Unable to open file '%s'. %s",
self::normalizePath($path),
Helpers::getLastError(),
));
}
return $f;
}
/**
* Deletes a file or an entire directory if exists. If the directory is not empty, it deletes its contents first.
* @throws Nette\IOException on error occurred
*/
public static function delete(string $path): void
{
if (is_file($path) || is_link($path)) {
$func = DIRECTORY_SEPARATOR === '\\' && is_dir($path) ? 'rmdir' : 'unlink';
if (!@$func($path)) { // @ is escalated to exception
throw new Nette\IOException(sprintf(
"Unable to delete '%s'. %s",
self::normalizePath($path),
Helpers::getLastError(),
));
}
} elseif (is_dir($path)) {
foreach (new \FilesystemIterator($path) as $item) {
\assert($item instanceof \SplFileInfo);
static::delete($item->getPathname());
}
if (!@rmdir($path)) { // @ is escalated to exception
throw new Nette\IOException(sprintf(
"Unable to delete directory '%s'. %s",
self::normalizePath($path),
Helpers::getLastError(),
));
}
}
}
/**
* Renames or moves a file or a directory. Overwrites existing files and directories by default.
* @throws Nette\IOException on error occurred
* @throws Nette\InvalidStateException if $overwrite is set to false and destination already exists
*/
public static function rename(string $origin, string $target, bool $overwrite = true): void
{
if (!$overwrite && file_exists($target)) {
throw new Nette\InvalidStateException(sprintf("File or directory '%s' already exists.", self::normalizePath($target)));
} elseif (!file_exists($origin)) {
throw new Nette\IOException(sprintf("File or directory '%s' not found.", self::normalizePath($origin)));
} else {
static::createDir(dirname($target));
if (realpath($origin) !== realpath($target)) {
static::delete($target);
}
if (!@rename($origin, $target)) { // @ is escalated to exception
throw new Nette\IOException(sprintf(
"Unable to rename file or directory '%s' to '%s'. %s",
self::normalizePath($origin),
self::normalizePath($target),
Helpers::getLastError(),
));
}
}
}
/**
* Reads the content of a file.
* @throws Nette\IOException on error occurred
*/
public static function read(string $file): string
{
$content = @file_get_contents($file); // @ is escalated to exception
if ($content === false) {
throw new Nette\IOException(sprintf(
"Unable to read file '%s'. %s",
self::normalizePath($file),
Helpers::getLastError(),
));
}
return $content;
}
/**
* Reads the file content line by line. Because it reads continuously as we iterate over the lines,
* it is possible to read files larger than the available memory.
* @return \Generator<int, string>
* @throws Nette\IOException on error occurred
*/
public static function readLines(string $file, bool $stripNewLines = true): \Generator
{
return (function ($f) use ($file, $stripNewLines) {
$counter = 0;
do {
$line = Callback::invokeSafe('fgets', [$f], fn($error) => throw new Nette\IOException(sprintf(
"Unable to read file '%s'. %s",
self::normalizePath($file),
$error,
)));
if ($line === false) {
fclose($f);
break;
}
if ($stripNewLines) {
$line = rtrim($line, "\r\n");
}
yield $counter++ => $line;
} while (true);
})(static::open($file, 'r'));
}
/**
* Writes the string to a file. Creates the parent directory if it does not exist. Pass null as $mode to skip chmod.
* @throws Nette\IOException on error occurred
*/
public static function write(string $file, string $content, ?int $mode = 0o666): void
{
static::createDir(dirname($file));
if (@file_put_contents($file, $content) === false) { // @ is escalated to exception
throw new Nette\IOException(sprintf(
"Unable to write file '%s'. %s",
self::normalizePath($file),
Helpers::getLastError(),
));
}
if ($mode !== null && !@chmod($file, $mode)) { // @ is escalated to exception
throw new Nette\IOException(sprintf(
"Unable to chmod file '%s' to mode %s. %s",
self::normalizePath($file),
decoct($mode),
Helpers::getLastError(),
));
}
}
/**
* Sets file permissions to `$fileMode` or directory permissions to `$dirMode`.
* Recursively traverses and sets permissions on the entire contents of the directory as well.
* @throws Nette\IOException on error occurred
*/
public static function makeWritable(string $path, int $dirMode = 0o777, int $fileMode = 0o666): void
{
if (is_file($path)) {
if (!@chmod($path, $fileMode)) { // @ is escalated to exception
throw new Nette\IOException(sprintf(
"Unable to chmod file '%s' to mode %s. %s",
self::normalizePath($path),
decoct($fileMode),
Helpers::getLastError(),
));
}
} elseif (is_dir($path)) {
foreach (new \FilesystemIterator($path) as $item) {
\assert($item instanceof \SplFileInfo);
static::makeWritable($item->getPathname(), $dirMode, $fileMode);
}
if (!@chmod($path, $dirMode)) { // @ is escalated to exception
throw new Nette\IOException(sprintf(
"Unable to chmod directory '%s' to mode %s. %s",
self::normalizePath($path),
decoct($dirMode),
Helpers::getLastError(),
));
}
} else {
throw new Nette\IOException(sprintf("File or directory '%s' not found.", self::normalizePath($path)));
}
}
/**
* Determines if the path is absolute.
*/
public static function isAbsolute(string $path): bool
{
return (bool) preg_match('#([a-z]:)?[/\\\]|[a-z][a-z0-9+.-]*://#Ai', $path);
}
/**
* Determines whether the string is a valid cross-platform filename without any path information.
*/
public static function isValidFilename(string $name): bool
{
[$stem] = explode('.', $name, 2);
return $name !== '' && $name !== '.' && $name !== '..'
&& !preg_match('#[\x00-\x1F<>:"|?*\\\/]#', $name) // control and reserved characters
&& !str_ends_with($name, '.') && !str_ends_with($name, ' ') // trailing dots/spaces
&& !preg_match('#^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$#i', $stem); // Windows reserved device names
}
/**
* Normalizes `..` and `.` and directory separators in path.
*/
public static function normalizePath(string $path): string
{
$parts = $path === '' ? [] : preg_split('~[/\\\]+~', $path);
$res = [];
foreach ($parts as $part) {
if ($part === '..' && $res && end($res) !== '..' && end($res) !== '') {
array_pop($res);
} elseif ($part !== '.') {
$res[] = $part;
}
}
return $res === ['']
? DIRECTORY_SEPARATOR
: implode(DIRECTORY_SEPARATOR, $res);
}
/**
* Joins all segments of the path and normalizes the result.
*/
public static function joinPaths(string ...$paths): string
{
return self::normalizePath(implode('/', $paths));
}
/**
* Resolves a path against a base path. If the path is absolute, returns it directly, if it's relative, joins it with the base path.
*/
public static function resolvePath(string $basePath, string $path): string
{
return match (true) {
self::isAbsolute($path) => self::platformSlashes($path),
$path === '' => self::platformSlashes($basePath),
default => self::joinPaths($basePath, $path),
};
}
/**
* Converts backslashes to slashes.
*/
public static function unixSlashes(string $path): string
{
return strtr($path, '\\', '/');
}
/**
* Converts slashes to platform-specific directory separators.
*/
public static function platformSlashes(string $path): string
{
return DIRECTORY_SEPARATOR === '/'
? strtr($path, '\\', '/')
: str_replace(':\\\\', '://', strtr($path, '/', '\\')); // protocol://
}
}

View File

@ -1,527 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette;
use function array_merge, count, func_get_args, func_num_args, glob, implode, is_array, is_dir, iterator_to_array, preg_match, preg_quote, preg_replace, preg_split, rtrim, spl_object_id, sprintf, str_ends_with, str_starts_with, strnatcmp, strpbrk, strrpos, strtolower, strtr, substr, usort;
use const GLOB_NOESCAPE, GLOB_NOSORT, GLOB_ONLYDIR;
/**
* Searches for files and directories in directory trees.
*
* Finder::findFiles('*.php')
* ->size('> 10kB')
* ->from('.')
* ->exclude('temp');
*
* @implements \IteratorAggregate<string, FileInfo>
*/
class Finder implements \IteratorAggregate
{
/** @var array<array{string, string}> */
private array $find = [];
/** @var string[] */
private array $in = [];
/** @var array<\Closure(FileInfo): bool> */
private array $filters = [];
/** @var array<\Closure(FileInfo): bool> */
private array $descentFilters = [];
/** @var array<string|self> */
private array $appends = [];
private bool $childFirst = false;
/** @var ?(\Closure(FileInfo, FileInfo): int) */
private ?\Closure $sort = null;
private int $maxDepth = -1;
private bool $ignoreUnreadableDirs = true;
/**
* Begins search for files and directories matching mask.
* @param string|list<string> $masks
*/
public static function find(string|array $masks = ['*']): static
{
$masks = is_array($masks) ? $masks : func_get_args(); // compatibility with variadic
return (new static)->addMask($masks, 'dir')->addMask($masks, 'file');
}
/**
* Begins search for files matching mask.
* @param string|list<string> $masks
*/
public static function findFiles(string|array $masks = ['*']): static
{
$masks = is_array($masks) ? $masks : func_get_args(); // compatibility with variadic
return (new static)->addMask($masks, 'file');
}
/**
* Begins search for directories matching mask.
* @param string|list<string> $masks
*/
public static function findDirectories(string|array $masks = ['*']): static
{
$masks = is_array($masks) ? $masks : func_get_args(); // compatibility with variadic
return (new static)->addMask($masks, 'dir');
}
/**
* Finds files matching the specified masks.
* @param string|list<string> $masks
*/
public function files(string|array $masks = ['*']): static
{
return $this->addMask((array) $masks, 'file');
}
/**
* Finds directories matching the specified masks.
* @param string|list<string> $masks
*/
public function directories(string|array $masks = ['*']): static
{
return $this->addMask((array) $masks, 'dir');
}
/** @param list<string> $masks */
private function addMask(array $masks, string $mode): static
{
foreach ($masks as $mask) {
$mask = FileSystem::unixSlashes($mask);
if ($mode === 'dir') {
$mask = rtrim($mask, '/');
}
if ($mask === '' || ($mode === 'file' && str_ends_with($mask, '/'))) {
throw new Nette\InvalidArgumentException("Invalid mask '$mask'");
}
if (str_starts_with($mask, '**/')) {
$mask = substr($mask, 3);
}
$this->find[] = [$mask, $mode];
}
return $this;
}
/**
* Searches in the given directories. Wildcards are allowed.
* @param string|list<string> $paths
*/
public function in(string|array $paths): static
{
$paths = is_array($paths) ? $paths : func_get_args(); // compatibility with variadic
$this->addLocation($paths, '');
return $this;
}
/**
* Searches recursively from the given directories. Wildcards are allowed.
* @param string|list<string> $paths
*/
public function from(string|array $paths): static
{
$paths = is_array($paths) ? $paths : func_get_args(); // compatibility with variadic
$this->addLocation($paths, '/**');
return $this;
}
/** @param list<string> $paths */
private function addLocation(array $paths, string $ext): void
{
foreach ($paths as $path) {
if ($path === '') {
throw new Nette\InvalidArgumentException("Invalid directory '$path'");
}
$path = rtrim(FileSystem::unixSlashes($path), '/');
$this->in[] = $path . $ext;
}
}
/**
* Lists directory's contents before the directory itself. By default, this is disabled.
*/
public function childFirst(bool $state = true): static
{
$this->childFirst = $state;
return $this;
}
/**
* Ignores unreadable directories. By default, this is enabled.
*/
public function ignoreUnreadableDirs(bool $state = true): static
{
$this->ignoreUnreadableDirs = $state;
return $this;
}
/**
* Sets a comparison function for sorting entries within each directory.
* @param callable(FileInfo, FileInfo): int $callback
*/
public function sortBy(callable $callback): static
{
$this->sort = $callback(...);
return $this;
}
/**
* Sorts files in each directory naturally by name.
*/
public function sortByName(): static
{
$this->sort = fn(FileInfo $a, FileInfo $b): int => strnatcmp($a->getBasename(), $b->getBasename());
return $this;
}
/**
* Appends the specified file paths to results. Passing null creates and returns a new sub-finder whose results are appended.
* @param string|list<string>|null $paths
*/
public function append(string|array|null $paths = null): static
{
if ($paths === null) {
return $this->appends[] = new static;
}
$this->appends = array_merge($this->appends, (array) $paths);
return $this;
}
/********************* filtering ****************d*g**/
/**
* Skips entries that matches the given masks relative to the ones defined with the in() or from() methods.
* @param string|list<string> $masks
*/
public function exclude(string|array $masks): static
{
$masks = is_array($masks) ? $masks : func_get_args(); // compatibility with variadic
foreach ($masks as $mask) {
$mask = FileSystem::unixSlashes($mask);
if (!preg_match('~^/?(\*\*/)?(.+)(/\*\*|/\*|/|)$~D', $mask, $m)) {
throw new Nette\InvalidArgumentException("Invalid mask '$mask'");
}
$end = $m[3];
$re = $this->buildPattern($m[2]);
$filter = fn(FileInfo $file): bool => ($end && !$file->isDir())
|| !preg_match($re, FileSystem::unixSlashes($file->getRelativePathname()));
$this->descentFilter($filter);
if ($end !== '/*') {
$this->filter($filter);
}
}
return $this;
}
/**
* Yields only entries which satisfy the given filter.
* @param callable(FileInfo): bool $callback
*/
public function filter(callable $callback): static
{
$this->filters[] = $callback(...);
return $this;
}
/**
* It descends only to directories that match the specified filter.
* @param callable(FileInfo): bool $callback
*/
public function descentFilter(callable $callback): static
{
$this->descentFilters[] = $callback(...);
return $this;
}
/**
* Sets the maximum depth of entries.
*/
public function limitDepth(?int $depth): static
{
$this->maxDepth = $depth ?? -1;
return $this;
}
/**
* Restricts the search by size. $operator accepts "[operator] [size] [unit]" example: >=10kB
* @param '>'|'>='|'<'|'<='|'='|'=='|'==='|'!='|'!=='|'<>' $operator or predicate string
*/
public function size(string $operator, ?int $size = null): static
{
if (func_num_args() === 1) { // in $operator is predicate
if (!preg_match('#^(?:([=<>!]=?|<>)\s*)?((?:\d*\.)?\d+)\s*(K|M|G|)B?$#Di', $operator, $matches)) {
throw new Nette\InvalidArgumentException('Invalid size predicate format.');
}
[, $operator, $size, $unit] = $matches;
$units = ['' => 1, 'k' => 1e3, 'm' => 1e6, 'g' => 1e9];
$size = (float) $size * $units[strtolower($unit)];
$operator = $operator ?: '=';
}
return $this->filter(fn(FileInfo $file): bool => !$file->isFile() || Helpers::compare($file->getSize(), $operator, $size));
}
/**
* Restricts the search by modified time. $operator accepts "[operator] [date]" example: >1978-01-23
* @param '>'|'>='|'<'|'<='|'='|'=='|'==='|'!='|'!=='|'<>' $operator or predicate string
*/
public function date(string $operator, string|int|\DateTimeInterface|null $date = null): static
{
if (func_num_args() === 1) { // in $operator is predicate
if (!preg_match('#^(?:([=<>!]=?|<>)\s*)?(.+)$#Di', $operator, $matches)) {
throw new Nette\InvalidArgumentException('Invalid date predicate format.');
}
[, $operator, $date] = $matches;
$operator = $operator ?: '=';
}
$date = DateTime::from($date)->getTimestamp();
return $this->filter(fn(FileInfo $file): bool => !$file->isFile() || Helpers::compare($file->getMTime(), $operator, $date));
}
/********************* iterator generator ****************d*g**/
/**
* Returns an array with all found files and directories.
* @return list<FileInfo>
*/
public function collect(): array
{
return iterator_to_array($this->getIterator(), preserve_keys: false);
}
/** @return \Generator<string, FileInfo> */
public function getIterator(): \Generator
{
$plan = $this->buildPlan();
foreach ($plan as $dir => $searches) {
yield from $this->traverseDir($dir, $searches);
}
foreach ($this->appends as $item) {
if ($item instanceof self) {
yield from $item->getIterator();
} else {
$item = FileSystem::platformSlashes($item);
yield $item => new FileInfo($item);
}
}
}
/**
* @param array<object{pattern: string, mode: string, recursive: bool}> $searches
* @param string[] $subdirs
* @return \Generator<string, FileInfo>
*/
private function traverseDir(string $dir, array $searches, array $subdirs = []): \Generator
{
if ($this->maxDepth >= 0 && count($subdirs) > $this->maxDepth) {
return;
} elseif (!is_dir($dir)) {
throw new Nette\InvalidStateException(sprintf("Directory '%s' does not exist.", rtrim($dir, '/\\')));
}
try {
$pathNames = new \FilesystemIterator($dir, \FilesystemIterator::FOLLOW_SYMLINKS | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::UNIX_PATHS);
} catch (\UnexpectedValueException $e) {
if ($this->ignoreUnreadableDirs) {
return;
} else {
throw new Nette\InvalidStateException($e->getMessage());
}
}
$files = $this->convertToFiles($pathNames, implode('/', $subdirs), FileSystem::isAbsolute($dir));
if ($this->sort) {
$files = iterator_to_array($files);
usort($files, $this->sort);
}
foreach ($files as $file) {
$pathName = $file->getPathname();
$cache = $subSearch = [];
if ($file->isDir()) {
foreach ($searches as $search) {
if ($search->recursive && $this->proveFilters($this->descentFilters, $file, $cache)) {
$subSearch[] = $search;
}
}
}
if ($this->childFirst && $subSearch) {
yield from $this->traverseDir($pathName, $subSearch, array_merge($subdirs, [$file->getBasename()]));
}
$relativePathname = FileSystem::unixSlashes($file->getRelativePathname());
foreach ($searches as $search) {
if (
"is_$search->mode"(Helpers::IsWindows && $file->isLink() ? $file->getLinkTarget() : $file->getPathname())
&& preg_match($search->pattern, $relativePathname)
&& $this->proveFilters($this->filters, $file, $cache)
) {
yield $pathName => $file;
break;
}
}
if (!$this->childFirst && $subSearch) {
yield from $this->traverseDir($pathName, $subSearch, array_merge($subdirs, [$file->getBasename()]));
}
}
}
/** @param iterable<string> $pathNames */
private function convertToFiles(iterable $pathNames, string $relativePath, bool $absolute): \Generator
{
foreach ($pathNames as $pathName) {
if (!$absolute) {
$pathName = preg_replace('~\.?/~A', '', $pathName);
}
$pathName = FileSystem::platformSlashes($pathName);
yield new FileInfo($pathName, $relativePath);
}
}
/**
* @param (\Closure(FileInfo): bool)[] $filters
* @param array<int, bool> $cache
*/
private function proveFilters(array $filters, FileInfo $file, array &$cache): bool
{
foreach ($filters as $filter) {
$res = &$cache[spl_object_id($filter)];
$res ??= $filter($file);
if (!$res) {
return false;
}
}
return true;
}
/** @return array<string, array<object{pattern: string, mode: string, recursive: bool}>> */
private function buildPlan(): array
{
$plan = $dirCache = [];
foreach ($this->find as [$mask, $mode]) {
$splits = [];
if (FileSystem::isAbsolute($mask)) {
if ($this->in) {
throw new Nette\InvalidStateException("You cannot combine the absolute path in the mask '$mask' and the directory to search '{$this->in[0]}'.");
}
$splits[] = self::splitRecursivePart($mask);
} else {
foreach ($this->in ?: ['.'] as $in) {
$in = strtr($in, ['[' => '[[]', ']' => '[]]']); // in path, do not treat [ and ] as a pattern by glob()
$splits[] = self::splitRecursivePart($in . '/' . $mask);
}
}
foreach ($splits as [$base, $rest, $recursive]) {
$base = $base === '' ? '.' : $base;
$dirs = $dirCache[$base] ??= strpbrk($base, '*?[')
? glob($base, GLOB_NOSORT | GLOB_ONLYDIR | GLOB_NOESCAPE)
: [strtr($base, ['[[]' => '[', '[]]' => ']'])]; // unescape [ and ]
if (!$dirs) {
throw new Nette\InvalidStateException(sprintf("Directory '%s' does not exist.", rtrim($base, '/\\')));
}
$search = (object) ['pattern' => $this->buildPattern($rest), 'mode' => $mode, 'recursive' => $recursive];
foreach ($dirs as $dir) {
$plan[$dir][] = $search;
}
}
}
return $plan;
}
/**
* Since glob() does not know ** wildcard, we divide the path into a part for glob and a part for manual traversal.
* @return array{string, string, bool}
*/
private static function splitRecursivePart(string $path): array
{
$a = strrpos($path, '/');
$parts = preg_split('~(?<=^|/)\*\*($|/)~', substr($path, 0, $a + 1), 2);
return isset($parts[1])
? [$parts[0], $parts[1] . substr($path, $a + 1), true]
: [$parts[0], substr($path, $a + 1), false];
}
/**
* Converts wildcards to regular expression.
*/
private function buildPattern(string $mask): string
{
if ($mask === '*') {
return '##';
} elseif (str_starts_with($mask, './')) {
$anchor = '^';
$mask = substr($mask, 2);
} else {
$anchor = '(?:^|/)';
}
$pattern = strtr(
preg_quote($mask, '#'),
[
'\*\*/' => '(.+/)?',
'\*' => '[^/]*',
'\?' => '[^/]',
'\[\!' => '[^',
'\[' => '[',
'\]' => ']',
'\-' => '-',
],
);
return '#' . $anchor . $pattern . '$#D' . (Helpers::IsWindows ? 'i' : '');
}
}

View File

@ -1,106 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette;
use function abs, is_finite, is_nan, max, round;
/**
* Floating-point numbers comparison.
*/
class Floats
{
use Nette\StaticClass;
private const Epsilon = 1e-10;
public static function isZero(float $value): bool
{
return abs($value) < self::Epsilon;
}
public static function isInteger(float $value): bool
{
return abs(round($value) - $value) < self::Epsilon;
}
/**
* Compare two floats. If $a < $b it returns -1, if they are equal it returns 0 and if $a > $b it returns 1
* @throws \LogicException if one of parameters is NAN
*/
public static function compare(float $a, float $b): int
{
if (is_nan($a) || is_nan($b)) {
throw new \LogicException('Trying to compare NAN');
} elseif (!is_finite($a) && !is_finite($b) && $a === $b) {
return 0;
}
$diff = abs($a - $b);
if (($diff < self::Epsilon || ($diff / max(abs($a), abs($b)) < self::Epsilon))) {
return 0;
}
return $a < $b ? -1 : 1;
}
/**
* Returns true if $a = $b
* @throws \LogicException if one of parameters is NAN
*/
public static function areEqual(float $a, float $b): bool
{
return self::compare($a, $b) === 0;
}
/**
* Returns true if $a < $b
* @throws \LogicException if one of parameters is NAN
*/
public static function isLessThan(float $a, float $b): bool
{
return self::compare($a, $b) < 0;
}
/**
* Returns true if $a <= $b
* @throws \LogicException if one of parameters is NAN
*/
public static function isLessThanOrEqualTo(float $a, float $b): bool
{
return self::compare($a, $b) <= 0;
}
/**
* Returns true if $a > $b
* @throws \LogicException if one of parameters is NAN
*/
public static function isGreaterThan(float $a, float $b): bool
{
return self::compare($a, $b) > 0;
}
/**
* Returns true if $a >= $b
* @throws \LogicException if one of parameters is NAN
*/
public static function isGreaterThanOrEqualTo(float $a, float $b): bool
{
return self::compare($a, $b) >= 0;
}
}

View File

@ -1,125 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette;
use function array_unique, ini_get, levenshtein, max, min, ob_end_clean, ob_get_clean, ob_start, preg_replace, strlen;
use const PHP_OS_FAMILY;
/**
* Miscellaneous utilities.
*/
class Helpers
{
public const IsWindows = PHP_OS_FAMILY === 'Windows';
/**
* Executes a callback and returns the captured output as a string.
* @param callable(): void $func
*/
public static function capture(callable $func): string
{
ob_start(fn() => '');
try {
$func();
return ob_get_clean();
} catch (\Throwable $e) {
ob_end_clean();
throw $e;
}
}
/**
* Returns the last occurred PHP error or an empty string if no error occurred. Unlike error_get_last(),
* it is not affected by the PHP directive html_errors and always returns text, not HTML.
*/
public static function getLastError(): string
{
$message = error_get_last()['message'] ?? '';
$message = ini_get('html_errors') ? Html::htmlToText($message) : $message;
$message = preg_replace('#^\w+\(.*?\): #', '', $message);
return $message;
}
/**
* Converts false to null, does not change other values.
*/
public static function falseToNull(mixed $value): mixed
{
return $value === false ? null : $value;
}
/**
* Returns value clamped to the inclusive range of min and max.
* @return ($value is float ? float : ($min is float ? float : ($max is float ? float : int)))
*/
public static function clamp(int|float $value, int|float $min, int|float $max): int|float
{
if ($min > $max) {
throw new Nette\InvalidArgumentException("Minimum ($min) is not less than maximum ($max).");
}
return min(max($value, $min), $max);
}
/**
* Finds the string from $possibilities most similar to $value using Levenshtein distance, or null if none is close enough.
* @param string[] $possibilities
*/
public static function getSuggestion(array $possibilities, string $value): ?string
{
$best = null;
$min = (strlen($value) / 4 + 1) * 10 + .1;
foreach (array_unique($possibilities) as $item) {
if ($item !== $value && ($len = levenshtein($item, $value, 10, 11, 10)) < $min) {
$min = $len;
$best = $item;
}
}
return $best;
}
/**
* Compares two values in the same way that PHP does. Recognizes operators: >, >=, <, <=, =, ==, ===, !=, !==, <>
* @param '>'|'>='|'<'|'<='|'='|'=='|'==='|'!='|'!=='|'<>' $operator
*/
public static function compare(mixed $left, string $operator, mixed $right): bool
{
return match ($operator) {
'>' => $left > $right,
'>=' => $left >= $right,
'<' => $left < $right,
'<=' => $left <= $right,
'=', '==' => $left == $right,
'===' => $left === $right,
'!=', '<>' => $left != $right,
'!==' => $left !== $right,
default => throw new Nette\InvalidArgumentException("Unknown operator '$operator'"),
};
}
/**
* Splits a class name into namespace and short class name.
* @return array{string, string}
*/
public static function splitClassName(string $name): array
{
return ($pos = strrpos($name, '\\')) === false
? ['', $name]
: [substr($name, 0, $pos), substr($name, $pos + 1)];
}
}

View File

@ -1,840 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette\HtmlStringable;
use function array_merge, array_splice, count, explode, func_num_args, html_entity_decode, htmlspecialchars, http_build_query, implode, is_array, is_bool, is_float, is_object, is_string, json_encode, max, number_format, rtrim, str_contains, str_repeat, str_replace, strip_tags, strncmp, strpbrk, substr;
use const ENT_HTML5, ENT_NOQUOTES, ENT_QUOTES;
/**
* Generates HTML elements with automatic attribute escaping.
*
* @property ?string $accept
* @property ?string $accesskey
* @property ?string $action
* @property ?string $align
* @property ?string $allow
* @property ?string $alt
* @property ?bool $async
* @property ?string $autocapitalize
* @property ?string $autocomplete
* @property ?bool $autofocus
* @property ?bool $autoplay
* @property ?string $charset
* @property ?bool $checked
* @property ?string $cite
* @property ?string $class
* @property ?int $cols
* @property ?int $colspan
* @property ?string $content
* @property ?bool $contenteditable
* @property ?bool $controls
* @property ?string $coords
* @property ?string $crossorigin
* @property ?string $data
* @property ?string $datetime
* @property ?string $decoding
* @property ?bool $default
* @property ?bool $defer
* @property ?string $dir
* @property ?string $dirname
* @property ?bool $disabled
* @property ?bool $download
* @property ?string $draggable
* @property ?string $dropzone
* @property ?string $enctype
* @property ?string $for
* @property ?string $form
* @property ?string $formaction
* @property ?string $formenctype
* @property ?string $formmethod
* @property ?bool $formnovalidate
* @property ?string $formtarget
* @property ?string $headers
* @property ?int $height
* @property ?bool $hidden
* @property ?float $high
* @property ?string $href
* @property ?string $hreflang
* @property ?string $id
* @property ?string $integrity
* @property ?string $inputmode
* @property ?bool $ismap
* @property ?string $itemprop
* @property ?string $kind
* @property ?string $label
* @property ?string $lang
* @property ?string $list
* @property ?bool $loop
* @property ?float $low
* @property ?float $max
* @property ?int $maxlength
* @property ?int $minlength
* @property ?string $media
* @property ?string $method
* @property ?float $min
* @property ?bool $multiple
* @property ?bool $muted
* @property ?string $name
* @property ?bool $novalidate
* @property ?bool $open
* @property ?float $optimum
* @property ?string $pattern
* @property ?string $ping
* @property ?string $placeholder
* @property ?string $poster
* @property ?string $preload
* @property ?string $radiogroup
* @property ?bool $readonly
* @property ?string $rel
* @property ?bool $required
* @property ?bool $reversed
* @property ?int $rows
* @property ?int $rowspan
* @property ?string $sandbox
* @property ?string $scope
* @property ?bool $selected
* @property ?string $shape
* @property ?int $size
* @property ?string $sizes
* @property ?string $slot
* @property ?int $span
* @property ?string $spellcheck
* @property ?string $src
* @property ?string $srcdoc
* @property ?string $srclang
* @property ?string $srcset
* @property ?int $start
* @property ?float $step
* @property ?string $style
* @property ?int $tabindex
* @property ?string $target
* @property ?string $title
* @property ?string $translate
* @property ?string $type
* @property ?string $usemap
* @property ?string $value
* @property ?int $width
* @property ?string $wrap
*
* @method self accept(?string $val)
* @method self accesskey(?string $val, bool $state = null)
* @method self action(?string $val)
* @method self align(?string $val)
* @method self allow(?string $val, bool $state = null)
* @method self alt(?string $val)
* @method self async(?bool $val)
* @method self autocapitalize(?string $val)
* @method self autocomplete(?string $val)
* @method self autofocus(?bool $val)
* @method self autoplay(?bool $val)
* @method self charset(?string $val)
* @method self checked(?bool $val)
* @method self cite(?string $val)
* @method self class(?string $val, bool $state = null)
* @method self cols(?int $val)
* @method self colspan(?int $val)
* @method self content(?string $val)
* @method self contenteditable(?bool $val)
* @method self controls(?bool $val)
* @method self coords(?string $val)
* @method self crossorigin(?string $val)
* @method self datetime(?string $val)
* @method self decoding(?string $val)
* @method self default(?bool $val)
* @method self defer(?bool $val)
* @method self dir(?string $val)
* @method self dirname(?string $val)
* @method self disabled(?bool $val)
* @method self download(?bool $val)
* @method self draggable(?string $val)
* @method self dropzone(?string $val)
* @method self enctype(?string $val)
* @method self for(?string $val)
* @method self form(?string $val)
* @method self formaction(?string $val)
* @method self formenctype(?string $val)
* @method self formmethod(?string $val)
* @method self formnovalidate(?bool $val)
* @method self formtarget(?string $val)
* @method self headers(?string $val, bool $state = null)
* @method self height(?int $val)
* @method self hidden(?bool $val)
* @method self high(?float $val)
* @method self hreflang(?string $val)
* @method self id(?string $val)
* @method self integrity(?string $val)
* @method self inputmode(?string $val)
* @method self ismap(?bool $val)
* @method self itemprop(?string $val)
* @method self kind(?string $val)
* @method self label(?string $val)
* @method self lang(?string $val)
* @method self list(?string $val)
* @method self loop(?bool $val)
* @method self low(?float $val)
* @method self max(?float $val)
* @method self maxlength(?int $val)
* @method self minlength(?int $val)
* @method self media(?string $val)
* @method self method(?string $val)
* @method self min(?float $val)
* @method self multiple(?bool $val)
* @method self muted(?bool $val)
* @method self name(?string $val)
* @method self novalidate(?bool $val)
* @method self open(?bool $val)
* @method self optimum(?float $val)
* @method self pattern(?string $val)
* @method self ping(?string $val, bool $state = null)
* @method self placeholder(?string $val)
* @method self poster(?string $val)
* @method self preload(?string $val)
* @method self radiogroup(?string $val)
* @method self readonly(?bool $val)
* @method self rel(?string $val)
* @method self required(?bool $val)
* @method self reversed(?bool $val)
* @method self rows(?int $val)
* @method self rowspan(?int $val)
* @method self sandbox(?string $val, bool $state = null)
* @method self scope(?string $val)
* @method self selected(?bool $val)
* @method self shape(?string $val)
* @method self size(?int $val)
* @method self sizes(?string $val)
* @method self slot(?string $val)
* @method self span(?int $val)
* @method self spellcheck(?string $val)
* @method self src(?string $val)
* @method self srcdoc(?string $val)
* @method self srclang(?string $val)
* @method self srcset(?string $val)
* @method self start(?int $val)
* @method self step(?float $val)
* @method self style(?string $property, string $val = null)
* @method self tabindex(?int $val)
* @method self target(?string $val)
* @method self title(?string $val)
* @method self translate(?string $val)
* @method self type(?string $val)
* @method self usemap(?string $val)
* @method self value(?string $val)
* @method self width(?int $val)
* @method self wrap(?string $val)
*
* @implements \IteratorAggregate<int, self|string>
* @implements \ArrayAccess<int, self|string>
*/
class Html implements \ArrayAccess, \Countable, \IteratorAggregate, HtmlStringable
{
/** @var array<string, mixed> element's attributes */
public array $attrs = [];
/** @var array<string, int> void elements */
public static array $emptyElements = [
'img' => 1, 'hr' => 1, 'br' => 1, 'input' => 1, 'meta' => 1, 'area' => 1, 'embed' => 1, 'keygen' => 1,
'source' => 1, 'base' => 1, 'col' => 1, 'link' => 1, 'param' => 1, 'basefont' => 1, 'frame' => 1,
'isindex' => 1, 'wbr' => 1, 'command' => 1, 'track' => 1,
];
/** @var array<int, self|string> nodes */
protected array $children = [];
/** element's name */
private string $name = '';
private bool $isEmpty = false;
/**
* Constructs new HTML element.
* @param array<string, mixed>|string|null $attrs element's attributes or plain text content
*/
public static function el(?string $name = null, array|string|null $attrs = null): static
{
$el = new static;
$parts = explode(' ', (string) $name, 2);
$el->setName($parts[0]);
if (is_array($attrs)) {
$el->attrs = $attrs;
} elseif ($attrs !== null) {
$el->setText($attrs);
}
if (isset($parts[1])) {
foreach (Strings::matchAll($parts[1] . ' ', '#([a-z0-9:-]+)(?:=(["\'])?(.*?)(?(2)\2|\s))?#i') as $m) {
$el->attrs[$m[1]] = $m[3] ?? true;
}
}
return $el;
}
/**
* Returns an object representing HTML text.
*/
public static function fromHtml(string $html): static
{
return (new static)->setHtml($html);
}
/**
* Returns an object representing plain text.
*/
public static function fromText(string $text): static
{
return (new static)->setText($text);
}
/**
* Converts to HTML.
*/
final public function toHtml(): string
{
return $this->render();
}
/**
* Converts to plain text.
*/
final public function toText(): string
{
return $this->getText();
}
/**
* Converts given HTML code to plain text.
*/
public static function htmlToText(string $html): string
{
return html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
/**
* Changes element's name.
*/
final public function setName(string $name, ?bool $isEmpty = null): static
{
$this->name = $name;
$this->isEmpty = $isEmpty ?? isset(static::$emptyElements[$name]);
return $this;
}
/**
* Returns element's name.
*/
final public function getName(): string
{
return $this->name;
}
/**
* Checks whether the element is a void (self-closing) element.
*/
final public function isEmpty(): bool
{
return $this->isEmpty;
}
/**
* Sets multiple attributes.
* @param array<string, mixed> $attrs
*/
public function addAttributes(array $attrs): static
{
$this->attrs = array_merge($this->attrs, $attrs);
return $this;
}
/**
* Appends value to element's attribute.
*/
public function appendAttribute(string $name, mixed $value, mixed $option = true): static
{
if (is_array($value)) {
$prev = isset($this->attrs[$name]) ? (array) $this->attrs[$name] : [];
$this->attrs[$name] = $value + $prev;
} elseif ((string) $value === '') {
$tmp = &$this->attrs[$name]; // appending empty value? -> ignore, but ensure it exists
} elseif (!isset($this->attrs[$name]) || is_array($this->attrs[$name])) { // needs array
$this->attrs[$name][$value] = $option;
} else {
$this->attrs[$name] = [$this->attrs[$name] => true, $value => $option];
}
return $this;
}
/**
* Sets element's attribute.
*/
public function setAttribute(string $name, mixed $value): static
{
$this->attrs[$name] = $value;
return $this;
}
/**
* Returns element's attribute.
*/
public function getAttribute(string $name): mixed
{
return $this->attrs[$name] ?? null;
}
/**
* Unsets element's attribute.
*/
public function removeAttribute(string $name): static
{
unset($this->attrs[$name]);
return $this;
}
/**
* Unsets element's attributes.
* @param list<string> $attributes
*/
public function removeAttributes(array $attributes): static
{
foreach ($attributes as $name) {
unset($this->attrs[$name]);
}
return $this;
}
/**
* Sets element's attribute via property assignment.
*/
final public function __set(string $name, mixed $value): void
{
$this->attrs[$name] = $value;
}
/**
* Returns element's attribute via property access.
*/
final public function &__get(string $name): mixed
{
return $this->attrs[$name];
}
/**
* Checks if element's attribute is set.
*/
final public function __isset(string $name): bool
{
return isset($this->attrs[$name]);
}
/**
* Unsets element's attribute via property unset.
*/
final public function __unset(string $name): void
{
unset($this->attrs[$name]);
}
/**
* Sets or returns element's attribute via method call.
* @param mixed[] $args
*/
final public function __call(string $m, array $args): mixed
{
$p = substr($m, 0, 3);
if ($p === 'get' || $p === 'set' || $p === 'add') {
$m = substr($m, 3);
$m[0] = $m[0] | "\x20";
if ($p === 'get') {
return $this->attrs[$m] ?? null;
} elseif ($p === 'add') {
$args[] = true;
}
}
if (count($args) === 0) { // invalid
} elseif (count($args) === 1) { // set
$this->attrs[$m] = $args[0];
} else { // add
$this->appendAttribute($m, $args[0], $args[1]);
}
return $this;
}
/**
* Special setter for element's attribute.
* @param array<string, mixed> $query
*/
final public function href(string $path, array $query = []): static
{
if ($query) {
$query = http_build_query($query, '', '&');
if ($query !== '') {
$path .= '?' . $query;
}
}
$this->attrs['href'] = $path;
return $this;
}
/**
* Setter for data-* attributes. Booleans are converted to 'true' resp. 'false'.
*/
public function data(string $name, mixed $value = null): static
{
if (func_num_args() === 1) {
$this->attrs['data'] = $name;
} else {
$this->attrs["data-$name"] = is_bool($value)
? json_encode($value)
: $value;
}
return $this;
}
/**
* Sets element's HTML content.
*/
final public function setHtml(mixed $html): static
{
$this->children = [(string) $html];
return $this;
}
/**
* Returns element's HTML content.
*/
final public function getHtml(): string
{
return implode('', $this->children);
}
/**
* Sets element's textual content.
*/
final public function setText(mixed $text): static
{
if (!$text instanceof HtmlStringable) {
$text = htmlspecialchars((string) $text, ENT_NOQUOTES, 'UTF-8');
}
$this->children = [(string) $text];
return $this;
}
/**
* Returns element's textual content.
*/
final public function getText(): string
{
return self::htmlToText($this->getHtml());
}
/**
* Adds new element's child.
*/
final public function addHtml(HtmlStringable|string $child): static
{
return $this->insert(null, $child);
}
/**
* Appends plain-text string to element content.
*/
public function addText(\Stringable|string|int|null $text): static
{
if (!$text instanceof HtmlStringable) {
$text = htmlspecialchars((string) $text, ENT_NOQUOTES, 'UTF-8');
}
return $this->insert(null, $text);
}
/**
* Creates and adds a new Html child.
* @param array<string, mixed>|string|null $attrs
*/
final public function create(string $name, array|string|null $attrs = null): static
{
$this->insert(null, $child = static::el($name, $attrs));
return $child;
}
/**
* Inserts child node.
*/
public function insert(?int $index, HtmlStringable|string $child, bool $replace = false): static
{
$child = $child instanceof self ? $child : (string) $child;
if ($index === null) { // append
$this->children[] = $child;
} else { // insert or replace
array_splice($this->children, $index, $replace ? 1 : 0, [$child]);
}
return $this;
}
/**
* Inserts (replaces) child node (\ArrayAccess implementation).
* @param ?int $index position or null for appending
* @param Html|string $child Html node or raw HTML string
*/
final public function offsetSet($index, $child): void
{
$this->insert($index, $child, replace: true);
}
/**
* Returns child node (\ArrayAccess implementation).
* @param int $index
*/
final public function offsetGet($index): self|string
{
return $this->children[$index];
}
/**
* Exists child node? (\ArrayAccess implementation).
* @param int $index
*/
final public function offsetExists($index): bool
{
return isset($this->children[$index]);
}
/**
* Removes child node (\ArrayAccess implementation).
* @param int $index
*/
public function offsetUnset($index): void
{
if (isset($this->children[$index])) {
array_splice($this->children, $index, 1);
}
}
/**
* Returns children count.
*/
final public function count(): int
{
return count($this->children);
}
/**
* Removes all children.
*/
public function removeChildren(): void
{
$this->children = [];
}
/**
* Iterates over elements.
* @return \ArrayIterator<int, self|string>
*/
final public function getIterator(): \ArrayIterator
{
return new \ArrayIterator($this->children);
}
/**
* Returns all children.
* @return array<int, self|string>
*/
final public function getChildren(): array
{
return $this->children;
}
/**
* Renders element's start tag, content and end tag. Pass indent level to enable pretty-printing.
*/
final public function render(?int $indent = null): string
{
$s = $this->startTag();
if (!$this->isEmpty) {
// add content
if ($indent !== null) {
$indent++;
}
foreach ($this->children as $child) {
if ($child instanceof self) {
$s .= $child->render($indent);
} else {
$s .= $child;
}
}
// add end tag
$s .= $this->endTag();
}
if ($indent !== null) {
return "\n" . str_repeat("\t", $indent - 1) . $s . "\n" . str_repeat("\t", max(0, $indent - 2));
}
return $s;
}
final public function __toString(): string
{
return $this->render();
}
/**
* Returns element's start tag.
*/
final public function startTag(): string
{
return $this->name
? '<' . $this->name . $this->attributes() . '>'
: '';
}
/**
* Returns element's end tag.
*/
final public function endTag(): string
{
return $this->name && !$this->isEmpty ? '</' . $this->name . '>' : '';
}
/**
* Returns element's attributes.
* @internal
*/
final public function attributes(): string
{
$s = '';
$attrs = $this->attrs;
foreach ($attrs as $key => $value) {
if ($value === null || $value === false) {
continue;
} elseif ($value === true) {
$s .= ' ' . $key;
continue;
} elseif (is_array($value)) {
if (str_starts_with($key, 'data-')) {
$value = Json::encode($value);
} else {
$tmp = null;
foreach ($value as $k => $v) {
if ($v != null) { // intentionally ==, skip nulls & empty string
// composite 'style' vs. 'others'
$tmp[] = $v === true
? $k
: (is_string($k) ? $k . ':' . $v : $v);
}
}
if ($tmp === null) {
continue;
}
$value = implode($key === 'style' || !strncmp($key, 'on', 2) ? ';' : ' ', $tmp);
}
} elseif (is_float($value)) {
$value = rtrim(rtrim(number_format($value, 10, '.', ''), '0'), '.');
} else {
$value = (string) $value;
}
$q = str_contains($value, '"') ? "'" : '"';
$s .= ' ' . $key . '=' . $q
. str_replace(
['&', $q, '<'],
['&amp;', $q === '"' ? '&quot;' : '&#39;', '<'],
$value,
)
. (str_contains($value, '`') && strpbrk($value, ' <>"\'') === false ? ' ' : '')
. $q;
}
$s = str_replace('@', '&#64;', $s);
return $s;
}
/**
* Clones all children too.
*/
public function __clone()
{
foreach ($this->children as $key => $value) {
if (is_object($value)) {
$this->children[$key] = clone $value;
}
}
}
}

View File

@ -1,853 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette;
use function is_array, is_int, is_string;
use const IMG_BMP, IMG_FLIP_BOTH, IMG_FLIP_HORIZONTAL, IMG_FLIP_VERTICAL, IMG_GIF, IMG_JPG, IMG_PNG, IMG_WEBP, PATHINFO_EXTENSION;
/**
* Basic manipulation with images. Supported types are JPEG, PNG, GIF, WEBP, AVIF and BMP.
*
* <code>
* $image = Image::fromFile('nette.jpg');
* $image->resize(150, 100);
* $image->sharpen();
* $image->send();
* </code>
*
* @method Image affine(array<int, float|int> $affine, ?array{x: int, y: int, width: int, height: int} $clip = null)
* @method void alphaBlending(bool $enable)
* @method void antialias(bool $enable)
* @method void arc(int $centerX, int $centerY, int $width, int $height, int $startAngle, int $endAngle, ImageColor $color)
* @method int colorAllocate(int $red, int $green, int $blue)
* @method int colorAllocateAlpha(int $red, int $green, int $blue, int $alpha)
* @method int colorAt(int $x, int $y)
* @method int colorClosest(int $red, int $green, int $blue)
* @method int colorClosestAlpha(int $red, int $green, int $blue, int $alpha)
* @method int colorClosestHWB(int $red, int $green, int $blue)
* @method void colorDeallocate(int $color)
* @method int colorExact(int $red, int $green, int $blue)
* @method int colorExactAlpha(int $red, int $green, int $blue, int $alpha)
* @method void colorMatch(Image $image2)
* @method int colorResolve(int $red, int $green, int $blue)
* @method int colorResolveAlpha(int $red, int $green, int $blue, int $alpha)
* @method void colorSet(int $index, int $red, int $green, int $blue, int $alpha = 0)
* @method array{red: int, green: int, blue: int, alpha: int} colorsForIndex(int $color)
* @method int colorsTotal()
* @method int colorTransparent(?int $color = null)
* @method void convolution(array<int, array<int, float>> $matrix, float $div, float $offset)
* @method void copy(Image $src, int $dstX, int $dstY, int $srcX, int $srcY, int $srcW, int $srcH)
* @method void copyMerge(Image $src, int $dstX, int $dstY, int $srcX, int $srcY, int $srcW, int $srcH, int $pct)
* @method void copyMergeGray(Image $src, int $dstX, int $dstY, int $srcX, int $srcY, int $srcW, int $srcH, int $pct)
* @method void copyResampled(Image $src, int $dstX, int $dstY, int $srcX, int $srcY, int $dstW, int $dstH, int $srcW, int $srcH)
* @method void copyResized(Image $src, int $dstX, int $dstY, int $srcX, int $srcY, int $dstW, int $dstH, int $srcW, int $srcH)
* @method Image cropAuto(int $mode = 0, float $threshold = .5, ?ImageColor $color = null)
* @method void ellipse(int $centerX, int $centerY, int $width, int $height, ImageColor $color)
* @method void fill(int $x, int $y, ImageColor $color)
* @method void filledArc(int $centerX, int $centerY, int $width, int $height, int $startAngle, int $endAngle, ImageColor $color, int $style)
* @method void filledEllipse(int $centerX, int $centerY, int $width, int $height, ImageColor $color)
* @method void filledPolygon(array<int, int> $points, ImageColor $color)
* @method void filledRectangle(int $x1, int $y1, int $x2, int $y2, ImageColor $color)
* @method void fillToBorder(int $x, int $y, ImageColor $borderColor, ImageColor $color)
* @method void filter(int $filter, ...$args)
* @method void flip(int $mode)
* @method array<int, int> ftText(float $size, float $angle, int $x, int $y, ImageColor $color, string $fontFile, string $text, array<string, mixed> $options = [])
* @method void gammaCorrect(float $inputgamma, float $outputgamma)
* @method array{int, int, int, int} getClip()
* @method int getInterpolation()
* @method int interlace(?bool $enable = null)
* @method bool isTrueColor()
* @method void layerEffect(int $effect)
* @method void line(int $x1, int $y1, int $x2, int $y2, ImageColor $color)
* @method void openPolygon(array<int, int> $points, ImageColor $color)
* @method void paletteCopy(Image $source)
* @method void paletteToTrueColor()
* @method void polygon(array<int, int> $points, ImageColor $color)
* @method void rectangle(int $x1, int $y1, int $x2, int $y2, ImageColor $color)
* @method mixed resolution(?int $resolutionX = null, ?int $resolutionY = null)
* @method Image rotate(float $angle, ImageColor $backgroundColor)
* @method void saveAlpha(bool $enable)
* @method Image scale(int $newWidth, int $newHeight = -1, int $mode = 3)
* @method void setBrush(Image $brush)
* @method void setClip(int $x1, int $y1, int $x2, int $y2)
* @method void setInterpolation(int $method = 3)
* @method void setPixel(int $x, int $y, ImageColor $color)
* @method void setStyle(array<int, int> $style)
* @method void setThickness(int $thickness)
* @method void setTile(Image $tile)
* @method void trueColorToPalette(bool $dither, int $ncolors)
* @method array<int, int> ttfText(float $size, float $angle, int $x, int $y, ImageColor $color, string $fontfile, string $text, array<string, mixed> $options = [])
* @property-read positive-int $width
* @property-read positive-int $height
* @property-read \GdImage $imageResource
*/
class Image
{
use Nette\SmartObject;
/** Prevent from getting resized to a bigger size than the original */
public const ShrinkOnly = 0b0001;
/** Resizes to a specified width and height without keeping aspect ratio */
public const Stretch = 0b0010;
/** Resizes to fit into a specified width and height and preserves aspect ratio */
public const OrSmaller = 0b0000;
/** Resizes while bounding the smaller dimension to the specified width or height and preserves aspect ratio */
public const OrBigger = 0b0100;
/** Resizes to the smallest possible size to completely cover specified width and height and reserves aspect ratio */
public const Cover = 0b1000;
/** @deprecated use Image::ShrinkOnly */
public const SHRINK_ONLY = self::ShrinkOnly;
/** @deprecated use Image::Stretch */
public const STRETCH = self::Stretch;
/** @deprecated use Image::OrSmaller */
public const FIT = self::OrSmaller;
/** @deprecated use Image::OrBigger */
public const FILL = self::OrBigger;
/** @deprecated use Image::Cover */
public const EXACT = self::Cover;
/** @deprecated use Image::EmptyGIF */
public const EMPTY_GIF = self::EmptyGIF;
/** image types */
public const
JPEG = ImageType::JPEG,
PNG = ImageType::PNG,
GIF = ImageType::GIF,
WEBP = ImageType::WEBP,
AVIF = ImageType::AVIF,
BMP = ImageType::BMP;
public const EmptyGIF = "GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\x00\x00\x00!\xf9\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;";
private const Formats = [ImageType::JPEG => 'jpeg', ImageType::PNG => 'png', ImageType::GIF => 'gif', ImageType::WEBP => 'webp', ImageType::AVIF => 'avif', ImageType::BMP => 'bmp'];
private const Sentinel = "\0";
private \GdImage $image;
/**
* Returns RGB color (0..255) and transparency (0..127).
* @deprecated use ImageColor::rgb()
* @return array{red: int, green: int, blue: int, alpha: int}
*/
public static function rgb(int $red, int $green, int $blue, int $transparency = 0): array
{
return [
'red' => max(0, min(255, $red)),
'green' => max(0, min(255, $green)),
'blue' => max(0, min(255, $blue)),
'alpha' => max(0, min(127, $transparency)),
];
}
/**
* Reads an image from a file and returns its type in $type. If $warnings is passed, recoverable decoder
* warnings are returned in it instead of being raised as a PHP warning.
* @param-out ?string $warnings
* @throws Nette\NotSupportedException if gd extension is not loaded
* @throws UnknownImageFileException if file not found or file type is not known
*/
public static function fromFile(string $file, ?int &$type = null, ?string &$warnings = self::Sentinel): static
{
self::ensureExtension();
$type = self::detectTypeFromFile($file);
if (!$type) {
throw new UnknownImageFileException(is_file($file) ? "Unknown type of file '$file'." : "File '$file' not found.");
}
return self::invokeSafe('imagecreatefrom' . self::Formats[$type], $file, "Unable to open file '$file'.", __METHOD__, $warnings);
}
/**
* Reads an image from a string and returns its type in $type. If $warnings is passed, recoverable decoder
* warnings are returned in it instead of being raised as a PHP warning.
* @param-out ?string $warnings
* @throws Nette\NotSupportedException if gd extension is not loaded
* @throws ImageException
*/
public static function fromString(string $s, ?int &$type = null, ?string &$warnings = self::Sentinel): static
{
self::ensureExtension();
$type = self::detectTypeFromString($s);
if (!$type) {
throw new UnknownImageFileException('Unknown type of image.');
}
return self::invokeSafe('imagecreatefromstring', $s, 'Unable to open image from string.', __METHOD__, $warnings);
}
/** @param callable-string $func */
private static function invokeSafe(
string $func,
string $arg,
string $message,
string $callee,
?string &$warnings = self::Sentinel,
): static
{
$errors = [];
$res = Callback::invokeSafe($func, [$arg], function (string $message) use (&$errors): void {
$errors[] = $message;
});
$raiseWarning = $warnings === self::Sentinel;
$warnings = $errors ? implode(', ', $errors) : null;
if (!$res) {
throw new ImageException($message . ' Errors: ' . $warnings);
} elseif ($errors && $raiseWarning) {
trigger_error($callee . '(): ' . $warnings, E_USER_WARNING);
}
return new static($res);
}
/**
* Creates a new true color image of the given dimensions. The default color is black.
* @param positive-int $width
* @param positive-int $height
* @param ImageColor|array{red: int, green: int, blue: int, alpha?: int}|null $color
* @throws Nette\NotSupportedException if gd extension is not loaded
*/
public static function fromBlank(int $width, int $height, ImageColor|array|null $color = null): static
{
self::ensureExtension();
if ($width < 1 || $height < 1) {
throw new Nette\InvalidArgumentException('Image width and height must be greater than zero.');
}
$image = new static(imagecreatetruecolor($width, $height));
if ($color) {
$image->alphaBlending(false);
$image->filledRectangle(0, 0, $width - 1, $height - 1, self::normalizeColor($color));
$image->alphaBlending(true);
}
return $image;
}
/**
* Returns the type of image from file.
* @param-out ?int $width
* @param-out ?int $height
* @return ?ImageType::*
*/
public static function detectTypeFromFile(string $file, mixed &$width = null, mixed &$height = null): ?int
{
[$width, $height, $type] = Helpers::falseToNull(@getimagesize($file)); // @ - files smaller than 12 bytes causes read error
return $type && isset(self::Formats[$type]) ? $type : null;
}
/**
* Returns the type of image from string.
* @param-out ?int $width
* @param-out ?int $height
* @return ?ImageType::*
*/
public static function detectTypeFromString(string $s, mixed &$width = null, mixed &$height = null): ?int
{
[$width, $height, $type] = Helpers::falseToNull(@getimagesizefromstring($s)); // @ - strings smaller than 12 bytes causes read error
return $type && isset(self::Formats[$type]) ? $type : null;
}
/**
* Returns the file extension for the given image type.
* @param ImageType::* $type
* @return value-of<self::Formats>
*/
public static function typeToExtension(int $type): string
{
if (!isset(self::Formats[$type])) {
throw new Nette\InvalidArgumentException("Unsupported image type '$type'.");
}
return self::Formats[$type];
}
/**
* Returns the image type for given file extension.
* @return ImageType::*
*/
public static function extensionToType(string $extension): int
{
$extensions = array_flip(self::Formats) + ['jpg' => ImageType::JPEG];
$extension = strtolower($extension);
if (!isset($extensions[$extension])) {
throw new Nette\InvalidArgumentException("Unsupported file extension '$extension'.");
}
return $extensions[$extension];
}
/**
* Returns the mime type for the given image type.
* @param ImageType::* $type
*/
public static function typeToMimeType(int $type): string
{
return 'image/' . self::typeToExtension($type);
}
/**
* Checks whether the given image type is supported by the GD extension.
* @param ImageType::* $type
*/
public static function isTypeSupported(int $type): bool
{
self::ensureExtension();
return (bool) (imagetypes() & match ($type) {
ImageType::JPEG => IMG_JPG,
ImageType::PNG => IMG_PNG,
ImageType::GIF => IMG_GIF,
ImageType::WEBP => IMG_WEBP,
ImageType::AVIF => 256, // IMG_AVIF,
ImageType::BMP => IMG_BMP,
default => 0,
});
}
/**
* Returns list of image types supported by the GD extension.
* @return ImageType::*[]
*/
public static function getSupportedTypes(): array
{
self::ensureExtension();
$flag = imagetypes();
return array_filter([
$flag & IMG_GIF ? ImageType::GIF : null,
$flag & IMG_JPG ? ImageType::JPEG : null,
$flag & IMG_PNG ? ImageType::PNG : null,
$flag & IMG_WEBP ? ImageType::WEBP : null,
$flag & 256 ? ImageType::AVIF : null, // IMG_AVIF
$flag & IMG_BMP ? ImageType::BMP : null,
]);
}
/**
* Wraps GD image.
*/
public function __construct(\GdImage $image)
{
$this->setImageResource($image);
imagesavealpha($image, true);
}
/**
* Returns image width.
* @return positive-int
*/
public function getWidth(): int
{
return imagesx($this->image);
}
/**
* Returns image height.
* @return positive-int
*/
public function getHeight(): int
{
return imagesy($this->image);
}
/**
* Sets image resource.
*/
protected function setImageResource(\GdImage $image): static
{
$this->image = $image;
return $this;
}
/**
* Returns image GD resource.
*/
public function getImageResource(): \GdImage
{
return $this->image;
}
/**
* Scales an image. Width and height accept pixels or percent.
* @param int-mask-of<self::OrSmaller|self::OrBigger|self::Stretch|self::Cover|self::ShrinkOnly> $mode
*/
public function resize(int|string|null $width, int|string|null $height, int $mode = self::OrSmaller): static
{
if ($mode & self::Cover) {
if ($width === null || $height === null) {
throw new Nette\InvalidArgumentException('Both width and height must be set for Cover mode.');
}
return $this->resize($width, $height, self::OrBigger)->crop('50%', '50%', $width, $height);
}
[$newWidth, $newHeight] = static::calculateSize($this->getWidth(), $this->getHeight(), $width, $height, $mode);
if ($newWidth !== $this->getWidth() || $newHeight !== $this->getHeight()) { // resize
$newImage = static::fromBlank($newWidth, $newHeight, ImageColor::rgb(0, 0, 0, 0))->getImageResource();
imagecopyresampled(
$newImage,
$this->image,
0,
0,
0,
0,
$newWidth,
$newHeight,
$this->getWidth(),
$this->getHeight(),
);
$this->image = $newImage;
}
if ($width < 0 || $height < 0) {
imageflip($this->image, $width < 0 ? ($height < 0 ? IMG_FLIP_BOTH : IMG_FLIP_HORIZONTAL) : IMG_FLIP_VERTICAL);
}
return $this;
}
/**
* Calculates dimensions of resized image. Width and height accept pixels or percent.
* @param int-mask-of<self::OrSmaller|self::OrBigger|self::Stretch|self::Cover|self::ShrinkOnly> $mode
* @return array{int<1, max>, int<1, max>}
*/
public static function calculateSize(
int $srcWidth,
int $srcHeight,
int|string|null $newWidth,
int|string|null $newHeight,
int $mode = self::OrSmaller,
): array
{
if ($newWidth === null) {
} elseif (self::isPercent($newWidth)) {
$newWidth = (int) round($srcWidth / 100 * abs($newWidth));
$percents = true;
} else {
$newWidth = abs($newWidth);
}
if ($newHeight === null) {
} elseif (self::isPercent($newHeight)) {
$newHeight = (int) round($srcHeight / 100 * abs($newHeight));
$mode |= empty($percents) ? 0 : self::Stretch;
} else {
$newHeight = abs($newHeight);
}
if ($mode & self::Stretch) { // non-proportional
if (!$newWidth || !$newHeight) {
throw new Nette\InvalidArgumentException('For stretching must be both width and height specified.');
}
if ($mode & self::ShrinkOnly) {
$newWidth = min($srcWidth, $newWidth);
$newHeight = min($srcHeight, $newHeight);
}
} else { // proportional
if (!$newWidth && !$newHeight) {
throw new Nette\InvalidArgumentException('At least width or height must be specified.');
}
$scale = [];
if ($newWidth > 0) { // fit width
$scale[] = $newWidth / $srcWidth;
}
if ($newHeight > 0) { // fit height
$scale[] = $newHeight / $srcHeight;
}
if ($mode & self::OrBigger) {
$scale = [max($scale ?: [1])];
}
if ($mode & self::ShrinkOnly) {
$scale[] = 1;
}
$scale = min($scale ?: [1]);
$newWidth = (int) round($srcWidth * $scale);
$newHeight = (int) round($srcHeight * $scale);
}
return [max((int) $newWidth, 1), max((int) $newHeight, 1)];
}
/**
* Crops image. Arguments accepts pixels or percent.
*/
public function crop(int|string $left, int|string $top, int|string $width, int|string $height): static
{
[$r['x'], $r['y'], $r['width'], $r['height']]
= static::calculateCutout($this->getWidth(), $this->getHeight(), $left, $top, $width, $height);
if (gd_info()['GD Version'] === 'bundled (2.1.0 compatible)') {
$this->image = imagecrop($this->image, $r);
imagesavealpha($this->image, true);
} else {
$newImage = static::fromBlank(max(1, $r['width']), max(1, $r['height']), ImageColor::rgb(0, 0, 0, 0))->getImageResource();
imagecopy($newImage, $this->image, 0, 0, $r['x'], $r['y'], $r['width'], $r['height']);
$this->image = $newImage;
}
return $this;
}
/**
* Calculates dimensions of cutout in image. Arguments accepts pixels or percent.
* @return array{int, int, int, int}
*/
public static function calculateCutout(
int $srcWidth,
int $srcHeight,
int|string $left,
int|string $top,
int|string $newWidth,
int|string $newHeight,
): array
{
$newWidth = (int) (self::isPercent($newWidth) ? round($srcWidth / 100 * $newWidth) : $newWidth);
$newHeight = (int) (self::isPercent($newHeight) ? round($srcHeight / 100 * $newHeight) : $newHeight);
$left = (int) (self::isPercent($left) ? round(($srcWidth - $newWidth) / 100 * $left) : $left);
$top = (int) (self::isPercent($top) ? round(($srcHeight - $newHeight) / 100 * $top) : $top);
if ($left < 0) {
$newWidth += $left;
$left = 0;
}
if ($top < 0) {
$newHeight += $top;
$top = 0;
}
$newWidth = min($newWidth, $srcWidth - $left);
$newHeight = min($newHeight, $srcHeight - $top);
return [$left, $top, $newWidth, $newHeight];
}
/**
* Sharpens image a little bit.
*/
public function sharpen(): static
{
imageconvolution($this->image, [ // my magic numbers ;)
[-1, -1, -1],
[-1, 24, -1],
[-1, -1, -1],
], 16, 0);
return $this;
}
/**
* Puts another image into this image. Left and top accepts pixels or percent.
* @param int<0, 100> $opacity 0..100
*/
public function place(self $image, int|string $left = 0, int|string $top = 0, int $opacity = 100): static
{
$opacity = max(0, min(100, $opacity));
if ($opacity === 0) {
return $this;
}
$width = $image->getWidth();
$height = $image->getHeight();
$left = (int) (self::isPercent($left) ? round(($this->getWidth() - $width) / 100 * $left) : $left);
$top = (int) (self::isPercent($top) ? round(($this->getHeight() - $height) / 100 * $top) : $top);
$output = $input = $image->image;
if ($opacity < 100) {
$tbl = [];
for ($i = 0; $i < 128; $i++) {
$tbl[$i] = round(127 - (127 - $i) * $opacity / 100);
}
$output = imagecreatetruecolor($width, $height);
imagealphablending($output, false);
if (!$image->isTrueColor()) {
$input = $output;
imagefilledrectangle($output, 0, 0, $width, $height, (int) imagecolorallocatealpha($output, 0, 0, 0, 127));
imagecopy($output, $image->image, 0, 0, 0, 0, $width, $height);
}
for ($x = 0; $x < $width; $x++) {
for ($y = 0; $y < $height; $y++) {
$c = \imagecolorat($input, $x, $y);
$c = ($c & 0xFFFFFF) + ($tbl[$c >> 24] << 24);
\imagesetpixel($output, $x, $y, $c);
}
}
imagealphablending($output, true);
}
imagecopy(
$this->image,
$output,
$left,
$top,
0,
0,
$width,
$height,
);
return $this;
}
/**
* Calculates the bounding box for a TrueType text. Returns keys left, top, width and height.
* @param array<string, mixed> $options
* @return array{left: int, top: int, width: int, height: int}
*/
public static function calculateTextBox(
string $text,
string $fontFile,
float $size,
float $angle = 0,
array $options = [],
): array
{
self::ensureExtension();
$box = imagettfbbox($size, $angle, $fontFile, $text, $options);
return [
'left' => $minX = min([$box[0], $box[2], $box[4], $box[6]]),
'top' => $minY = min([$box[1], $box[3], $box[5], $box[7]]),
'width' => max([$box[0], $box[2], $box[4], $box[6]]) - $minX + 1,
'height' => max([$box[1], $box[3], $box[5], $box[7]]) - $minY + 1,
];
}
/**
* Draws a rectangle using top-left coordinates and dimensions instead of two corner coordinates.
*/
public function rectangleWH(int $x, int $y, int $width, int $height, ImageColor $color): void
{
if ($width !== 0 && $height !== 0) {
$this->rectangle($x, $y, $x + $width + ($width > 0 ? -1 : 1), $y + $height + ($height > 0 ? -1 : 1), $color);
}
}
/**
* Draws a filled rectangle using top-left coordinates and dimensions instead of two corner coordinates.
*/
public function filledRectangleWH(int $x, int $y, int $width, int $height, ImageColor $color): void
{
if ($width !== 0 && $height !== 0) {
$this->filledRectangle($x, $y, $x + $width + ($width > 0 ? -1 : 1), $y + $height + ($height > 0 ? -1 : 1), $color);
}
}
/**
* Saves image to the file. Quality is in the range 0..100 for JPEG (default 85), WEBP (default 80) and AVIF (default 30) and 0..9 for PNG (default 9).
* @param ?ImageType::* $type
* @throws ImageException
*/
public function save(string $file, ?int $quality = null, ?int $type = null): void
{
$type ??= self::extensionToType(pathinfo($file, PATHINFO_EXTENSION));
$this->output($type, $quality, $file);
}
/**
* Outputs image to string. Quality is in the range 0..100 for JPEG (default 85), WEBP (default 80) and AVIF (default 30) and 0..9 for PNG (default 9).
* @param ImageType::* $type
*/
public function toString(int $type = ImageType::JPEG, ?int $quality = null): string
{
return Helpers::capture(function () use ($type, $quality): void {
$this->output($type, $quality);
});
}
/**
* Outputs image to string.
*/
public function __toString(): string
{
return $this->toString();
}
/**
* Outputs image to browser. Quality is in the range 0..100 for JPEG (default 85), WEBP (default 80) and AVIF (default 30) and 0..9 for PNG (default 9).
* @param ImageType::* $type
* @throws ImageException
*/
public function send(int $type = ImageType::JPEG, ?int $quality = null): void
{
header('Content-Type: ' . self::typeToMimeType($type));
$this->output($type, $quality);
}
/**
* Outputs image to browser or file.
* @param ImageType::* $type
* @throws ImageException
*/
private function output(int $type, ?int $quality, ?string $file = null): void
{
[$defQuality, $min, $max] = match ($type) {
ImageType::JPEG => [85, 0, 100],
ImageType::PNG => [9, 0, 9],
ImageType::GIF => [null, null, null],
ImageType::WEBP => [80, 0, 100],
ImageType::AVIF => [30, 0, 100],
ImageType::BMP => [null, null, null],
default => throw new Nette\InvalidArgumentException("Unsupported image type '$type'."),
};
$args = [$this->image, $file];
if ($defQuality !== null) {
$args[] = $quality === null ? $defQuality : max($min, min($max, $quality));
}
Callback::invokeSafe('image' . self::Formats[$type], $args, function (string $message) use ($file): void {
if ($file !== null) {
@unlink($file);
}
throw new ImageException($message);
});
}
/**
* Call to undefined method.
* @param mixed[] $args
* @throws Nette\MemberAccessException
*/
public function __call(string $name, array $args): mixed
{
$function = 'image' . $name;
if (!function_exists($function)) {
ObjectHelpers::strictCall(static::class, $name);
}
foreach ($args as $key => $value) {
if ($value instanceof self) {
$args[$key] = $value->getImageResource();
} elseif ($value instanceof ImageColor || (is_array($value) && isset($value['red']))) {
/** @var ImageColor|array{red: int, green: int, blue: int, alpha?: int} $value */
$args[$key] = $this->resolveColor($value);
}
}
$res = $function($this->image, ...$args);
return $res instanceof \GdImage
? $this->setImageResource($res)
: $res;
}
public function __clone()
{
ob_start(fn() => '');
imagepng($this->image, null, 0);
$this->setImageResource(imagecreatefromstring(ob_get_clean()) ?: throw new Nette\ShouldNotHappenException);
}
/** @param-out int|float $num */
private static function isPercent(int|string &$num): bool
{
if (is_string($num) && str_ends_with($num, '%')) {
$num = (float) substr($num, 0, -1);
return true;
} elseif (is_int($num) || $num === (string) (int) $num) {
$num = (int) $num;
return false;
}
throw new Nette\InvalidArgumentException("Expected dimension in int|string, '$num' given.");
}
/**
* Prevents serialization.
*/
public function __serialize(): array
{
throw new Nette\NotSupportedException('You cannot serialize or unserialize ' . self::class . ' instances.');
}
/**
* Resolves a color to a GD color index for the current image.
* @param ImageColor|array{red: int, green: int, blue: int, alpha?: int} $color
*/
public function resolveColor(ImageColor|array $color): int
{
$color = self::normalizeColor($color)->toRGBA();
return imagecolorallocatealpha($this->image, ...$color) ?: imagecolorresolvealpha($this->image, ...$color);
}
/** @param ImageColor|array{red: int, green: int, blue: int, alpha?: int} $color */
private static function normalizeColor(ImageColor|array $color): ImageColor
{
return $color instanceof ImageColor
? $color
: ImageColor::rgb(
$color['red'],
$color['green'],
$color['blue'],
(127 - ($color['alpha'] ?? 0)) / 127,
);
}
private static function ensureExtension(): void
{
if (!extension_loaded('gd')) {
throw new Nette\NotSupportedException('PHP extension GD is not loaded.');
}
}
}

View File

@ -1,81 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette;
use function hexdec, ltrim, max, min, round, strlen;
/**
* Represent RGB color (0..255) with opacity (0..1).
*/
class ImageColor
{
/**
* Creates a color from RGB components (0..255) and opacity (0..1).
*/
public static function rgb(int $red, int $green, int $blue, float $opacity = 1): self
{
return new self($red, $green, $blue, $opacity);
}
/**
* Accepts formats #RRGGBB, #RRGGBBAA, #RGB, #RGBA
*/
public static function hex(string $hex): self
{
$hex = ltrim($hex, '#');
$len = strlen($hex);
if ($len === 3 || $len === 4) {
return new self(
(int) hexdec($hex[0]) * 17,
(int) hexdec($hex[1]) * 17,
(int) hexdec($hex[2]) * 17,
(int) hexdec($hex[3] ?? 'F') * 17 / 255,
);
} elseif ($len === 6 || $len === 8) {
return new self(
(int) hexdec($hex[0] . $hex[1]),
(int) hexdec($hex[2] . $hex[3]),
(int) hexdec($hex[4] . $hex[5]),
(int) hexdec(($hex[6] ?? 'F') . ($hex[7] ?? 'F')) / 255,
);
} else {
throw new Nette\InvalidArgumentException('Invalid hex color format.');
}
}
private function __construct(
public int $red,
public int $green,
public int $blue,
public float $opacity = 1,
) {
$this->red = max(0, min(255, $red));
$this->green = max(0, min(255, $green));
$this->blue = max(0, min(255, $blue));
$this->opacity = max(0, min(1, $opacity));
}
/**
* Returns GD-compatible color array [R, G, B, alpha].
* @return array{int<0, 255>, int<0, 255>, int<0, 255>, int<0, 127>}
*/
public function toRGBA(): array
{
return [
max(0, min(255, $this->red)),
max(0, min(255, $this->green)),
max(0, min(255, $this->blue)),
max(0, min(127, (int) round(127 - $this->opacity * 127))),
];
}
}

View File

@ -1,25 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use const IMAGETYPE_BMP, IMAGETYPE_GIF, IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_WEBP;
/**
* Type of image file.
*/
/*enum*/ final class ImageType
{
public const
JPEG = IMAGETYPE_JPEG,
PNG = IMAGETYPE_PNG,
GIF = IMAGETYPE_GIF,
WEBP = IMAGETYPE_WEBP,
AVIF = 19, // IMAGETYPE_AVIF,
BMP = IMAGETYPE_BMP;
}

View File

@ -1,271 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette;
use function is_array;
/**
* Utilities for iterables.
*/
final class Iterables
{
use Nette\StaticClass;
/**
* Tests for the presence of value.
* @param iterable<mixed> $iterable
*/
public static function contains(iterable $iterable, mixed $value): bool
{
foreach ($iterable as $v) {
if ($v === $value) {
return true;
}
}
return false;
}
/**
* Tests for the presence of key.
* @param iterable<mixed> $iterable
*/
public static function containsKey(iterable $iterable, mixed $key): bool
{
foreach ($iterable as $k => $v) {
if ($k === $key) {
return true;
}
}
return false;
}
/**
* Returns the first item (matching the specified predicate if given). If there is no such item, it returns result of invoking $else or null.
* @template K
* @template V
* @template E
* @param iterable<K, V> $iterable
* @param ?callable(V, K, iterable<K, V>): bool $predicate
* @param ?callable(): E $else
* @return ($else is null ? ?V : V|E)
*/
public static function first(iterable $iterable, ?callable $predicate = null, ?callable $else = null): mixed
{
foreach ($iterable as $k => $v) {
if (!$predicate || $predicate($v, $k, $iterable)) {
return $v;
}
}
return $else ? $else() : null;
}
/**
* Returns the key of first item (matching the specified predicate if given). If there is no such item, it returns result of invoking $else or null.
* @template K
* @template V
* @template E
* @param iterable<K, V> $iterable
* @param ?callable(V, K, iterable<K, V>): bool $predicate
* @param ?callable(): E $else
* @return ($else is null ? ?K : K|E)
*/
public static function firstKey(iterable $iterable, ?callable $predicate = null, ?callable $else = null): mixed
{
foreach ($iterable as $k => $v) {
if (!$predicate || $predicate($v, $k, $iterable)) {
return $k;
}
}
return $else ? $else() : null;
}
/**
* Tests whether at least one element in the iterable passes the test implemented by the provided function.
* @template K
* @template V
* @param iterable<K, V> $iterable
* @param callable(V, K, iterable<K, V>): bool $predicate
*/
public static function some(iterable $iterable, callable $predicate): bool
{
foreach ($iterable as $k => $v) {
if ($predicate($v, $k, $iterable)) {
return true;
}
}
return false;
}
/**
* Tests whether all elements in the iterable pass the test implemented by the provided function.
* @template K
* @template V
* @param iterable<K, V> $iterable
* @param callable(V, K, iterable<K, V>): bool $predicate
*/
public static function every(iterable $iterable, callable $predicate): bool
{
foreach ($iterable as $k => $v) {
if (!$predicate($v, $k, $iterable)) {
return false;
}
}
return true;
}
/**
* Returns a generator that yields only elements matching the given $predicate. Maintains original keys.
* @template K
* @template V
* @param iterable<K, V> $iterable
* @param callable(V, K, iterable<K, V>): bool $predicate
* @return \Generator<K, V>
*/
public static function filter(iterable $iterable, callable $predicate): \Generator
{
foreach ($iterable as $k => $v) {
if ($predicate($v, $k, $iterable)) {
yield $k => $v;
}
}
}
/**
* Returns a generator that transforms values by calling $transformer. Maintains original keys.
* @template K
* @template V
* @template R
* @param iterable<K, V> $iterable
* @param callable(V, K, iterable<K, V>): R $transformer
* @return \Generator<K, R>
*/
public static function map(iterable $iterable, callable $transformer): \Generator
{
foreach ($iterable as $k => $v) {
yield $k => $transformer($v, $k, $iterable);
}
}
/**
* Returns a generator that transforms keys and values by calling $transformer. If it returns null, the element is skipped.
* @template K
* @template V
* @template ResK
* @template ResV
* @param iterable<K, V> $iterable
* @param callable(V, K, iterable<K, V>): ?array{ResK, ResV} $transformer
* @return \Generator<ResK, ResV>
*/
public static function mapWithKeys(iterable $iterable, callable $transformer): \Generator
{
foreach ($iterable as $k => $v) {
$pair = $transformer($v, $k, $iterable);
if ($pair) {
yield $pair[0] => $pair[1];
}
}
}
/**
* Creates a repeatable iterator from a factory function.
* The factory is called every time the iterator is iterated.
* @template K
* @template V
* @param callable(): iterable<K, V> $factory
* @return \IteratorAggregate<K, V>
*/
public static function repeatable(callable $factory): \IteratorAggregate
{
return new class ($factory(...)) implements \IteratorAggregate {
public function __construct(
/** @var \Closure(): iterable<mixed, mixed> */
private \Closure $factory,
) {
}
public function getIterator(): \Iterator
{
return Iterables::toIterator(($this->factory)());
}
};
}
/**
* Wraps around iterator and caches its keys and values during iteration.
* This allows the data to be re-iterated multiple times.
* @template K
* @template V
* @param iterable<K, V> $iterable
* @return \IteratorAggregate<K, V>
*/
public static function memoize(iterable $iterable): \IteratorAggregate
{
return new class (self::toIterator($iterable)) implements \IteratorAggregate {
public function __construct(
private readonly \Iterator $iterator,
/** @var array<array{mixed, mixed}> */
private array $cache = [],
) {
}
public function getIterator(): \Generator
{
if (!$this->cache) {
$this->iterator->rewind();
}
$i = 0;
while (true) {
if (isset($this->cache[$i])) {
[$k, $v] = $this->cache[$i];
} elseif ($this->iterator->valid()) {
$k = $this->iterator->key();
$v = $this->iterator->current();
$this->iterator->next();
$this->cache[$i] = [$k, $v];
} else {
break;
}
yield $k => $v;
$i++;
}
}
};
}
/**
* Creates an iterator from anything that is iterable.
* @template K
* @template V
* @param iterable<K, V> $iterable
* @return \Iterator<K, V>
*/
public static function toIterator(iterable $iterable): \Iterator
{
return match (true) {
$iterable instanceof \Iterator => $iterable,
$iterable instanceof \IteratorAggregate => self::toIterator($iterable->getIterator()),
is_array($iterable) => new \ArrayIterator($iterable),
default => throw new Nette\ShouldNotHappenException,
};
}
}

View File

@ -1,84 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette;
use function defined, is_int, json_decode, json_encode, json_last_error, json_last_error_msg;
use const JSON_BIGINT_AS_STRING, JSON_FORCE_OBJECT, JSON_HEX_AMP, JSON_HEX_APOS, JSON_HEX_QUOT, JSON_HEX_TAG, JSON_OBJECT_AS_ARRAY, JSON_PRESERVE_ZERO_FRACTION, JSON_PRETTY_PRINT, JSON_UNESCAPED_SLASHES, JSON_UNESCAPED_UNICODE;
/**
* JSON encoder and decoder.
*/
final class Json
{
use Nette\StaticClass;
/** @deprecated use Json::decode(..., forceArrays: true) */
public const FORCE_ARRAY = JSON_OBJECT_AS_ARRAY;
/** @deprecated use Json::encode(..., pretty: true) */
public const PRETTY = JSON_PRETTY_PRINT;
/** @deprecated use Json::encode(..., asciiSafe: true) */
public const ESCAPE_UNICODE = 1 << 19;
/**
* Converts value to JSON format. Use $pretty for formatted output, $asciiSafe for ASCII-only output,
* $htmlSafe for HTML-safe output, and $forceObjects to encode non-associative arrays as objects.
* @throws JsonException
*/
public static function encode(
mixed $value,
bool|int $pretty = false,
bool $asciiSafe = false,
bool $htmlSafe = false,
bool $forceObjects = false,
): string
{
if (is_int($pretty)) { // back compatibility
$flags = ($pretty & self::ESCAPE_UNICODE ? 0 : JSON_UNESCAPED_UNICODE) | ($pretty & ~self::ESCAPE_UNICODE);
} else {
$flags = ($asciiSafe ? 0 : JSON_UNESCAPED_UNICODE)
| ($pretty ? JSON_PRETTY_PRINT : 0)
| ($forceObjects ? JSON_FORCE_OBJECT : 0)
| ($htmlSafe ? JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG : 0);
}
$flags |= JSON_UNESCAPED_SLASHES
| (defined('JSON_PRESERVE_ZERO_FRACTION') ? JSON_PRESERVE_ZERO_FRACTION : 0); // since PHP 5.6.6 & PECL JSON-C 1.3.7
$json = json_encode($value, $flags);
if ($error = json_last_error()) {
throw new JsonException(json_last_error_msg(), $error);
}
return $json;
}
/**
* Decodes a JSON string to a PHP value. Use $forceArrays to decode objects as arrays.
* @throws JsonException
*/
public static function decode(string $json, bool|int $forceArrays = false): mixed
{
$flags = is_int($forceArrays) // back compatibility
? $forceArrays
: ($forceArrays ? JSON_OBJECT_AS_ARRAY : 0);
$flags |= JSON_BIGINT_AS_STRING;
$value = json_decode($json, flags: $flags);
if ($error = json_last_error()) {
throw new JsonException(json_last_error_msg(), $error);
}
return $value;
}
}

View File

@ -1,229 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette;
use Nette\MemberAccessException;
use function array_filter, array_merge, array_pop, array_unique, get_class_methods, get_parent_class, implode, is_a, levenshtein, method_exists, preg_match_all, preg_replace, strlen, ucfirst;
use const PREG_SET_ORDER, SORT_REGULAR;
/**
* Nette\SmartObject helpers.
* @internal
*/
final class ObjectHelpers
{
use Nette\StaticClass;
/**
* @return never
* @throws MemberAccessException
*/
public static function strictGet(string $class, string $name): void
{
$rc = new \ReflectionClass($class);
$hint = self::getSuggestion(array_merge(
array_filter($rc->getProperties(\ReflectionProperty::IS_PUBLIC), fn($p) => !$p->isStatic()),
self::parseFullDoc($rc, '~^[ \t*]*@property(?:-read)?[ \t]+(?:\S+[ \t]+)??\$(\w+)~m'),
), $name);
throw new MemberAccessException("Cannot read an undeclared property $class::\$$name" . ($hint ? ", did you mean \$$hint?" : '.'));
}
/**
* @return never
* @throws MemberAccessException
*/
public static function strictSet(string $class, string $name): void
{
$rc = new \ReflectionClass($class);
$hint = self::getSuggestion(array_merge(
array_filter($rc->getProperties(\ReflectionProperty::IS_PUBLIC), fn($p) => !$p->isStatic()),
self::parseFullDoc($rc, '~^[ \t*]*@property(?:-write)?[ \t]+(?:\S+[ \t]+)??\$(\w+)~m'),
), $name);
throw new MemberAccessException("Cannot write to an undeclared property $class::\$$name" . ($hint ? ", did you mean \$$hint?" : '.'));
}
/**
* @return never
* @throws MemberAccessException
*/
public static function strictCall(string $class, string $method, array $additionalMethods = []): void
{
$trace = debug_backtrace(0, 3); // suppose this method is called from __call()
$context = ($trace[1]['function'] ?? null) === '__call'
? ($trace[2]['class'] ?? null)
: null;
if ($context && is_a($class, $context, true) && method_exists($context, $method)) { // called parent::$method()
$class = get_parent_class($context);
}
if (method_exists($class, $method)) { // insufficient visibility
$rm = new \ReflectionMethod($class, $method);
$visibility = $rm->isPrivate()
? 'private '
: ($rm->isProtected() ? 'protected ' : '');
throw new MemberAccessException("Call to {$visibility}method $class::$method() from " . ($context ? "scope $context." : 'global scope.'));
} else {
$hint = self::getSuggestion(array_merge(
get_class_methods($class),
self::parseFullDoc(new \ReflectionClass($class), '~^[ \t*]*@method[ \t]+(?:static[ \t]+)?(?:\S+[ \t]+)??(\w+)\(~m'),
$additionalMethods,
), $method);
throw new MemberAccessException("Call to undefined method $class::$method()" . ($hint ? ", did you mean $hint()?" : '.'));
}
}
/**
* @return never
* @throws MemberAccessException
*/
public static function strictStaticCall(string $class, string $method): void
{
$trace = debug_backtrace(0, 3); // suppose this method is called from __callStatic()
$context = ($trace[1]['function'] ?? null) === '__callStatic'
? ($trace[2]['class'] ?? null)
: null;
if ($context && is_a($class, $context, true) && method_exists($context, $method)) { // called parent::$method()
$class = get_parent_class($context);
}
if (method_exists($class, $method)) { // insufficient visibility
$rm = new \ReflectionMethod($class, $method);
$visibility = $rm->isPrivate()
? 'private '
: ($rm->isProtected() ? 'protected ' : '');
throw new MemberAccessException("Call to {$visibility}method $class::$method() from " . ($context ? "scope $context." : 'global scope.'));
} else {
$hint = self::getSuggestion(
array_filter((new \ReflectionClass($class))->getMethods(\ReflectionMethod::IS_PUBLIC), fn($m) => $m->isStatic()),
$method,
);
throw new MemberAccessException("Call to undefined static method $class::$method()" . ($hint ? ", did you mean $hint()?" : '.'));
}
}
/**
* Returns array of magic properties defined by annotation @property.
* @return array of [name => bit mask]
* @internal
*/
public static function getMagicProperties(string $class): array
{
static $cache;
$props = &$cache[$class];
if ($props !== null) {
return $props;
}
$rc = new \ReflectionClass($class);
preg_match_all(
'~^ [ \t*]* @property(|-read|-write|-deprecated) [ \t]+ [^\s$]+ [ \t]+ \$ (\w+) ()~mx',
(string) $rc->getDocComment(),
$matches,
PREG_SET_ORDER,
);
$props = [];
foreach ($matches as [, $type, $name]) {
$uname = ucfirst($name);
$write = $type !== '-read'
&& $rc->hasMethod($nm = 'set' . $uname)
&& ($rm = $rc->getMethod($nm))->name === $nm && !$rm->isPrivate() && !$rm->isStatic();
$read = $type !== '-write'
&& ($rc->hasMethod($nm = 'get' . $uname) || $rc->hasMethod($nm = 'is' . $uname))
&& ($rm = $rc->getMethod($nm))->name === $nm && !$rm->isPrivate() && !$rm->isStatic();
if ($read || $write) {
$props[$name] = $read << 0 | ($nm[0] === 'g') << 1 | $rm->returnsReference() << 2 | $write << 3 | ($type === '-deprecated') << 4;
}
}
foreach ($rc->getTraits() as $trait) {
$props += self::getMagicProperties($trait->name);
}
if ($parent = get_parent_class($class)) {
$props += self::getMagicProperties($parent);
}
return $props;
}
/**
* Finds the best suggestion (for 8-bit encoding).
* @param (\ReflectionFunctionAbstract|\ReflectionParameter|\ReflectionClass|\ReflectionProperty|string)[] $possibilities
* @internal
*/
public static function getSuggestion(array $possibilities, string $value): ?string
{
$norm = preg_replace($re = '#^(get|set|has|is|add)(?=[A-Z])#', '+', $value);
$best = null;
$min = (strlen($value) / 4 + 1) * 10 + .1;
foreach (array_unique($possibilities, SORT_REGULAR) as $item) {
$item = $item instanceof \Reflector ? $item->name : $item;
if ($item !== $value && (
($len = levenshtein($item, $value, 10, 11, 10)) < $min
|| ($len = levenshtein(preg_replace($re, '*', $item), $norm, 10, 11, 10)) < $min
)) {
$min = $len;
$best = $item;
}
}
return $best;
}
private static function parseFullDoc(\ReflectionClass $rc, string $pattern): array
{
do {
$doc[] = $rc->getDocComment();
$traits = $rc->getTraits();
while ($trait = array_pop($traits)) {
$doc[] = $trait->getDocComment();
$traits += $trait->getTraits();
}
} while ($rc = $rc->getParentClass());
return preg_match_all($pattern, implode('', $doc), $m) ? $m[1] : [];
}
/**
* Checks if the public non-static property exists.
* Returns 'event' if the property exists and has event like name
* @internal
*/
public static function hasProperty(string $class, string $name): bool|string
{
static $cache;
$prop = &$cache[$class][$name];
if ($prop === null) {
$prop = false;
try {
$rp = new \ReflectionProperty($class, $name);
if ($rp->isPublic() && !$rp->isStatic()) {
$prop = $name >= 'onA' && $name < 'on_' ? 'event' : true;
}
} catch (\ReflectionException $e) {
}
}
return $prop;
}
}

View File

@ -1,210 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette;
/**
* Paginating math.
*
* @property int $page
* @property-read int $firstPage
* @property-read ?int $lastPage
* @property-read int<0,max> $firstItemOnPage
* @property-read int<0,max> $lastItemOnPage
* @property int $base
* @property-read bool $first
* @property-read bool $last
* @property-read ?int<0,max> $pageCount
* @property positive-int $itemsPerPage
* @property ?int<0,max> $itemCount
* @property-read int<0,max> $offset
* @property-read ?int<0,max> $countdownOffset
* @property-read int<0,max> $length
*/
class Paginator
{
use Nette\SmartObject;
private int $base = 1;
/** @var positive-int */
private int $itemsPerPage = 1;
private int $page = 1;
/** @var ?int<0, max> */
private ?int $itemCount = null;
public function setPage(int $page): static
{
$this->page = $page;
return $this;
}
public function getPage(): int
{
return $this->base + $this->getPageIndex();
}
public function getFirstPage(): int
{
return $this->base;
}
public function getLastPage(): ?int
{
return $this->itemCount === null
? null
: $this->base + max(0, $this->getPageCount() - 1);
}
/**
* Returns the 1-based index of the first item on the current page, or 0 if the page is empty.
* @return int<0, max>
*/
public function getFirstItemOnPage(): int
{
return $this->itemCount !== 0
? $this->offset + 1
: 0;
}
/**
* Returns the 1-based index of the last item on the current page.
* @return int<0, max>
*/
public function getLastItemOnPage(): int
{
return $this->offset + $this->length;
}
public function setBase(int $base): static
{
$this->base = $base;
return $this;
}
public function getBase(): int
{
return $this->base;
}
/**
* Returns zero-based page number.
* @return int<0, max>
*/
protected function getPageIndex(): int
{
$index = max(0, $this->page - $this->base);
return $this->itemCount === null
? $index
: min($index, max(0, $this->getPageCount() - 1));
}
public function isFirst(): bool
{
return $this->getPageIndex() === 0;
}
public function isLast(): bool
{
return $this->itemCount === null
? false
: $this->getPageIndex() >= $this->getPageCount() - 1;
}
/**
* @return ?int<0, max>
*/
public function getPageCount(): ?int
{
return $this->itemCount === null
? null
: max(0, (int) ceil($this->itemCount / $this->itemsPerPage));
}
public function setItemsPerPage(int $itemsPerPage): static
{
$this->itemsPerPage = max(1, $itemsPerPage);
return $this;
}
/**
* @return positive-int
*/
public function getItemsPerPage(): int
{
return $this->itemsPerPage;
}
public function setItemCount(?int $itemCount = null): static
{
$this->itemCount = $itemCount === null ? null : max(0, $itemCount);
return $this;
}
/**
* @return ?int<0, max>
*/
public function getItemCount(): ?int
{
return $this->itemCount;
}
/**
* Returns the absolute index of the first item on current page.
* @return int<0, max>
*/
public function getOffset(): int
{
return $this->getPageIndex() * $this->itemsPerPage;
}
/**
* Returns the absolute index of the first item on current page in countdown paging.
* @return ?int<0, max>
*/
public function getCountdownOffset(): ?int
{
return $this->itemCount === null
? null
: max(0, $this->itemCount - ($this->getPageIndex() + 1) * $this->itemsPerPage);
}
/**
* Returns the number of items on current page.
* @return int<0, max>
*/
public function getLength(): int
{
return $this->itemCount === null
? $this->itemsPerPage
: max(0, min($this->itemsPerPage, $this->itemCount - $this->getPageIndex() * $this->itemsPerPage));
}
}

View File

@ -1,507 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette;
/**
* Represents a process, which can be started and controlled (reading output, writing input, waiting for completion).
*/
final class Process
{
private const PollInterval = 10_000;
private const DefaultTimeout = 60;
private const StdIn = 0;
private const StdOut = 1;
private const StdErr = 2;
/** @var resource */
private mixed $process;
/** @var array<string, mixed> result of proc_get_status() */
private array $status = ['running' => true];
/** @var resource */
private mixed $inputPipe;
/** @var array<int, resource|string[]> pipe resources, or ['file', path, mode] descriptors for discarded output */
private array $outputPipes = [];
/** @var string[] */
private array $outputBuffers = [];
/** @var int[] Number of bytes already read from buffers. */
private array $outputBufferOffsets = [];
/** @var array<int, true> Output IDs whose target resource was supplied by the caller and must not be closed here. */
private array $callerOutputs = [];
private float $startTime;
/**
* Starts an executable with given arguments. Because the arguments are passed as an array, the shell is
* never involved, so they need no escaping and there is no risk of shell injection.
* @param string $executable Path to the executable binary.
* @param list<string> $arguments Arguments passed to the executable.
* @param string[]|null $env Environment variables or null to use the same environment as the current process.
* @param array<string, mixed> $options Additional options for proc_open(). On Windows the executable is launched directly, without cmd.exe.
* @param mixed $stdin Input: string, a readable resource (its content is copied to STDIN), a Process (its STDOUT is piped in), or null (STDIN stays open for writeStdInput()).
* @param mixed $stdout Output target: string filename, a writable resource backed by a real OS file descriptor (not php://memory etc.), false to discard, or null to capture into memory.
* @param mixed $stderr Error output target (same options as $stdout).
* @param string|null $directory Working directory.
* @param float|null $timeout Time limit in seconds, checked while waiting for or reading the process; null disables it.
*/
public static function runExecutable(
string $executable,
array $arguments = [],
?array $env = null,
array $options = [],
mixed $stdin = '',
mixed $stdout = null,
mixed $stderr = null,
?string $directory = null,
?float $timeout = self::DefaultTimeout,
): self
{
return new self([$executable, ...$arguments], $env, $options, $directory, $stdin, $stdout, $stderr, $timeout);
}
/**
* Starts a process from a command string interpreted by the shell (/bin/sh on POSIX, cmd.exe on Windows).
* Because the shell parses the string, NEVER pass unescaped user input here - use runExecutable() for that.
* @param string $command Shell command to run.
* @param string[]|null $env Environment variables or null to use the same environment as the current process.
* @param array<string, mixed> $options Options for proc_open(), e.g. ['bypass_shell' => true] on Windows to skip cmd.exe.
* @param mixed $stdin Input: string, a readable resource (its content is copied to STDIN), a Process (its STDOUT is piped in), or null (STDIN stays open for writeStdInput()).
* @param mixed $stdout Output target: string filename, a writable resource backed by a real OS file descriptor (not php://memory etc.), false to discard, or null to capture into memory.
* @param mixed $stderr Error output target (same options as $stdout).
* @param string|null $directory Working directory.
* @param float|null $timeout Time limit in seconds, checked while waiting for or reading the process; null disables it.
*/
public static function runCommand(
string $command,
?array $env = null,
array $options = [],
mixed $stdin = '',
mixed $stdout = null,
mixed $stderr = null,
?string $directory = null,
?float $timeout = self::DefaultTimeout,
): self
{
return new self($command, $env, $options, $directory, $stdin, $stdout, $stderr, $timeout);
}
/**
* @param list<string>|string $command
* @param array<string, string>|null $env
* @param array<string, mixed> $options
*/
private function __construct(
string|array $command,
?array $env,
array $options,
?string $directory,
mixed $stdin,
mixed $stdout,
mixed $stderr,
private ?float $timeout,
) {
$descriptors = [
self::StdIn => $this->createInputDescriptor($stdin),
self::StdOut => $this->createOutputDescriptor(self::StdOut, $stdout),
self::StdErr => $this->createOutputDescriptor(self::StdErr, $stderr),
];
$process = @proc_open($command, $descriptors, $pipes, $directory, $env, $options);
if (!is_resource($process)) {
throw new ProcessFailedException('Failed to start process: ' . Helpers::getLastError());
}
$this->process = $process;
[$this->inputPipe, $this->outputPipes[self::StdOut], $this->outputPipes[self::StdErr]] = $pipes + $descriptors;
if ($stdin instanceof self) {
// the source process hands over its STDOUT pipe; from now on this process owns it
unset(
$stdin->outputBuffers[self::StdOut],
$stdin->outputBufferOffsets[self::StdOut],
$stdin->outputPipes[self::StdOut],
);
}
$this->writeInitialInput($stdin);
$this->startTime = microtime(true);
}
public function __destruct()
{
$this->outputBuffers = [];
$this->terminate();
}
/**
* Checks if the process is currently running.
*/
public function isRunning(): bool
{
if (!$this->status['running']) {
return false;
}
$this->status = proc_get_status($this->process);
if (!$this->status['running']) {
$this->close();
}
return $this->status['running'];
}
/**
* Finishes the process by waiting for its completion. While waiting, the captured output is read
* continuously and kept in memory; an optional callback is invoked with each new output/error chunk.
*
* @param (\Closure(string, string): void)|null $callback
*/
public function wait(?\Closure $callback = null): void
{
while ($this->isRunning()) {
$this->enforceTimeout();
$this->drainPipes();
$this->dispatchCallback($callback);
usleep(self::PollInterval);
}
$this->dispatchCallback($callback);
}
/**
* Reads any new data from the captured pipes into the buffers, so a process producing more output
* than the OS pipe buffer holds does not block. (On Windows the captured output is a file and never blocks.)
*/
private function drainPipes(): void
{
foreach ([self::StdOut, self::StdErr] as $id) {
$this->readFromPipe($id);
}
}
/**
* Terminates the running process if it is still running.
*/
public function terminate(): void
{
if (!$this->isRunning()) {
return;
} elseif (Helpers::IsWindows) {
exec("taskkill /F /T /PID {$this->getPid()} 2>&1");
} else {
proc_terminate($this->process, 9); // 9 = SIGKILL: cannot be trapped, so the following proc_close() won't hang
}
$this->status['running'] = false;
$this->close();
}
/**
* Returns the process exit code. If the process is still running, waits until it finishes.
*/
public function getExitCode(): int
{
$this->wait();
return $this->status['exitcode'] ?? -1;
}
/**
* Returns true if the process terminated with exit code 0.
*/
public function isSuccess(): bool
{
return $this->getExitCode() === 0;
}
/**
* Waits for the process to finish and throws ProcessFailedException if exit code is not zero.
*/
public function ensureSuccess(): void
{
$code = $this->getExitCode();
if ($code !== 0) {
throw new ProcessFailedException("Process failed with non-zero exit code: $code");
}
}
/**
* Returns the PID of the running process, or null if it is not running.
*/
public function getPid(): ?int
{
return $this->isRunning() ? $this->status['pid'] : null;
}
/**
* Waits for the process to finish and returns everything it wrote to STDOUT.
*/
public function getStdOutput(): string
{
$this->wait();
return $this->outputBuffers[self::StdOut] ?? throw new Nette\InvalidStateException('Cannot read output: it is not captured (it was redirected, discarded or piped).');
}
/**
* Waits for the process to finish and returns everything it wrote to STDERR.
*/
public function getStdError(): string
{
$this->wait();
return $this->outputBuffers[self::StdErr] ?? throw new Nette\InvalidStateException('Cannot read output: it is not captured (it was redirected, discarded or piped).');
}
/**
* Returns the STDOUT data produced since the previous consumeStdOutput() call.
* To read everything incrementally, poll `while ($p->isRunning())` calling this, then call it once more
* after the loop; that last call returns whatever the process wrote just before the loop noticed it had exited.
*/
public function consumeStdOutput(): string
{
return $this->consumeBuffer(self::StdOut);
}
/**
* Returns the STDERR data produced since the previous consumeStdError() call. See consumeStdOutput().
*/
public function consumeStdError(): string
{
return $this->consumeBuffer(self::StdErr);
}
/**
* Returns newly available data from the specified buffer and advances the read pointer.
*/
private function consumeBuffer(int $id): string
{
if (!isset($this->outputBuffers[$id])) {
throw new Nette\InvalidStateException('Cannot read output: it is not captured (it was redirected, discarded or piped).');
} elseif ($this->isRunning()) {
$this->enforceTimeout();
$this->readFromPipe($id);
}
return $this->extractNewData($id);
}
/**
* Returns the buffered data not returned yet and advances the read pointer.
*/
private function extractNewData(int $id): string
{
if (!isset($this->outputBuffers[$id])) {
return '';
}
$res = substr($this->outputBuffers[$id], $this->outputBufferOffsets[$id]);
$this->outputBufferOffsets[$id] = strlen($this->outputBuffers[$id]);
return $res;
}
/**
* Writes data into the process' STDIN. If STDIN is closed, throws exception.
*/
public function writeStdInput(string $string): void
{
if (!is_resource($this->inputPipe)) {
throw new Nette\InvalidStateException('Cannot write to process: STDIN pipe is closed');
}
$this->writeToPipe($string);
}
/**
* Writes the whole string to STDIN, handling partial writes. Stops (and lets fwrite() warn) on a broken pipe,
* i.e. when the process stopped reading its STDIN.
*/
private function writeToPipe(string $string): void
{
$length = strlen($string);
for ($written = 0; $written < $length; $written += $bytes) {
$bytes = fwrite($this->inputPipe, substr($string, $written));
if (!$bytes) {
break;
}
}
}
/**
* Closes the STDIN pipe, indicating no more data will be sent.
*/
public function closeStdInput(): void
{
if (is_resource($this->inputPipe)) {
fclose($this->inputPipe);
}
}
/**
* If a callback is given, invokes it with the output/error produced since the previous call.
* @param (\Closure(string, string): void)|null $callback
*/
private function dispatchCallback(?\Closure $callback): void
{
if (!$callback) {
return;
}
$output = $this->extractNewData(self::StdOut);
$error = $this->extractNewData(self::StdErr);
if ($output !== '' || $error !== '') {
$callback($output, $error);
}
}
/**
* Checks if the timeout has expired. If yes, terminates the process.
*/
private function enforceTimeout(): void
{
if ($this->timeout !== null && (microtime(true) - $this->startTime) >= $this->timeout) {
$this->terminate();
throw new ProcessTimeoutException('Process exceeded the time limit of ' . $this->timeout . ' seconds');
}
}
/**
* Reads any new data from the specified pipe and appends it to the buffer. Does nothing if the output
* is not captured or the pipe is already closed (or handed over to another process).
*/
private function readFromPipe(int $id): void
{
if (!isset($this->outputBuffers[$id]) || !is_resource($this->outputPipes[$id] ?? null)) {
return;
} elseif (Helpers::IsWindows) {
fseek($this->outputPipes[$id], strlen($this->outputBuffers[$id]));
} else {
stream_set_blocking($this->outputPipes[$id], false);
}
$this->outputBuffers[$id] .= stream_get_contents($this->outputPipes[$id]);
}
/**
* Sends the initial input to the process: writes and closes a string or stream input,
* or leaves STDIN open when input is null (until closeStdInput()) or another Process (fed by that process).
* The input type was already validated by createInputDescriptor().
*
* Note: a string or stream input is written upfront, so if it is large and the process does not read it
* while filling its own output, this can block; in that case pass null and feed STDIN via writeStdInput().
*/
private function writeInitialInput(mixed $input): void
{
if ($input === null || $input instanceof self) {
// STDIN stays open
} elseif (is_string($input)) {
$this->writeToPipe($input);
$this->closeStdInput();
} elseif (is_resource($input)) {
stream_copy_to_stream($input, $this->inputPipe);
$this->closeStdInput();
}
}
/**
* Validates the input and determines the STDIN descriptor based on its type.
*/
private function createInputDescriptor(mixed $input): mixed
{
if ($input === null || is_string($input) || is_resource($input)) {
return ['pipe', 'r'];
} elseif (!$input instanceof self) {
throw new Nette\InvalidArgumentException('Input must be string, resource, Process or null, ' . get_debug_type($input) . ' given.');
} elseif (Helpers::IsWindows) {
throw new Nette\NotSupportedException('Process piping is not supported on Windows.');
} elseif (!isset($input->outputBuffers[self::StdOut]) || !is_resource($input->outputPipes[self::StdOut] ?? null)) {
throw new Nette\InvalidStateException('Cannot pipe from the given process: its STDOUT must be captured (it must not be redirected elsewhere).');
}
return $input->outputPipes[self::StdOut];
}
/**
* Determines the descriptor for STDOUT or STDERR based on the specified output target.
*/
private function createOutputDescriptor(int $id, mixed $output): mixed
{
if (is_resource($output)) {
$this->callerOutputs[$id] = true;
return $output;
} elseif (is_string($output)) {
return FileSystem::open($output, 'w');
} elseif ($output === false) {
return ['file', Helpers::IsWindows ? 'NUL' : '/dev/null', 'w'];
} elseif ($output === null) {
$this->outputBuffers[$id] = '';
$this->outputBufferOffsets[$id] = 0;
// On Windows anonymous pipes are blocking and cannot be polled without freezing the process,
// so captured output is backed by a temporary file that can be read non-blockingly (needed for timeouts).
return Helpers::IsWindows ? tmpfile() : ['pipe', 'w'];
} else {
throw new Nette\InvalidArgumentException('Output must be string, resource, bool or null, ' . get_debug_type($output) . ' given.');
}
}
/**
* Closes all pipes and the process resource.
*/
private function close(): void
{
$this->drainPipes();
$this->closeStdInput();
$this->closeOutputPipes();
proc_close($this->process);
}
/**
* Closes the output pipes that this class opened; resources supplied by the caller are left untouched.
* (The temporary file backing captured output on Windows is removed by fclose() itself.)
*/
private function closeOutputPipes(): void
{
foreach ($this->outputPipes as $id => $pipe) {
if (is_resource($pipe) && !isset($this->callerOutputs[$id])) {
fclose($pipe);
}
}
}
}

View File

@ -1,52 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette;
use Random\Randomizer;
use function strlen;
use const PHP_VERSION_ID;
/**
* Secure random string generator.
*/
final class Random
{
use Nette\StaticClass;
/**
* Generates a random string of given length from characters specified in second argument.
* Supports intervals, such as `0-9` or `A-Z`.
*/
public static function generate(int $length = 10, string $charlist = '0-9a-z'): string
{
$charlist = preg_replace_callback(
'#.-.#',
fn(array $m): string => implode('', range($m[0][0], $m[0][2])),
$charlist,
);
$charlist = count_chars($charlist, mode: 3);
$chLen = strlen($charlist);
if ($length < 1) {
throw new Nette\InvalidArgumentException('Length must be greater than zero.');
} elseif ($chLen < 2) {
throw new Nette\InvalidArgumentException('Character list must contain at least two chars.');
} elseif (PHP_VERSION_ID >= 80300) {
return (new Randomizer)->getBytesFromString($charlist, $length);
}
$res = '';
for ($i = 0; $i < $length; $i++) {
$res .= $charlist[random_int(0, $chLen - 1)];
}
return $res;
}
}

View File

@ -1,335 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette;
use function constant, current, defined, end, explode, file_get_contents, implode, ltrim, next, ord, strrchr, strtolower, substr;
use const T_AS, T_CLASS, T_COMMENT, T_CURLY_OPEN, T_DOC_COMMENT, T_DOLLAR_OPEN_CURLY_BRACES, T_ENUM, T_INTERFACE, T_NAME_FULLY_QUALIFIED, T_NAME_QUALIFIED, T_NAMESPACE, T_NS_SEPARATOR, T_STRING, T_TRAIT, T_USE, T_WHITESPACE, TOKEN_PARSE;
/**
* PHP reflection helpers.
*/
final class Reflection
{
use Nette\StaticClass;
/** @deprecated use Nette\Utils\Validators::isBuiltinType() */
public static function isBuiltinType(string $type): bool
{
return Validators::isBuiltinType($type);
}
#[\Deprecated('use Nette\Utils\Validators::isClassKeyword()')]
public static function isClassKeyword(string $name): bool
{
return Validators::isClassKeyword($name);
}
/**
* Returns the default value of a parameter. Resolves constants and class constants used as default values.
* @throws \ReflectionException if the constant cannot be resolved
*/
public static function getParameterDefaultValue(\ReflectionParameter $param): mixed
{
if ($param->isDefaultValueConstant()) {
$const = $orig = $param->getDefaultValueConstantName() ?? throw new Nette\ShouldNotHappenException;
$pair = explode('::', $const);
if (isset($pair[1])) {
$pair[0] = Type::resolve($pair[0], $param);
try {
$rcc = new \ReflectionClassConstant($pair[0], $pair[1]);
} catch (\ReflectionException $e) {
$name = self::toString($param);
throw new \ReflectionException("Unable to resolve constant $orig used as default value of $name.", 0, $e);
}
return $rcc->getValue();
} elseif (!defined($const)) {
$const = substr((string) strrchr($const, '\\'), 1);
if (!defined($const)) {
$name = self::toString($param);
throw new \ReflectionException("Unable to resolve constant $orig used as default value of $name.");
}
}
return constant($const);
}
return $param->getDefaultValue();
}
/**
* Returns a reflection of a class or trait that contains a declaration of given property. Property can also be declared in the trait.
* @return \ReflectionClass<object>
*/
public static function getPropertyDeclaringClass(\ReflectionProperty $prop): \ReflectionClass
{
foreach ($prop->getDeclaringClass()->getTraits() as $trait) {
if ($trait->hasProperty($prop->name)
// doc-comment guessing as workaround for insufficient PHP reflection
&& $trait->getProperty($prop->name)->getDocComment() === $prop->getDocComment()
) {
return self::getPropertyDeclaringClass($trait->getProperty($prop->name));
}
}
return $prop->getDeclaringClass();
}
/**
* Returns a reflection of a method that contains a declaration of $method.
* Usually, each method is its own declaration, but the body of the method can also be in the trait and under a different name.
*/
public static function getMethodDeclaringMethod(\ReflectionMethod $method): \ReflectionMethod
{
// file & line guessing as workaround for insufficient PHP reflection
$decl = $method->getDeclaringClass();
if ($decl->getFileName() === $method->getFileName()
&& $decl->getStartLine() <= $method->getStartLine()
&& $decl->getEndLine() >= $method->getEndLine()
) {
return $method;
}
$hash = [$method->getFileName(), $method->getStartLine(), $method->getEndLine()];
if (($alias = $decl->getTraitAliases()[$method->name] ?? null)
&& ($m = new \ReflectionMethod(...explode('::', $alias, 2)))
&& $hash === [$m->getFileName(), $m->getStartLine(), $m->getEndLine()]
) {
return self::getMethodDeclaringMethod($m);
}
foreach ($decl->getTraits() as $trait) {
if ($trait->hasMethod($method->name)
&& ($m = $trait->getMethod($method->name))
&& $hash === [$m->getFileName(), $m->getStartLine(), $m->getEndLine()]
) {
return self::getMethodDeclaringMethod($m);
}
}
return $method;
}
/**
* Finds out if reflection has access to PHPdoc comments. Comments may not be available due to the opcode cache.
*/
public static function areCommentsAvailable(): bool
{
static $res;
return $res ?? $res = (bool) (new \ReflectionMethod(self::class, __FUNCTION__))->getDocComment();
}
/**
* Returns a human-readable string representation of a reflection object.
*/
public static function toString(\Reflector $ref): string
{
if ($ref instanceof \ReflectionClass) {
return $ref->name;
} elseif ($ref instanceof \ReflectionMethod) {
return $ref->getDeclaringClass()->name . '::' . $ref->name . '()';
} elseif ($ref instanceof \ReflectionFunction) {
return $ref->isAnonymous() ? '{closure}()' : $ref->name . '()';
} elseif ($ref instanceof \ReflectionProperty) {
return self::getPropertyDeclaringClass($ref)->name . '::$' . $ref->name;
} elseif ($ref instanceof \ReflectionParameter) {
return '$' . $ref->name . ' in ' . self::toString($ref->getDeclaringFunction());
} else {
throw new Nette\InvalidArgumentException;
}
}
/**
* Expands the name of the class to full name in the given context of given class.
* Thus, it returns how the PHP parser would understand $name if it were written in the body of the class $context.
* @param \ReflectionClass<object> $context
* @throws Nette\InvalidArgumentException
*/
public static function expandClassName(string $name, \ReflectionClass $context): string
{
$lower = strtolower($name);
if (empty($name)) {
throw new Nette\InvalidArgumentException('Class name must not be empty.');
} elseif (Validators::isBuiltinType($lower)) {
return $lower;
} elseif ($lower === 'self' || $lower === 'static') {
return $context->name;
} elseif ($lower === 'parent') {
return $context->getParentClass()
? $context->getParentClass()->name
: 'parent';
} elseif ($name[0] === '\\') { // fully qualified name
return ltrim($name, '\\');
}
$uses = self::getUseStatements($context);
$parts = explode('\\', $name, 2);
if (isset($uses[$parts[0]])) {
$parts[0] = $uses[$parts[0]];
return implode('\\', $parts);
} elseif ($context->inNamespace()) {
return $context->getNamespaceName() . '\\' . $name;
} else {
return $name;
}
}
/**
* Returns the use statements from the file where the class is defined.
* @param \ReflectionClass<object> $class
* @return array<string, class-string> Map of alias to fully qualified class name
*/
public static function getUseStatements(\ReflectionClass $class): array
{
if ($class->isAnonymous()) {
throw new Nette\NotImplementedException('Anonymous classes are not supported.');
}
static $cache = [];
if (!isset($cache[$name = $class->name])) {
if ($class->isInternal()) {
$cache[$name] = [];
} else {
$code = (string) file_get_contents((string) $class->getFileName());
$cache = self::parseUseStatements($code, $name) + $cache;
}
}
return $cache[$name];
}
/**
* Parses PHP code to [class => [alias => class, ...]]
* @return array<string, array<string, string>>
*/
private static function parseUseStatements(string $code, ?string $forClass = null): array
{
try {
$tokens = \PhpToken::tokenize($code, TOKEN_PARSE);
} catch (\ParseError $e) {
trigger_error($e->getMessage(), E_USER_NOTICE);
$tokens = [];
}
$namespace = $class = null;
$classLevel = $level = 0;
$res = $uses = [];
$nameTokens = [T_STRING, T_NS_SEPARATOR, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED];
while ($token = current($tokens)) {
next($tokens);
switch ($token->id) {
case T_NAMESPACE:
$namespace = ltrim(self::fetch($tokens, $nameTokens) . '\\', '\\');
$uses = [];
break;
case T_CLASS:
case T_INTERFACE:
case T_TRAIT:
case T_ENUM:
if ($name = self::fetch($tokens, T_STRING)) {
$class = $namespace . $name;
$classLevel = $level + 1;
$res[$class] = $uses;
if ($class === $forClass) {
return $res;
}
}
break;
case T_USE:
while (!$class && ($name = self::fetch($tokens, $nameTokens))) {
$name = ltrim($name, '\\');
if (self::fetch($tokens, '{')) {
while ($suffix = self::fetch($tokens, $nameTokens)) {
if (self::fetch($tokens, T_AS) && ($alias = self::fetch($tokens, T_STRING))) {
$uses[$alias] = $name . $suffix;
} else {
$tmp = explode('\\', $suffix);
$uses[end($tmp)] = $name . $suffix;
}
if (!self::fetch($tokens, ',')) {
break;
}
}
} elseif (self::fetch($tokens, T_AS) && ($alias = self::fetch($tokens, T_STRING))) {
$uses[$alias] = $name;
} else {
$tmp = explode('\\', $name);
$uses[end($tmp)] = $name;
}
if (!self::fetch($tokens, ',')) {
break;
}
}
break;
case T_CURLY_OPEN:
case T_DOLLAR_OPEN_CURLY_BRACES:
case ord('{'):
$level++;
break;
case ord('}'):
if ($level === $classLevel) {
$class = $classLevel = 0;
}
$level--;
}
}
return $res;
}
/**
* @param \PhpToken[] $tokens
* @param string|int|int[] $take
*/
private static function fetch(array &$tokens, string|int|array $take): ?string
{
$res = null;
while ($token = current($tokens)) {
if ($token->is($take)) {
$res .= $token->text;
} elseif (!$token->is([T_DOC_COMMENT, T_WHITESPACE, T_COMMENT])) {
break;
}
next($tokens);
}
return $res;
}
}

View File

@ -1,39 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use function explode, is_string, str_contains;
/**
* ReflectionMethod preserving the original class name.
* @internal
*/
final class ReflectionMethod extends \ReflectionMethod
{
/** @var \ReflectionClass<object> */
private readonly \ReflectionClass $originalClass;
/** @param class-string|object $objectOrMethod */
public function __construct(object|string $objectOrMethod, ?string $method = null)
{
if (is_string($objectOrMethod) && str_contains($objectOrMethod, '::')) {
[$objectOrMethod, $method] = explode('::', $objectOrMethod, 2);
}
parent::__construct($objectOrMethod, $method);
$this->originalClass = new \ReflectionClass($objectOrMethod);
}
/** @return \ReflectionClass<object> */
public function getOriginalClass(): \ReflectionClass
{
return $this->originalClass;
}
}

View File

@ -1,730 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use JetBrains\PhpStorm\Language;
use Nette;
use function array_keys, array_map, array_shift, array_values, bin2hex, class_exists, defined, extension_loaded, function_exists, htmlspecialchars, htmlspecialchars_decode, iconv, iconv_strlen, iconv_substr, implode, in_array, is_array, is_callable, is_int, is_object, is_string, key, max, mb_convert_case, mb_strlen, mb_strtolower, mb_strtoupper, mb_substr, pack, preg_last_error, preg_last_error_msg, preg_quote, preg_replace, str_contains, str_ends_with, str_repeat, str_replace, str_starts_with, strlen, strpos, strrev, strrpos, strtolower, strtoupper, strtr, substr, trim, unpack, utf8_decode;
use const ENT_IGNORE, ENT_NOQUOTES, ICONV_IMPL, MB_CASE_TITLE, PHP_EOL, PREG_OFFSET_CAPTURE, PREG_PATTERN_ORDER, PREG_SET_ORDER, PREG_SPLIT_DELIM_CAPTURE, PREG_SPLIT_NO_EMPTY, PREG_SPLIT_OFFSET_CAPTURE, PREG_UNMATCHED_AS_NULL;
/**
* String tools library.
*/
class Strings
{
use Nette\StaticClass;
public const TrimCharacters = " \t\n\r\0\x0B\u{A0}\u{2000}\u{2001}\u{2002}\u{2003}\u{2004}\u{2005}\u{2006}\u{2007}\u{2008}\u{2009}\u{200A}\u{200B}\u{2028}\u{3000}";
#[\Deprecated('use Strings::TrimCharacters')]
public const TRIM_CHARACTERS = self::TrimCharacters;
/**
* @deprecated use Nette\Utils\Validators::isUnicode()
*/
public static function checkEncoding(string $s): bool
{
return $s === self::fixEncoding($s);
}
/**
* Removes all invalid UTF-8 characters from a string.
*/
public static function fixEncoding(string $s): string
{
// removes xD800-xDFFF, x110000 and higher
return htmlspecialchars_decode(htmlspecialchars($s, ENT_NOQUOTES | ENT_IGNORE, 'UTF-8'), ENT_NOQUOTES);
}
/**
* Returns a specific character in UTF-8 from code point (number in range 0x0000..D7FF or 0xE000..10FFFF).
* @throws Nette\InvalidArgumentException if code point is not in valid range
*/
public static function chr(int $code): string
{
if ($code < 0 || ($code >= 0xD800 && $code <= 0xDFFF) || $code > 0x10FFFF) {
throw new Nette\InvalidArgumentException('Code point must be in range 0x0 to 0xD7FF or 0xE000 to 0x10FFFF.');
} elseif (!extension_loaded('iconv')) {
throw new Nette\NotSupportedException(__METHOD__ . '() requires ICONV extension that is not loaded.');
}
$res = iconv('UTF-32BE', 'UTF-8//IGNORE', pack('N', $code));
return $res === false ? throw new Nette\ShouldNotHappenException : $res;
}
/**
* Returns a code point of specific character in UTF-8 (number in range 0x0000..D7FF or 0xE000..10FFFF).
*/
public static function ord(string $c): int
{
if (!extension_loaded('iconv')) {
throw new Nette\NotSupportedException(__METHOD__ . '() requires ICONV extension that is not loaded.');
}
$tmp = iconv('UTF-8', 'UTF-32BE//IGNORE', $c);
if ($tmp === false || $tmp === '') {
throw new Nette\InvalidArgumentException('Invalid UTF-8 character "' . ($c === '' ? '' : '\x' . strtoupper(bin2hex($c))) . '".');
}
return unpack('N', $tmp)[1] ?? throw new Nette\ShouldNotHappenException;
}
/**
* @deprecated use str_starts_with()
*/
public static function startsWith(string $haystack, string $needle): bool
{
return str_starts_with($haystack, $needle);
}
/**
* @deprecated use str_ends_with()
*/
public static function endsWith(string $haystack, string $needle): bool
{
return str_ends_with($haystack, $needle);
}
/**
* @deprecated use str_contains()
*/
public static function contains(string $haystack, string $needle): bool
{
return str_contains($haystack, $needle);
}
/**
* Returns a part of UTF-8 string specified by starting position and length. If start is negative,
* the returned string will start at the start'th character from the end of string.
*/
public static function substring(string $s, int $start, ?int $length = null): string
{
if (function_exists('mb_substr')) {
return mb_substr($s, $start, $length, 'UTF-8'); // MB is much faster
} elseif (!extension_loaded('iconv')) {
throw new Nette\NotSupportedException(__METHOD__ . '() requires extension ICONV or MBSTRING, neither is loaded.');
} elseif ($length === null) {
$length = self::length($s);
} elseif ($start < 0 && $length < 0) {
$start += self::length($s); // unifies iconv_substr behavior with mb_substr
}
$res = iconv_substr($s, $start, $length, 'UTF-8');
return $res === false ? throw new Nette\InvalidStateException('iconv_substr() failed.') : $res;
}
/**
* Removes control characters, normalizes line breaks to `\n`, removes leading and trailing blank lines,
* trims end spaces on lines, normalizes UTF-8 to the normal form of NFC.
*/
public static function normalize(string $s): string
{
// convert to compressed normal form (NFC)
if (class_exists('Normalizer', autoload: false) && ($n = \Normalizer::normalize($s, \Normalizer::FORM_C)) !== false) {
$s = $n;
}
$s = self::unixNewLines($s);
// remove control characters; leave \t + \n
$s = self::pcre('preg_replace', ['#[\x00-\x08\x0B-\x1F\x7F-\x9F]+#u', '', $s]);
// right trim
$s = self::pcre('preg_replace', ['#[\t ]+$#m', '', $s]);
// leading and trailing blank lines
$s = trim($s, "\n");
return $s;
}
/** @deprecated use Strings::unixNewLines() */
public static function normalizeNewLines(string $s): string
{
return self::unixNewLines($s);
}
/**
* Converts line endings to \n used on Unix-like systems.
* Line endings are: \n, \r, \r\n, U+2028 line separator, U+2029 paragraph separator.
*/
public static function unixNewLines(string $s): string
{
return preg_replace("~\r\n?|\u{2028}|\u{2029}~", "\n", $s);
}
/**
* Converts line endings to platform-specific, i.e. \r\n on Windows and \n elsewhere.
* Line endings are: \n, \r, \r\n, U+2028 line separator, U+2029 paragraph separator.
*/
public static function platformNewLines(string $s): string
{
return preg_replace("~\r\n?|\n|\u{2028}|\u{2029}~", PHP_EOL, $s);
}
/**
* Converts UTF-8 string to ASCII, ie removes diacritics etc.
*/
public static function toAscii(string $s): string
{
if (!extension_loaded('intl')) {
throw new Nette\NotSupportedException(__METHOD__ . '() requires INTL extension that is not loaded.');
}
$iconv = defined('ICONV_IMPL') ? trim(ICONV_IMPL, '"\'') : null;
// remove control characters and check UTF-8 validity
$s = self::pcre('preg_replace', ['#[^\x09\x0A\x0D\x20-\x7E\xA0-\x{2FF}\x{370}-\x{10FFFF}]#u', '', $s]);
// transliteration (by Transliterator and iconv) is not optimal, replace some characters directly
$s = strtr($s, ["\u{201E}" => '"', "\u{201C}" => '"', "\u{201D}" => '"', "\u{201A}" => "'", "\u{2018}" => "'", "\u{2019}" => "'", "\u{B0}" => '^', "\u{42F}" => 'Ya', "\u{44F}" => 'ya', "\u{42E}" => 'Yu', "\u{44E}" => 'yu', "\u{c4}" => 'Ae', "\u{d6}" => 'Oe', "\u{dc}" => 'Ue', "\u{1e9e}" => 'Ss', "\u{e4}" => 'ae', "\u{f6}" => 'oe', "\u{fc}" => 'ue', "\u{df}" => 'ss']); // „ “ ” ° Я я Ю ю Ä Ö Ü ẞ ä ö ü ß
if ($iconv !== 'libiconv') {
$s = strtr($s, ["\u{AE}" => '(R)', "\u{A9}" => '(c)', "\u{2026}" => '...', "\u{AB}" => '<<', "\u{BB}" => '>>', "\u{A3}" => 'lb', "\u{A5}" => 'yen', "\u{B2}" => '^2', "\u{B3}" => '^3', "\u{B5}" => 'u', "\u{B9}" => '^1', "\u{BA}" => 'o', "\u{BF}" => '?', "\u{2CA}" => "'", "\u{2CD}" => '_', "\u{2DD}" => '"', "\u{1FEF}" => '', "\u{20AC}" => 'EUR', "\u{2122}" => 'TM', "\u{212E}" => 'e', "\u{2190}" => '<-', "\u{2191}" => '^', "\u{2192}" => '->', "\u{2193}" => 'V', "\u{2194}" => '<->']); // ® © … « » £ ¥ ² ³ µ ¹ º ¿ ˊ ˍ ˝ € ™ ← ↑ → ↓ ↔
}
$s = \Transliterator::create('Any-Latin; Latin-ASCII')?->transliterate($s)
?? throw new Nette\InvalidStateException('Transliterator::transliterate() failed.');
// use iconv because The transliterator leaves some characters out of ASCII, eg → ʾ
if ($iconv === 'glibc') {
$s = strtr($s, '?', "\x01"); // temporarily hide ? to distinguish them from the garbage that iconv creates
$s = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
if ($s === false) {
throw new Nette\InvalidStateException('iconv() failed.');
}
$s = str_replace(['?', "\x01"], ['', '?'], $s); // remove garbage and restore ? characters
} elseif ($iconv === 'libiconv') {
$s = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
if ($s === false) {
throw new Nette\InvalidStateException('iconv() failed.');
}
} else { // null or 'unknown' (#216)
$s = self::pcre('preg_replace', ['#[^\x00-\x7F]++#', '', $s]); // remove non-ascii chars
}
return $s;
}
/**
* Modifies the UTF-8 string to the form used in the URL, ie removes diacritics and replaces all characters
* except letters of the English alphabet and numbers with a hyphens.
*/
public static function webalize(string $s, ?string $charlist = null, bool $lower = true): string
{
$s = self::toAscii($s);
if ($lower) {
$s = strtolower($s);
}
$s = self::pcre('preg_replace', ['#[^a-z0-9' . ($charlist !== null ? preg_quote($charlist, '#') : '') . ']+#i', '-', $s]);
$s = trim($s, '-');
return $s;
}
/**
* Truncates a UTF-8 string to given maximal length, while trying not to split whole words. Only if the string is truncated,
* an ellipsis (or something else set with third argument) is appended to the string.
*/
public static function truncate(string $s, int $maxLen, string $append = "\u{2026}"): string
{
if (self::length($s) > $maxLen) {
$maxLen -= self::length($append);
if ($maxLen < 1) {
return $append;
} elseif ($matches = self::match($s, '#^.{1,' . $maxLen . '}(?=[\s\x00-/:-@\[-`{-~])#us')) {
return $matches[0] . $append;
} else {
return self::substring($s, 0, $maxLen) . $append;
}
}
return $s;
}
/**
* Indents a multiline text from the left. Second argument sets how many indentation chars should be used,
* while the indent itself is the third argument (*tab* by default).
*/
public static function indent(string $s, int $level = 1, string $chars = "\t"): string
{
if ($level > 0) {
$s = self::replace($s, '#(?:^|[\r\n]+)(?=[^\r\n])#', '$0' . str_repeat($chars, $level));
}
return $s;
}
/**
* Converts all characters of UTF-8 string to lower case.
*/
public static function lower(string $s): string
{
return mb_strtolower($s, 'UTF-8');
}
/**
* Converts the first character of a UTF-8 string to lower case and leaves the other characters unchanged.
*/
public static function firstLower(string $s): string
{
return self::lower(self::substring($s, 0, 1)) . self::substring($s, 1);
}
/**
* Converts all characters of a UTF-8 string to upper case.
*/
public static function upper(string $s): string
{
return mb_strtoupper($s, 'UTF-8');
}
/**
* Converts the first character of a UTF-8 string to upper case and leaves the other characters unchanged.
*/
public static function firstUpper(string $s): string
{
return self::upper(self::substring($s, 0, 1)) . self::substring($s, 1);
}
/**
* Converts the first character of every word of a UTF-8 string to upper case and the others to lower case.
*/
public static function capitalize(string $s): string
{
return mb_convert_case($s, MB_CASE_TITLE, 'UTF-8');
}
/**
* Compares two UTF-8 strings or their parts, without taking character case into account. If length is null, whole strings are compared,
* if it is negative, the corresponding number of characters from the end of the strings is compared,
* otherwise the appropriate number of characters from the beginning is compared.
*/
public static function compare(string $left, string $right, ?int $length = null): bool
{
if (class_exists('Normalizer', autoload: false)) {
$left = \Normalizer::normalize($left, \Normalizer::FORM_D); // form NFD is faster
$right = \Normalizer::normalize($right, \Normalizer::FORM_D); // form NFD is faster
}
if ($length < 0) {
$left = self::substring($left, $length, -$length);
$right = self::substring($right, $length, -$length);
} elseif ($length !== null) {
$left = self::substring($left, 0, $length);
$right = self::substring($right, 0, $length);
}
return self::lower($left) === self::lower($right);
}
/**
* Finds the common prefix of strings or returns empty string if the prefix was not found.
* @param string[] $strings
*/
public static function findPrefix(array $strings): string
{
$first = array_shift($strings);
if ($first === null) {
return '';
}
for ($i = 0; $i < strlen($first); $i++) {
foreach ($strings as $s) {
if (!isset($s[$i]) || $first[$i] !== $s[$i]) {
while ($i && $first[$i - 1] >= "\x80" && $first[$i] >= "\x80" && $first[$i] < "\xC0") {
$i--;
}
return substr($first, 0, $i);
}
}
}
return $first;
}
/**
* Returns number of characters (not bytes) in UTF-8 string.
* That is the number of Unicode code points which may differ from the number of graphemes.
*/
public static function length(string $s): int
{
return match (true) {
extension_loaded('mbstring') => (int) mb_strlen($s, 'UTF-8'),
extension_loaded('iconv') => (int) iconv_strlen($s, 'UTF-8'),
default => strlen(@utf8_decode($s)), // deprecated
};
}
/**
* Removes all left and right side spaces (or the characters passed as second argument) from a UTF-8 encoded string.
*/
public static function trim(string $s, string $charlist = self::TrimCharacters): string
{
$charlist = preg_quote($charlist, '#');
return self::replace($s, '#^[' . $charlist . ']+|[' . $charlist . ']+$#Du', '');
}
/**
* Pads a UTF-8 string to given length by prepending the $pad string to the beginning.
* @param non-empty-string $pad
*/
public static function padLeft(string $s, int $length, string $pad = ' '): string
{
$length = max(0, $length - self::length($s));
$padLen = self::length($pad);
return str_repeat($pad, (int) ($length / $padLen)) . self::substring($pad, 0, $length % $padLen) . $s;
}
/**
* Pads UTF-8 string to given length by appending the $pad string to the end.
* @param non-empty-string $pad
*/
public static function padRight(string $s, int $length, string $pad = ' '): string
{
$length = max(0, $length - self::length($s));
$padLen = self::length($pad);
return $s . str_repeat($pad, (int) ($length / $padLen)) . self::substring($pad, 0, $length % $padLen);
}
/**
* Reverses UTF-8 string.
*/
public static function reverse(string $s): string
{
if (!extension_loaded('iconv')) {
throw new Nette\NotSupportedException(__METHOD__ . '() requires ICONV extension that is not loaded.');
}
$tmp = iconv('UTF-8', 'UTF-32BE', $s);
return $tmp === false
? throw new Nette\InvalidStateException('iconv() failed.')
: (string) iconv('UTF-32LE', 'UTF-8', strrev($tmp));
}
/**
* Returns part of $haystack before $nth occurence of $needle or returns null if the needle was not found.
* Negative value means searching from the end.
*/
public static function before(string $haystack, string $needle, int $nth = 1): ?string
{
$pos = self::pos($haystack, $needle, $nth);
return $pos === null
? null
: substr($haystack, 0, $pos);
}
/**
* Returns part of $haystack after $nth occurence of $needle or returns null if the needle was not found.
* Negative value means searching from the end.
*/
public static function after(string $haystack, string $needle, int $nth = 1): ?string
{
$pos = self::pos($haystack, $needle, $nth);
return $pos === null
? null
: substr($haystack, $pos + strlen($needle));
}
/**
* Returns position in characters of $nth occurence of $needle in $haystack or null if the $needle was not found.
* Negative value of `$nth` means searching from the end.
*/
public static function indexOf(string $haystack, string $needle, int $nth = 1): ?int
{
$pos = self::pos($haystack, $needle, $nth);
return $pos === null
? null
: self::length(substr($haystack, 0, $pos));
}
/**
* Returns position in characters of $nth occurence of $needle in $haystack or null if the needle was not found.
*/
private static function pos(string $haystack, string $needle, int $nth = 1): ?int
{
if (!$nth) {
return null;
} elseif ($nth > 0) {
if ($needle === '') {
return 0;
}
$pos = 0;
while (($pos = strpos($haystack, $needle, $pos)) !== false && --$nth) {
$pos++;
}
} else {
$len = strlen($haystack);
if ($needle === '') {
return $len;
} elseif ($len === 0) {
return null;
}
$pos = $len - 1;
while (($pos = strrpos($haystack, $needle, $pos - $len)) !== false && ++$nth) {
$pos--;
}
}
return Helpers::falseToNull($pos);
}
/**
* Splits the string by a regular expression. Expressions in parentheses will be captured and returned as well.
* @return list<string>
*/
public static function split(
string $subject,
#[Language('RegExp')]
string $pattern,
bool|int $captureOffset = false,
bool $skipEmpty = false,
int $limit = -1,
bool $utf8 = false,
): array
{
$flags = is_int($captureOffset) // back compatibility
? $captureOffset
: ($captureOffset ? PREG_SPLIT_OFFSET_CAPTURE : 0) | ($skipEmpty ? PREG_SPLIT_NO_EMPTY : 0);
$pattern .= $utf8 ? 'u' : '';
$m = self::pcre('preg_split', [$pattern, $subject, $limit, $flags | PREG_SPLIT_DELIM_CAPTURE]);
return $utf8 && $captureOffset
? self::bytesToChars($subject, [$m])[0]
: $m;
}
/**
* Searches the string for the first match of the regular expression and returns
* an array with the found expression and individual subexpressions, or null.
* @return ?array<string>
*/
public static function match(
string $subject,
#[Language('RegExp')]
string $pattern,
bool|int $captureOffset = false,
int $offset = 0,
bool $unmatchedAsNull = false,
bool $utf8 = false,
): ?array
{
$flags = is_int($captureOffset) // back compatibility
? $captureOffset
: ($captureOffset ? PREG_OFFSET_CAPTURE : 0) | ($unmatchedAsNull ? PREG_UNMATCHED_AS_NULL : 0);
if ($utf8) {
$offset = strlen(self::substring($subject, 0, $offset));
$pattern .= 'u';
}
$m = [];
if ($offset > strlen($subject)) {
return null;
} elseif (!self::pcre('preg_match', [$pattern, $subject, &$m, $flags, $offset])) {
return null;
} elseif ($utf8 && $captureOffset) {
return self::bytesToChars($subject, [$m])[0];
} else {
return $m;
}
}
/**
* Searches the string for all occurrences matching the regular expression and returns
* an array of arrays containing the found expression and each subexpression.
* @return ($lazy is true ? \Generator<int, array<string>> : list<array<string>>)
*/
public static function matchAll(
string $subject,
#[Language('RegExp')]
string $pattern,
bool|int $captureOffset = false,
int $offset = 0,
bool $unmatchedAsNull = false,
bool $patternOrder = false,
bool $utf8 = false,
bool $lazy = false,
): array|\Generator
{
if ($utf8) {
$offset = strlen(self::substring($subject, 0, $offset));
$pattern .= 'u';
}
if ($lazy) {
$flags = PREG_OFFSET_CAPTURE | ($unmatchedAsNull ? PREG_UNMATCHED_AS_NULL : 0);
return (function () use ($utf8, $captureOffset, $flags, $subject, $pattern, $offset) {
$counter = 0;
$m = [];
while (
$offset <= strlen($subject) - ($counter ? 1 : 0)
&& self::pcre('preg_match', [$pattern, $subject, &$m, $flags, $offset])
) {
/** @var list<array{string, int}> $m */
$offset = $m[0][1] + max(1, strlen($m[0][0]));
if (!$captureOffset) {
$m = array_map(fn($item) => $item[0], $m);
} elseif ($utf8) {
$m = self::bytesToChars($subject, [$m])[0];
}
yield $counter++ => $m;
}
})();
}
if ($offset > strlen($subject)) {
return [];
}
$flags = is_int($captureOffset) // back compatibility
? $captureOffset
: ($captureOffset ? PREG_OFFSET_CAPTURE : 0) | ($unmatchedAsNull ? PREG_UNMATCHED_AS_NULL : 0) | ($patternOrder ? PREG_PATTERN_ORDER : 0);
$m = [];
self::pcre('preg_match_all', [
$pattern, $subject, &$m,
($flags & PREG_PATTERN_ORDER) ? $flags : ($flags | PREG_SET_ORDER),
$offset,
]);
return $utf8 && $captureOffset
? self::bytesToChars($subject, $m)
: $m;
}
/**
* Replaces all occurrences matching the regular expression $pattern, which can be a string or array in the form `pattern => replacement`.
* @param string|array<string, string> $pattern
*/
public static function replace(
string $subject,
#[Language('RegExp')]
string|array $pattern,
string|callable $replacement = '',
int $limit = -1,
bool $captureOffset = false,
bool $unmatchedAsNull = false,
bool $utf8 = false,
): string
{
if (is_object($replacement) || is_array($replacement)) {
if (!is_callable($replacement, false, $textual)) {
throw new Nette\InvalidStateException("Callback '$textual' is not callable.");
}
$flags = ($captureOffset ? PREG_OFFSET_CAPTURE : 0) | ($unmatchedAsNull ? PREG_UNMATCHED_AS_NULL : 0);
if ($utf8) {
$pattern = is_array($pattern) ? array_map(fn($item) => $item . 'u', $pattern) : $pattern . 'u';
if ($captureOffset) {
$replacement = fn($m) => $replacement(self::bytesToChars($subject, [$m])[0]);
}
}
return self::pcre('preg_replace_callback', [$pattern, $replacement, $subject, $limit, 0, $flags]);
} elseif (is_array($pattern) && is_string(key($pattern))) {
$replacement = array_values($pattern);
$pattern = array_keys($pattern);
}
if ($utf8) {
$pattern = array_map(fn($item) => $item . 'u', (array) $pattern);
}
return self::pcre('preg_replace', [$pattern, $replacement, $subject, $limit]);
}
/**
* @param list<array<array{string, int}>> $groups
* @return list<array<array{string, int}>>
*/
private static function bytesToChars(string $s, array $groups): array
{
$lastBytes = $lastChars = 0;
foreach ($groups as &$matches) {
foreach ($matches as &$match) {
if ($match[1] > $lastBytes) {
$lastChars += self::length(substr($s, $lastBytes, $match[1] - $lastBytes));
} elseif ($match[1] < $lastBytes) {
$lastChars -= self::length(substr($s, $match[1], $lastBytes - $match[1]));
}
$lastBytes = $match[1];
$match[1] = $lastChars;
}
}
return $groups;
}
/**
* @param callable-string $func
* @param list<mixed> $args
* @internal
*/
public static function pcre(string $func, array $args): mixed
{
$res = Callback::invokeSafe($func, $args, function (string $message) use ($args): void {
// compile-time error, not detectable by preg_last_error
throw new RegexpException($message . ' in pattern: ' . implode(' or ', (array) $args[0]));
});
if (($code = preg_last_error()) // run-time error, but preg_last_error & return code are liars
&& ($res === null || !in_array($func, ['preg_filter', 'preg_replace_callback', 'preg_replace'], strict: true))
) {
throw new RegexpException(preg_last_error_msg()
. ' (pattern: ' . implode(' or ', (array) $args[0]) . ')', $code);
}
return $res;
}
}

View File

@ -1,314 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette;
use function array_map, array_search, array_splice, array_values, count, explode, implode, is_a, is_resource, is_string, strcasecmp, strtolower, substr, trim;
/**
* PHP type reflection.
*/
final readonly class Type
{
/** @var list<string|self> */
private array $types;
private ?string $singleName;
private string $kind; // | &
/**
* Creates a Type object based on reflection. Resolves self, static and parent to the actual class name.
* If the subject has no type, it returns null.
*/
public static function fromReflection(
\ReflectionFunctionAbstract|\ReflectionParameter|\ReflectionProperty $reflection,
): ?self
{
$type = $reflection instanceof \ReflectionFunctionAbstract
? $reflection->getReturnType() ?? ($reflection instanceof \ReflectionMethod ? $reflection->getTentativeReturnType() : null)
: $reflection->getType();
return $type ? self::fromReflectionType($type, $reflection, asObject: true) : null;
}
/** @return ($asObject is true ? self : self|string) */
private static function fromReflectionType(
\ReflectionType $type,
\ReflectionFunctionAbstract|\ReflectionParameter|\ReflectionProperty $of,
bool $asObject,
): self|string
{
if ($type instanceof \ReflectionNamedType) {
$name = self::resolve($type->getName(), $of);
return $asObject
? new self($type->allowsNull() && $name !== 'mixed' ? [$name, 'null'] : [$name])
: $name;
} elseif ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) {
return new self(
array_map(fn($t) => self::fromReflectionType($t, $of, asObject: false), $type->getTypes()),
$type instanceof \ReflectionUnionType ? '|' : '&',
);
} else {
throw new Nette\InvalidStateException('Unexpected type of ' . Reflection::toString($of));
}
}
/**
* Creates the Type object according to the text notation.
*/
public static function fromString(string $type): self
{
if (!Validators::isTypeDeclaration($type)) {
throw new Nette\InvalidArgumentException("Invalid type '$type'.");
}
if ($type[0] === '?') {
return new self([substr($type, 1), 'null']);
}
$unions = [];
foreach (explode('|', $type) as $part) {
$part = explode('&', trim($part, '()'));
$unions[] = count($part) === 1 ? $part[0] : new self($part, '&');
}
return count($unions) === 1 && $unions[0] instanceof self
? $unions[0]
: new self($unions);
}
/**
* Creates a Type object based on the actual type of value.
*/
public static function fromValue(mixed $value): self
{
$type = get_debug_type($value);
if (is_resource($value)) {
$type = 'mixed';
} elseif (str_ends_with($type, '@anonymous')) {
$parent = substr($type, 0, -10);
$type = $parent === 'class' ? 'object' : $parent;
}
return new self([$type]);
}
/**
* Resolves 'self', 'static' and 'parent' to the actual class name.
*/
public static function resolve(
string $type,
\ReflectionFunction|\ReflectionMethod|\ReflectionParameter|\ReflectionProperty $of,
): string
{
$lower = strtolower($type);
if ($of instanceof \ReflectionFunction) {
return $type;
}
$class = $of->getDeclaringClass();
if ($class === null) {
return $type;
} elseif ($lower === 'self') {
return $class->name;
} elseif ($lower === 'static') {
return ($of instanceof ReflectionMethod ? $of->getOriginalClass() : $class)->name;
} elseif ($lower === 'parent' && $class->getParentClass()) {
return $class->getParentClass()->name;
} else {
return $type;
}
}
/** @param array<string|self> $types */
private function __construct(array $types, string $kind = '|')
{
$o = array_search('null', $types, strict: true);
if ($o !== false) { // null as last
array_splice($types, (int) $o, 1);
$types[] = 'null';
}
$this->types = array_values($types);
$this->singleName = is_string($types[0]) && ($types[1] ?? 'null') === 'null' ? $types[0] : null;
$this->kind = count($types) > 1 ? $kind : '';
}
public function __toString(): string
{
$multi = count($this->types) > 1;
if ($this->singleName !== null) {
return ($multi ? '?' : '') . $this->singleName;
}
$res = [];
foreach ($this->types as $type) {
$res[] = $type instanceof self && $multi ? "($type)" : $type;
}
return implode($this->kind, $res);
}
/**
* Returns a union type that accepts both the current type and the given type.
*/
public function with(string|self $type): self
{
$type = is_string($type) ? self::fromString($type) : $type;
return match (true) {
$this->allows($type) => $this,
$type->allows($this) => $type,
default => new self(array_unique(
array_merge($this->isIntersection() ? [$this] : $this->types, $type->isIntersection() ? [$type] : $type->types),
SORT_REGULAR,
), '|'),
};
}
/**
* Returns the array of subtypes that make up the compound type as strings.
* @return list<string|array<string|array<mixed>>>
*/
public function getNames(): array
{
return array_map(fn($t) => $t instanceof self ? $t->getNames() : $t, $this->types);
}
/**
* Returns the array of subtypes that make up the compound type as Type objects.
* @return list<self>
*/
public function getTypes(): array
{
return array_map(fn($t) => $t instanceof self ? $t : new self([$t]), $this->types);
}
/**
* Returns the type name for simple types, otherwise null.
*/
public function getSingleName(): ?string
{
return $this->singleName;
}
/**
* Returns true whether it is a union type.
*/
public function isUnion(): bool
{
return $this->kind === '|';
}
/**
* Returns true whether it is an intersection type.
*/
public function isIntersection(): bool
{
return $this->kind === '&';
}
/**
* Checks whether it is a simple (non-compound) type. Single nullable types such as ?int are also considered simple.
*/
public function isSimple(): bool
{
return $this->singleName !== null;
}
#[\Deprecated('use isSimple()')]
public function isSingle(): bool
{
return $this->singleName !== null;
}
/**
* Checks whether it is a simple PHP built-in type (int, string, bool, etc.).
*/
public function isBuiltin(): bool
{
return $this->singleName !== null && Validators::isBuiltinType($this->singleName);
}
/**
* Checks whether it is a simple class or interface name (not a built-in type).
*/
public function isClass(): bool
{
return $this->singleName !== null && !Validators::isBuiltinType($this->singleName);
}
/**
* Determines if type is special class name self/parent/static.
*/
public function isClassKeyword(): bool
{
return $this->singleName !== null && Validators::isClassKeyword($this->singleName);
}
/**
* Checks whether a value of the given type could be assigned to this type.
*/
public function allows(string|self $type): bool
{
if ($this->types === ['mixed']) {
return true;
}
$type = is_string($type) ? self::fromString($type) : $type;
return $type->isUnion()
? Arrays::every($type->types, fn($t) => $this->allowsAny($t instanceof self ? $t->types : [$t]))
: $this->allowsAny($type->types);
}
/** @param array<string> $givenTypes */
private function allowsAny(array $givenTypes): bool
{
return $this->isUnion()
? Arrays::some($this->types, fn($t) => $this->allowsAll($t instanceof self ? $t->types : [$t], $givenTypes))
: $this->allowsAll($this->types, $givenTypes);
}
/**
* @param array<string> $ourTypes
* @param array<string> $givenTypes
*/
private function allowsAll(array $ourTypes, array $givenTypes): bool
{
return Arrays::every(
$ourTypes,
fn(string $ourType) => Arrays::some(
$givenTypes,
fn(string $givenType) => Validators::isBuiltinType($ourType)
? strcasecmp($ourType, $givenType) === 0
: is_a($givenType, $ourType, allow_string: true),
),
);
}
}

View File

@ -1,416 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette;
use function array_key_exists, class_exists, explode, gettype, interface_exists, is_callable, is_float, is_int, is_iterable, is_numeric, is_object, is_string, preg_match, str_ends_with, str_replace, str_starts_with, strlen, strtolower, substr, trait_exists, var_export;
/**
* Validation utilities.
*/
class Validators
{
use Nette\StaticClass;
private const BuiltinTypes = [
'string' => 1, 'int' => 1, 'float' => 1, 'bool' => 1, 'array' => 1, 'object' => 1,
'callable' => 1, 'iterable' => 1, 'void' => 1, 'null' => 1, 'mixed' => 1, 'false' => 1,
'never' => 1, 'true' => 1,
];
/** @var array<string, ?(callable(mixed): bool)> */
protected static $validators = [
// PHP types
'array' => 'is_array',
'bool' => 'is_bool',
'boolean' => 'is_bool',
'float' => 'is_float',
'int' => 'is_int',
'integer' => 'is_int',
'null' => 'is_null',
'object' => 'is_object',
'resource' => 'is_resource',
'scalar' => 'is_scalar',
'string' => 'is_string',
// pseudo-types
'callable' => [self::class, 'isCallable'],
'iterable' => 'is_iterable',
'list' => [Arrays::class, 'isList'],
'mixed' => [self::class, 'isMixed'],
'none' => [self::class, 'isNone'],
'number' => [self::class, 'isNumber'],
'numeric' => [self::class, 'isNumeric'],
'numericint' => [self::class, 'isNumericInt'],
// string patterns
'alnum' => 'ctype_alnum',
'alpha' => 'ctype_alpha',
'digit' => 'ctype_digit',
'lower' => 'ctype_lower',
'pattern' => null,
'space' => 'ctype_space',
'unicode' => [self::class, 'isUnicode'],
'upper' => 'ctype_upper',
'xdigit' => 'ctype_xdigit',
// syntax validation
'email' => [self::class, 'isEmail'],
'identifier' => [self::class, 'isPhpIdentifier'],
'uri' => [self::class, 'isUri'],
'url' => [self::class, 'isUrl'],
// environment validation
'class' => 'class_exists',
'interface' => 'interface_exists',
'directory' => 'is_dir',
'file' => 'is_file',
'type' => [self::class, 'isType'],
];
/** @var array<string, callable(mixed): int> */
protected static $counters = [
'string' => 'strlen',
'unicode' => [Strings::class, 'length'],
'array' => 'count',
'list' => 'count',
'alnum' => 'strlen',
'alpha' => 'strlen',
'digit' => 'strlen',
'lower' => 'strlen',
'space' => 'strlen',
'upper' => 'strlen',
'xdigit' => 'strlen',
];
/**
* Verifies that the value is of expected types separated by pipe.
* @throws AssertionException
*/
public static function assert(mixed $value, string $expected, string $label = 'variable'): void
{
if (!static::is($value, $expected)) {
$expected = str_replace(['|', ':'], [' or ', ' in range '], $expected);
$translate = ['boolean' => 'bool', 'integer' => 'int', 'double' => 'float', 'NULL' => 'null'];
$type = $translate[gettype($value)] ?? gettype($value);
if (is_int($value) || is_float($value) || (is_string($value) && strlen($value) < 40)) {
$type .= ' ' . var_export($value, return: true);
} elseif (is_object($value)) {
$type .= ' ' . $value::class;
}
throw new AssertionException("The $label expects to be $expected, $type given.");
}
}
/**
* Verifies that item $key in array exists and is of expected types separated by pipe.
* @param mixed[] $array
* @throws AssertionException
*/
public static function assertField(
array $array,
int|string $key,
?string $expected = null,
string $label = "item '%' in array",
): void
{
if (!array_key_exists($key, $array)) {
throw new AssertionException('Missing ' . str_replace('%', (string) $key, $label) . '.');
} elseif ($expected) {
static::assert($array[$key], $expected, str_replace('%', (string) $key, $label));
}
}
/**
* Verifies that the value is of expected types separated by pipe.
*/
public static function is(mixed $value, string $expected): bool
{
foreach (explode('|', $expected) as $item) {
if (str_ends_with($item, '[]')) {
if (is_iterable($value) && self::everyIs($value, substr($item, 0, -2))) {
return true;
}
continue;
} elseif (str_starts_with($item, '?')) {
$item = substr($item, 1);
if ($value === null) {
return true;
}
}
[$type] = $item = explode(':', $item, 2);
if (isset(static::$validators[$type])) {
try {
if (!static::$validators[$type]($value)) {
continue;
}
} catch (\TypeError) {
continue;
}
} elseif ($type === 'pattern') {
if (Strings::match($value, '|^' . ($item[1] ?? '') . '$|D')) {
return true;
}
continue;
} elseif (!$value instanceof $type) {
continue;
}
if (isset($item[1])) {
$length = $value;
if (isset(static::$counters[$type])) {
$length = static::$counters[$type]($value);
}
$range = explode('..', $item[1]);
if (!isset($range[1])) {
$range[1] = $range[0];
}
if (($range[0] !== '' && $length < $range[0]) || ($range[1] !== '' && $length > $range[1])) {
continue;
}
}
return true;
}
return false;
}
/**
* Finds whether all values are of expected types separated by pipe.
* @param iterable<mixed> $values
*/
public static function everyIs(iterable $values, string $expected): bool
{
foreach ($values as $value) {
if (!static::is($value, $expected)) {
return false;
}
}
return true;
}
/**
* Checks if the value is an integer or a float.
* @return ($value is int|float ? true : false)
*/
public static function isNumber(mixed $value): bool
{
return is_int($value) || is_float($value);
}
/**
* Checks if the value is an integer or a integer written in a string.
* @return ($value is non-empty-string ? bool : ($value is int ? true : false))
*/
public static function isNumericInt(mixed $value): bool
{
return is_int($value) || (is_string($value) && preg_match('#^[+-]?[0-9]+$#D', $value));
}
/**
* Checks if the value is a number or a number written in a string.
* @return ($value is non-empty-string ? bool : ($value is int|float ? true : false))
*/
public static function isNumeric(mixed $value): bool
{
return is_float($value) || is_int($value) || (is_string($value) && preg_match('#^[+-]?([0-9]++\.?[0-9]*|\.[0-9]+)$#D', $value));
}
/**
* Checks if the value is a syntactically correct callback.
*/
public static function isCallable(mixed $value): bool
{
return $value && is_callable($value, syntax_only: true);
}
/**
* Checks if the value is a valid UTF-8 string.
*/
public static function isUnicode(mixed $value): bool
{
return is_string($value) && preg_match('##u', $value);
}
/**
* Checks if the value is 0, '', false or null.
* @return ($value is 0|0.0|''|false|null ? true : false)
*/
public static function isNone(mixed $value): bool
{
return $value == null; // intentionally ==
}
/** @internal */
public static function isMixed(): bool
{
return true;
}
/**
* Checks if a variable is a zero-based integer indexed array.
* @deprecated use Nette\Utils\Arrays::isList
* @return ($value is list ? true : false)
*/
public static function isList(mixed $value): bool
{
return Arrays::isList($value);
}
/**
* Checks if the value is in the given range [min, max], where the upper or lower limit can be omitted (null).
* Numbers, strings and DateTime objects can be compared.
* @param array{int|float|string|\DateTimeInterface|null, int|float|string|\DateTimeInterface|null} $range
*/
public static function isInRange(mixed $value, array $range): bool
{
if ($value === null || !(isset($range[0]) || isset($range[1]))) {
return false;
}
$limit = $range[0] ?? $range[1];
if (is_string($limit)) {
$value = (string) $value;
} elseif ($limit instanceof \DateTimeInterface) {
if (!$value instanceof \DateTimeInterface) {
return false;
}
} elseif (is_numeric($value)) {
$value *= 1;
} else {
return false;
}
return (!isset($range[0]) || ($value >= $range[0])) && (!isset($range[1]) || ($value <= $range[1]));
}
/**
* Checks if the value is a valid email address. It does not verify that the domain actually exists, only the syntax is verified.
*/
public static function isEmail(string $value): bool
{
$atom = "[-a-z0-9!#$%&'*+/=?^_`{|}~]"; // RFC 5322 unquoted characters in local-part
$alpha = "a-z\x80-\xFF"; // superset of IDN
return (bool) preg_match(<<<XX
(^(?n)
("([ !#-[\\]-~]*|\\\\[ -~])+"|$atom+(\\.$atom+)*) # quoted or unquoted
@
([0-9$alpha]([-0-9$alpha]{0,61}[0-9$alpha])?\\.)+ # domain - RFC 1034
[$alpha]([-0-9$alpha]{0,17}[$alpha])? # top domain
$)Dix
XX, $value);
}
/**
* Checks if the value is a valid URL address.
*/
public static function isUrl(string $value): bool
{
$alpha = "a-z\x80-\xFF";
return (bool) preg_match(<<<XX
(^(?n)
https?://(
(([-_0-9$alpha]+\\.)* # subdomain
[0-9$alpha]([-0-9$alpha]{0,61}[0-9$alpha])?\\.)? # domain
[$alpha]([-0-9$alpha]{0,17}[$alpha])? # top domain
|\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3} # IPv4
|\\[[0-9a-f:]{3,39}\\] # IPv6
)(:\\d{1,5})? # port
(/\\S*)? # path
(\\?\\S*)? # query
(\\#\\S*)? # fragment
$)Dix
XX, $value);
}
/**
* Checks if the value is a valid URI address, that is, actually a string beginning with a syntactically valid schema.
*/
public static function isUri(string $value): bool
{
return (bool) preg_match('#^[a-z\d+\.-]+:\S+$#Di', $value);
}
/**
* Checks whether the input is a class, interface or trait.
* @deprecated
*/
public static function isType(string $type): bool
{
return class_exists($type) || interface_exists($type) || trait_exists($type);
}
/**
* Checks whether the input is a valid PHP identifier.
*/
public static function isPhpIdentifier(string $value): bool
{
return preg_match('#^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$#D', $value) === 1;
}
/**
* Determines if type is PHP built-in type. Otherwise, it is the class name.
*/
public static function isBuiltinType(string $type): bool
{
return isset(self::BuiltinTypes[strtolower($type)]);
}
/**
* Determines if type is special class name self/parent/static.
*/
public static function isClassKeyword(string $name): bool
{
return (bool) preg_match('#^(self|parent|static)$#Di', $name);
}
/**
* Checks whether the given type declaration is syntactically valid.
*/
public static function isTypeDeclaration(string $type): bool
{
return (bool) preg_match(<<<'XX'
~((?n)
\?? (?<type> \\? (?<name> [a-zA-Z_\x7f-\xff][\w\x7f-\xff]*) (\\ (?&name))* ) |
(?<intersection> (?&type) (& (?&type))+ ) |
(?<upart> (?&type) | \( (?&intersection) \) ) (\| (?&upart))+
)$~xAD
XX, $type);
}
}

View File

@ -1,64 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
/**
* An error occurred while working with the image.
*/
class ImageException extends \Exception
{
}
/**
* The image file is invalid or in an unsupported format.
*/
class UnknownImageFileException extends ImageException
{
}
/**
* JSON encoding or decoding failed.
*/
class JsonException extends \JsonException
{
}
/**
* Regular expression pattern or execution failed.
*/
class RegexpException extends \Exception
{
}
/**
* Type validation failed. The value doesn't match the expected type constraints.
*/
class AssertionException extends \Exception
{
}
/**
* The process failed to run successfully.
*/
class ProcessFailedException extends \RuntimeException
{
}
/**
* The process execution exceeded its timeout limit.
*/
class ProcessTimeoutException extends \RuntimeException
{
}

View File

@ -1,30 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette;
if (false) {
/** @deprecated use Nette\HtmlStringable */
interface IHtmlString extends Nette\HtmlStringable
{
}
} elseif (!interface_exists(IHtmlString::class)) {
class_alias(Nette\HtmlStringable::class, IHtmlString::class);
}
namespace Nette\Localization;
if (false) {
/** @deprecated use Nette\Localization\Translator */
interface ITranslator extends Translator
{
}
} elseif (!interface_exists(ITranslator::class)) {
class_alias(Translator::class, ITranslator::class);
}

View File

@ -1,112 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette;
/**
* The value is outside the allowed range.
*/
class ArgumentOutOfRangeException extends \InvalidArgumentException
{
}
/**
* The object is in a state that does not allow the requested operation.
*/
class InvalidStateException extends \RuntimeException
{
}
/**
* The requested feature is not implemented.
*/
class NotImplementedException extends \LogicException
{
}
/**
* The requested operation is not supported.
*/
class NotSupportedException extends \LogicException
{
}
/**
* The requested feature is deprecated and no longer available.
*/
class DeprecatedException extends NotSupportedException
{
}
/**
* Cannot access the requested class property or method.
*/
class MemberAccessException extends \Error
{
}
/**
* Failed to read from or write to a file or stream.
*/
class IOException extends \RuntimeException
{
}
/**
* The requested file does not exist.
*/
class FileNotFoundException extends IOException
{
}
/**
* The requested directory does not exist.
*/
class DirectoryNotFoundException extends IOException
{
}
/**
* The provided argument has invalid type or format.
*/
class InvalidArgumentException extends \InvalidArgumentException
{
}
/**
* The requested array or collection index does not exist.
*/
class OutOfRangeException extends \OutOfRangeException
{
}
/**
* The returned value has unexpected type or format.
*/
class UnexpectedValueException extends \UnexpectedValueException
{
}
/**
* Houston, we have a problem.
*/
class ShouldNotHappenException extends \LogicException
{
}

View File

@ -8,13 +8,14 @@
"keywords": ["static analysis"],
"require": {
"php": "^7.4 || ^8.0",
"phpstan/phpstan": "^2.1.39"
"phpstan/phpstan": "^2.1.52"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^9.6"
"phpunit/phpunit": "^9.6",
"shipmonk/name-collision-detector": "^2.1"
},
"config": {
"platform": {

View File

@ -3,6 +3,7 @@
namespace PHPStan\Rules\Operators;
use PhpParser\Node\Expr;
use PhpParser\Node\Scalar\Int_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\RuleLevelHelper;
use PHPStan\Type\Accessory\AccessoryNumericStringType;
@ -38,7 +39,20 @@ class OperatorRuleHelper
return true;
}
return $this->isSubtypeOfNumber($scope, $expr);
if ($this->isSubtypeOfNumber($scope, $expr)) {
return true;
}
// Check if the type supports arithmetic via operator overloading extensions
// (only applies to object types like GMP, BCMath\Number)
if ($type->isObject()->yes()) {
$resultType = $scope->getType(new Expr\BinaryOp\Plus($expr, new Int_(1)));
if (!$resultType instanceof ErrorType) {
return true;
}
}
return false;
}
public function isValidForIncrement(Scope $scope, Expr $expr): bool
@ -53,7 +67,18 @@ class OperatorRuleHelper
return true;
}
return $this->isSubtypeOfNumber($scope, $expr);
if ($this->isSubtypeOfNumber($scope, $expr)) {
return true;
}
if ($type->isObject()->yes()) {
$resultType = $scope->getType(new Expr\PreInc($expr));
if (!$resultType instanceof ErrorType) {
return true;
}
}
return false;
}
public function isValidForDecrement(Scope $scope, Expr $expr): bool
@ -63,7 +88,18 @@ class OperatorRuleHelper
return true;
}
return $this->isSubtypeOfNumber($scope, $expr);
if ($this->isSubtypeOfNumber($scope, $expr)) {
return true;
}
if ($type->isObject()->yes()) {
$resultType = $scope->getType(new Expr\PreDec($expr));
if (!$resultType instanceof ErrorType) {
return true;
}
}
return false;
}
private function isSubtypeOfNumber(Scope $scope, Expr $expr): bool

View File

@ -0,0 +1,115 @@
# PHPStan - PHP Static Analysis Tool
## Project Overview
PHPStan finds errors in PHP code without running it. It catches bugs before tests are written, moving PHP closer to compiled languages. This is the **distribution repository** — the compiled PHAR and supporting infrastructure. The actual source code lives at [phpstan/phpstan-src](https://github.com/phpstan/phpstan-src).
- **Website:** https://phpstan.org/
- **Documentation:** https://phpstan.org/user-guide/getting-started
- **API Reference:** https://apiref.phpstan.org/
## Repository Structure
```
├── phpstan # CLI entry point (shell script loading the PHAR)
├── phpstan.phar # Compiled PHAR archive (~26 MB)
├── phpstan.phar.asc # GPG signature for the PHAR
├── bootstrap.php # PHAR autoloader with PHP version polyfills
├── composer.json # Package definition (requires PHP ^7.4|^8.0)
├── .phar-checksum # MD5 + SHA1 checksums for reproducible builds
├── conf/
│ └── bleedingEdge.neon # Bleeding edge configuration profile
├── e2e/ # End-to-end tests (~67 test scenarios)
├── docker/ # Dockerfiles for PHP 8.08.4
├── identifier-extractor/ # Tool to extract error identifiers from rule source code
├── playground-api/ # AWS Lambda API for the online playground (TypeScript)
├── playground-runner/ # AWS Lambda runner for the playground (PHP/Bref)
├── website/ # phpstan.org static site (Eleventy + Vite + TailwindCSS)
└── .github/workflows/ # CI/CD workflows
```
## Key Concepts
### Distribution Model
This repository distributes a pre-built PHAR archive. The source code is in [phpstan/phpstan-src](https://github.com/phpstan/phpstan-src), which is on PHP 8.1+ internally but the PHAR build is downgraded to support PHP 7.4+. The `phpstan` script loads the PHAR and delegates to the bundled binary.
### PHP Version Support
- **This distribution package:** PHP ^7.4|^8.0 (defined in composer.json)
- **phpstan-src internally:** PHP 8.1+ (downgraded during PHAR build)
- **Other extension repositories** (phpstan-strict-rules, phpstan-doctrine, phpstan-symfony, etc.): still support PHP 7.4+
- **E2E tests run on:** PHP 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5 (Linux + Windows)
### Bleeding Edge
The `conf/bleedingEdge.neon` configuration enables experimental/strict checks before they become defaults. Users opt in by including this config.
### Error Identifiers
Every PHPStan error has a unique identifier (e.g., `argument.type`, `deadCode.unreachable`). The `identifier-extractor/` tool scans all PHPStan repositories to extract these identifiers, producing JSON used to generate documentation on the website.
## Development
### CI Workflows
Key workflows in `.github/workflows/`:
- **`tests.yml`** — Runs e2e tests on PHP 7.48.5, Linux + Windows. Triggered on changes to `e2e/**`, `phpstan`, `.phar-checksum`, `bootstrap.php`.
- **`other-tests.yml`** — Additional integration tests (PHP-Parser, React Promise, etc.)
- **`integration-tests.yml`** — Tests against major projects (Rector, Larastan, Carbon, etc.)
- **`extension-tests.yml`** — Tests 1st-party PHPStan extensions (PHPUnit, Doctrine, Symfony, etc.)
- **`release.yml`** — Creates GitHub release on tag push, uploads `phpstan.phar` and signature
- **`docker-stable.yml`** / **`docker-nightly.yml`** — Builds multi-arch Docker images (arm64 + amd64) pushed to ghcr.io
- **`extract-identifiers.yml`** — Extracts error identifiers from all PHPStan repos
- **`website.yml`** — Builds and deploys phpstan.org
- **`generate-error-docs.lock.yml`** — Generates error documentation using Claude
### Current Branch
The main development branch for this repository is `2.2.x`.
## Website
The website (phpstan.org) lives in `website/` and has its own `website/CLAUDE.md` with detailed instructions. Key points:
- Built with Eleventy (11ty) + Vite + TailwindCSS
- Two-stage build: `npm run build:11ty` then `npm run build:vite`
- Deployed to AWS S3 + CloudFront
- Error documentation in `website/errors/` is auto-generated (see `website/errors/CLAUDE.md`)
## Playground
The online playground at https://phpstan.org/try consists of two AWS Lambda services:
- **playground-api/** — TypeScript API that orchestrates analysis across multiple PHP versions
- **playground-runner/** — PHP Lambda (via Bref) that executes PHPStan against submitted code
Both are deployed via the Serverless Framework to eu-west-1.
## Docker
Dockerfiles in `docker/` build images for PHP 8.08.4, based on `php:*-cli-alpine`. Images are published to `ghcr.io` as multi-architecture (arm64 + amd64). The images install PHPStan via Composer and use `phpstan` as the entrypoint.
## Related Repositories
- [phpstan/phpstan-src](https://github.com/phpstan/phpstan-src) — Main source code (PHP 8.1+, downgraded for PHAR)
- [phpstan/phpstan-strict-rules](https://github.com/phpstan/phpstan-strict-rules) — Additional strict rules
- [phpstan/phpstan-deprecation-rules](https://github.com/phpstan/phpstan-deprecation-rules) — Deprecation detection
- [phpstan/phpstan-doctrine](https://github.com/phpstan/phpstan-doctrine) — Doctrine integration
- [phpstan/phpstan-symfony](https://github.com/phpstan/phpstan-symfony) — Symfony integration
- [phpstan/phpstan-phpunit](https://github.com/phpstan/phpstan-phpunit) — PHPUnit integration
- [phpstan/phpstan-nette](https://github.com/phpstan/phpstan-nette) — Nette integration
- [phpstan/phpstan-webmozart-assert](https://github.com/phpstan/phpstan-webmozart-assert) — Webmozart Assert integration
- [phpstan/phpdoc-parser](https://github.com/phpstan/phpdoc-parser) — PHPDoc parser library
Extension repositories support PHP 7.4+ and some support multiple versions of the libraries they analyse.
## Making Changes
- **Source code changes** belong in [phpstan/phpstan-src](https://github.com/phpstan/phpstan-src), not here
- **E2E tests** can be added or modified in `e2e/`
- **Website content** is in `website/src/` (see `website/CLAUDE.md`)
- **Error documentation** is in `website/errors/` (see `website/errors/CLAUDE.md`)
- Configuration uses NEON format (Nette Object Notation), similar to YAML

View File

@ -67,21 +67,17 @@ Want your logo here? [Learn more »](https://phpstan.org/sponsor)
<br>
<a href="https://zol.fr?utm_source=phpstan"><img src="website/src/images/sponsor/zol.png" alt="ZOL" width="283" height="64"></a>
&nbsp;&nbsp;&nbsp;
<a href="https://www.edgenext.com/"><img src="website/src/images/sponsor/edgenext.png" alt="EdgeNext" width="283" height="64"></a>
<a href="https://inviqa.com/"><img src="website/src/images/sponsor/inviqa.png" alt="Inviqa" width="254" height="65"></a>
<br>
<a href="https://route4me.com/"><img src="website/src/images/sponsor/route4me.png" alt="Route4Me: Route Optimizer and Route Planner Software" width="283" height="64"></a>
&nbsp;&nbsp;&nbsp;
<a href="https://craftcms.com/"><img src="website/src/images/sponsor/craftcms.png" alt="Craft CMS" width="283" height="64"></a>
<a href="https://www.crisp.nl/"><img src="website/src/images/sponsor/crisp.png" alt="Crisp.nl" width="283" height="64"></a>
<br>
<a href="https://jobs.ticketswap.com/"><img src="website/src/images/sponsor/ticketswap.png" alt="TicketSwap" width="269" height="64"></a>
&nbsp;&nbsp;&nbsp;
<a href="https://www.campoint.net/"><img src="website/src/images/sponsor/campoint.png" alt="campoint AG" width="283" height="64"></a>
<br>
<a href="https://www.crisp.nl/"><img src="website/src/images/sponsor/crisp.png" alt="Crisp.nl" width="283" height="64"></a>
&nbsp;&nbsp;&nbsp;
<a href="https://inviqa.com/"><img src="website/src/images/sponsor/inviqa.png" alt="Inviqa" width="254" height="65"></a>
<br>
<a href="https://www.testmuai.com/"><img src="website/src/images/sponsor/testmu-ai.png" alt="TestMu AI" width="283" height="64"></a>
[**You can sponsor my open-source work on PHPStan through GitHub Sponsors and also directly.**](https://phpstan.org/sponsor)

View File

@ -2,6 +2,17 @@
"name": "phpstan/phpstan",
"description": "PHPStan - PHP Static Analysis Tool",
"license": ["MIT"],
"authors": [
{
"name": "Ondřej Mirtes"
},
{
"name": "Markus Staab"
},
{
"name": "Vincent Langlet"
}
],
"keywords": ["dev", "static analysis"],
"require": {
"php": "^7.4|^8.0"

View File

@ -1,16 +1,16 @@
-----BEGIN PGP SIGNATURE-----
iQIzBAABCgAdFiEEynwsejDI6OEnSoR2UcZzBf/C5cAFAmoK/noACgkQUcZzBf/C
5cBmGQ/9FVTu2Fw3wDytR0viB97T+WPov7vGlzTdsfklHE2NTBKyCIDKX2lmm0Lu
dcM/yRVA0ZaPg6jcjMCm7CNr74asELrtkEKl4KWjQqa+XDAmqyNLbilahFaV9d4k
N4C4JFL5QL/GwgPaY9h7LhRgbzVpZxv2n9QsrFX2m7B9TxYiZVtK4A1UyRUa2XQh
WRgt8Nch6MAv558rbtQMyjUxmvtRmd4YSfZfmNf4tSwH68jfa5I5bPYYXm4z7gGV
Y4Gdmv8gG3ZtyFgrSPUJn7F9xKoZLHOZJxY8wC4EDItlwo7xpO7Qr2Q32Wp3s3iM
ORF1mUfeyRmVYdMCcQC3/NDA0P71/ukZEBk7SCWi5tBFcJTAPRzkBFpabJpOiWBW
2pwk8besI2fEr32O3WYf6b/oXH7SKXKGhKl2MOz3wxtxQaajpIodrGjkguup88qc
YVfNO/yO/X1ABDj5fjGzpEEsxmD4dwJ9D5MflD0dv6PVXPjZzaxt/8ETzvngEWbk
cgp8CrSy9EV0oObZZQuL33qPmkHCjHz4A+iTMyZRxrClYnCHM4ZZalobcLS/jj61
7dY8nvge/KjpKemU/KTcQkXsCFf1kA+MF3Z0Hhy4IOFWNQl3GbDW2M6HW56klZBM
FDB/+i27lZ4UI1x7RnoK0AiUR7eLcH/Ir9X9i3JErUiGcUI4a54=
=+jji
iQIzBAABCgAdFiEEynwsejDI6OEnSoR2UcZzBf/C5cAFAmpqOpYACgkQUcZzBf/C
5cCEABAAiNhRb1h8uQWaC3yY2aDdrISyH/ONSWanM56iFhsOP7n34Yyjwr/d3ywv
pFvy8CERGuIQj/3/Y4c8S84bpRZtZWOJ0+ti/dGsPG9nG4IMBpyOjaoHfqTuIqnI
qpD+2xMNG6iVhoYrpPcTi85A1RqJyh+T+7OjMml63EjhApY7A/zM+yVvktTmRBGe
d15vdOErXegl0i/2dSFgqoWmFtTNqy9twQutupjQeehrcTX7bjnqVvHL6E65dlLt
wPFjAU9CnQCkT4mmyt7tHuHuUL/uuBnJDRipB2KCO7K5rG2SbX2r6YJTt81AcvQ7
n0o6AMWf9YaTuzp73i2H1QMMsP/845CCHih8lJsg6a1VcdmEsdf+yvj4XJrjZgR1
4dKoA58zjX1SJ0k+PHl04eshWlJhXWXioUNyD2JXrlZP0l6GuXZnO7X3QYPtFcL0
C5mADqt5X1SIAiQYOyW/KjRmbt4EYjQYCBo+AQQR2yjm3FUjrfEI0rjF96/MIksp
R73S9JKS+Fo/igbI86iJoNse4+ijwyGgT3702fGqUyAPVLDZptEUMXuAnJ276RO1
URSHUXWTbPDCjoMOeOO8IcaKhCVnEElCssPsZCLZdoYWM6wM/4bdN/iOIHbIJNWL
nuw97z7kkY2rvPCHNmdSvNQrH4Xe++9TtcDParXOoelFKwnkkU0=
=YVL9
-----END PGP SIGNATURE-----

View File

@ -0,0 +1 @@
86312b7

View File

@ -138,3 +138,406 @@ vendor/bin/phpstan
<br>
Happy coding!
<br>
---
# Type Perfect
[![Downloads](https://img.shields.io/packagist/dt/rector/type-perfect.svg?style=flat-square)](https://packagist.org/packages/rector/type-perfect/stats)
Next level type declaration check PHPStan rules.
We use these sets to improve code quality of our clients' code beyond PHPStan features.
* These rules make skipped object types explicit, param types narrow and help you to fill more accurate object type hints.
* **They're easy to enable, even if your code does not pass level 0**
* They're effortless to resolve and make your code instantly more solid and reliable.
If you care about code quality and type safety, add these 10 rules to your CI.
<br>
These rules ship with the package [installed above](#install), there is nothing else to require.
*Migrating from `rector/type-perfect`? Remove it from your `composer.json`, the rules live here now.*
<br>
Every rule is opt-in and disabled by default, so you can pick the ones that fit your project. See [Configure](#configure) below for the full list.
The 3 checks below are the simplest ones to start with:
```yaml
parameters:
type_perfect:
no_isset_on_object: true
no_empty_on_object: true
no_array_access_on_object: true
no_param_type_removal: true
```
The first one makes sure we don't miss a chance to use `instanceof` to make further code know about exact object type:
```php
private ?SomeType $someType = null;
if (! empty($this->someType)) {
// ...
}
if (! isset($this->someType)) {
// ...
}
// here we only know, that $this->someType is not empty/null
```
:no_good:
```php
if (! $this->someType instanceof SomeType) {
return;
}
// here we know $this->someType is exactly SomeType
```
:heavy_check_mark:
<br>
Second rule (`no_array_access_on_object`) checks we use explicit object methods over magic array access:
```php
$article = new Article();
$id = $article['id'];
// we have no idea, what the type is
```
:no_good:
```php
$id = $article->getId();
// we know the type is int
```
:heavy_check_mark:
<br>
Last rule (`no_param_type_removal`) checks that all interface implementations follow the same method signature as the interface:
```php
interface SomeInterface
{
public function doSomething(int $value): void;
}
final class SomeClass implements SomeInterface
{
public function doSomething($value): void { // ... }
}
```
:no_good:
```php
final class SomeClass implements SomeInterface
{
public function doSomething(int $value): void { // ... }
}
```
:heavy_check_mark:
<br>
## Configure
All rules are enabled by configuration and disabled by default. We take them from the simplest to more powerful, in the same order we apply them on legacy projects.
You can enable them all at once:
```yaml
parameters:
type_perfect:
# the 3 checks above
no_isset_on_object: true
no_empty_on_object: true
no_array_access_on_object: true
no_param_type_removal: true
no_mixed_property: true
no_mixed_caller: true
null_over_false: true
narrow_param: true
narrow_return: true
```
Or one by one:
<br>
## 1. Null over False
```yaml
parameters:
type_perfect:
null_over_false: true
```
Bool types are typically used for on/off, yes/no responses. But sometimes, the `false` is misused as *no-result* response, where `null` would be more accurate:
```php
public function getProduct()
{
if (...) {
return $product;
}
return false;
}
```
:no_good:
We should use `null` instead, as it enabled strict type declaration in form of `?Product` since PHP 7.1:
```php
public function getProduct(): ?Product
{
if (...) {
return $product;
}
return null;
}
```
:heavy_check_mark:
<br>
## 2. No mixed Property
```yaml
parameters:
type_perfect:
no_mixed_property: true
```
This rule focuses on PHPStan blind spot while fetching a property. If we have a property with unknown type, PHPStan is not be able to analyse it. It silently ignores it.
```php
private $someType;
public function run()
{
$this->someType->vale;
}
```
It doesn't see there is a typo in `vale` property name. It should be `value`
:no_good:
```php
private SomeType $someType;
public function run()
{
$this->someType->value;
}
```
This rule makes sure all property fetches know their type they're called on.
:heavy_check_mark:
<br>
## 3. No mixed Caller
```yaml
parameters:
type_perfect:
no_mixed_caller: true
```
Same as above, only for method calls:
```php
private $someType;
public function run()
{
$this->someType->someMetho(1, 2);
}
```
It doesn't see there is a typo in `someMetho` name, and that the 2nd parameter must be `string`.
:no_good:
```php
private SomeType $someType;
public function run()
{
$this->someType->someMethod(1, 'active');
}
```
This group makes sure methods call know their type they're called on.
:heavy_check_mark:
<br>
## 4. Narrow Param Types
The more narrow param type we have, the reliable the code is. `string` beats `mixed`, `int` beats `scalar` and `ExactObject` beats `stdClass`.
```yaml
parameters:
type_perfect:
narrow_param: true
```
In case of `private`, but also `public` method calls, our project often knows exact types that are passed in it:
```php
// in one file
$product->addPrice(100.52);
// another file
$product->addPrice(52.05);
```
But out of from fear and "just to be safe", we keep the `addPrice()` param type empty, `mixed` or in a docblock.
:no_good:
If, in 100 % cases the `float` type is passed, PHPStan knows it can be added and improve further analysis:
```diff
-/**
- * @param float $price
- */
-public function addPrice($price)
+public function addPrice(float $price)
{
$this->price = $price;
}
```
That's where this group comes in. It checks all the passed types, and tells us know how to narrow the param type declaration.
:heavy_check_mark:
<br>
## 5. Narrow Return Types
Last but not least, the more narrow return type, the more reliable the code.
```yaml
parameters:
type_perfect:
narrow_return: true
```
Where does it help? Let's say we have 2 types of talks, that do have different behavior:
```php
final class ConferenceTalk extends Talk
{
public function bookHotel()
{
// ...
}
}
final class MeetupTalk extends Talk
{
public function bookTrain()
{
// ...
}
}
```
Then we have a factory (repository, or services) that returns generic `Talk` type:
```php
final class TalkFactory
{
public function createConferenceTalk(): Talk
{
return new ConferenceTalk();
}
public function createMeetupTalk(): Talk
{
return new MeetupTalk();
}
}
```
In this case we've just lost strict type and have to verify the type on runtime:
```php
$talk instanceof ConferenceTalk
```
:no_good:
That's where this group comes in. In case we return the exact type, we should use exact type in return type declaration to keep the code as reliable as possible:
```diff
final class TalkFactory
{
- public function createConferenceTalk(): Talk
+ public function createConferenceTalk(): ConferenceTalk
{
return new ConferenceTalk();
}
- public function createMeetupTalk(): Talk
+ public function createMeetupTalk(): MeetupTalk
{
return new MeetupTalk();
}
}
```
:heavy_check_mark:
Add sets one by one, fix what you find helpful and ignore the rest.
<br>
Happy coding!

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
use ShipMonk\ComposerDependencyAnalyser\Config\Configuration;
use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType;
return new Configuration()
// PhpParser classes are provided by phpstan/phpstan, no need to require nikic/php-parser directly
->ignoreErrorsOnPackage('nikic/php-parser', [ErrorType::SHADOW_DEPENDENCY])
// type-perfect test fixtures are static analysis inputs, not real code, and reference 3rd-party classes on purpose
->ignoreErrorsOnPath(
__DIR__ . '/packages/type-perfect/tests',
[ErrorType::UNKNOWN_CLASS, ErrorType::SHADOW_DEPENDENCY]
);

Some files were not shown because too many files have changed in this diff Show More