Compare commits

...

15 Commits
8.1.1 ... 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
251 changed files with 16821 additions and 178 deletions

View File

@ -11,7 +11,7 @@ concurrency:
cancel-in-progress: true
env:
COMPOSER_ROOT_VERSION: 8.1.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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha || github.sha }}
@ -66,7 +66,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha || github.sha }}
@ -117,7 +117,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha || github.sha }}
@ -176,7 +176,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha || github.sha }}

View File

@ -25,7 +25,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

View File

@ -2,6 +2,14 @@
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
@ -61,6 +69,7 @@ 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

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.2.4"
"phpunit/phpunit": "^13.3"
},
"autoload": {
"classmap": [
@ -58,7 +59,7 @@
},
"extra": {
"branch-alias": {
"dev-main": "8.1-dev"
"dev-main": "8.2-dev"
}
}
}

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

@ -61,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;
}
/**
@ -84,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
{
@ -126,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
{
@ -135,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) {
@ -179,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.
@ -312,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
{
@ -348,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';
@ -375,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)) {
@ -483,15 +529,17 @@ final readonly class Exporter
/**
* @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) {
@ -504,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";
}
@ -514,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 = '';
@ -536,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
{
@ -649,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.2.5",
"phpstan/phpstan": "^2.2.7",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan-strict-rules": "^2.0.11",
"tomasvotruba/type-coverage": "^2.2.2",
"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": "f2f0502dde6131231f7156bb150bc1fa",
"content-hash": "3579343022b2a28c7195fe30b1752bfb",
"packages": [],
"packages-dev": [
{
@ -130,11 +130,11 @@
},
{
"name": "phpstan/phpstan",
"version": "2.2.5",
"version": "2.2.7",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0",
"reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/692db47b9dddb0487934e5236e77d48594aef921",
"reference": "692db47b9dddb0487934e5236e77d48594aef921",
"shasum": ""
},
"require": {
@ -190,31 +190,32 @@
"type": "github"
}
],
"time": "2026-07-05T06:31:06+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": {
@ -239,35 +240,37 @@
],
"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.2.2",
"version": "2.3.0",
"source": {
"type": "git",
"url": "https://github.com/TomasVotruba/type-coverage.git",
"reference": "25f298265c823e8fb0505169135855e1e6a91021"
"reference": "7ba48c1bf3bbb7ffe99caacc7017bae5ee72d644"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/TomasVotruba/type-coverage/zipball/25f298265c823e8fb0505169135855e1e6a91021",
"reference": "25f298265c823e8fb0505169135855e1e6a91021",
"url": "https://api.github.com/repos/TomasVotruba/type-coverage/zipball/7ba48c1bf3bbb7ffe99caacc7017bae5ee72d644",
"reference": "7ba48c1bf3bbb7ffe99caacc7017bae5ee72d644",
"shasum": ""
},
"require": {
"php": "^8.4",
"phpstan/phpstan": "^2.2"
"phpstan/phpstan": "^2.2",
"webmozart/assert": "^1.11 || ^2.1"
},
"require-dev": {
"phpstan/extension-installer": "^1.4",
"phpunit/phpunit": "^12.5",
"phpunit/phpunit": "^13.2",
"rector/jack": "^1.0",
"rector/rector": "^2.4",
"rector/rector": "^2.5",
"shipmonk/composer-dependency-analyser": "^1.8",
"symplify/easy-coding-standard": "^13.1",
"symfony/dom-crawler": "^8.1",
"symplify/easy-coding-standard": "^13.2",
"tomasvotruba/unused-public": "^2.2",
"tracy/tracy": "^2.12"
},
@ -275,12 +278,14 @@
"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"
}
},
@ -295,7 +300,7 @@
],
"support": {
"issues": "https://github.com/TomasVotruba/type-coverage/issues",
"source": "https://github.com/TomasVotruba/type-coverage/tree/2.2.2"
"source": "https://github.com/TomasVotruba/type-coverage/tree/2.3.0"
},
"funding": [
{
@ -307,7 +312,73 @@
"type": "github"
}
],
"time": "2026-06-07T12:35:00+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

@ -129,12 +129,12 @@
},
{
"name": "phpstan/phpstan",
"version": "2.2.5",
"version_normalized": "2.2.5.0",
"version": "2.2.7",
"version_normalized": "2.2.7.0",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0",
"reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/692db47b9dddb0487934e5236e77d48594aef921",
"reference": "692db47b9dddb0487934e5236e77d48594aef921",
"shasum": ""
},
"require": {
@ -143,7 +143,7 @@
"conflict": {
"phpstan/phpstan-shim": "*"
},
"time": "2026-07-05T06:31:06+00:00",
"time": "2026-07-29T17:39:32+00:00",
"bin": [
"phpstan",
"phpstan.phar"
@ -196,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": {
@ -244,51 +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.2.2",
"version_normalized": "2.2.2.0",
"version": "2.3.0",
"version_normalized": "2.3.0.0",
"source": {
"type": "git",
"url": "https://github.com/TomasVotruba/type-coverage.git",
"reference": "25f298265c823e8fb0505169135855e1e6a91021"
"reference": "7ba48c1bf3bbb7ffe99caacc7017bae5ee72d644"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/TomasVotruba/type-coverage/zipball/25f298265c823e8fb0505169135855e1e6a91021",
"reference": "25f298265c823e8fb0505169135855e1e6a91021",
"url": "https://api.github.com/repos/TomasVotruba/type-coverage/zipball/7ba48c1bf3bbb7ffe99caacc7017bae5ee72d644",
"reference": "7ba48c1bf3bbb7ffe99caacc7017bae5ee72d644",
"shasum": ""
},
"require": {
"php": "^8.4",
"phpstan/phpstan": "^2.2"
"phpstan/phpstan": "^2.2",
"webmozart/assert": "^1.11 || ^2.1"
},
"require-dev": {
"phpstan/extension-installer": "^1.4",
"phpunit/phpunit": "^12.5",
"phpunit/phpunit": "^13.2",
"rector/jack": "^1.0",
"rector/rector": "^2.4",
"rector/rector": "^2.5",
"shipmonk/composer-dependency-analyser": "^1.8",
"symplify/easy-coding-standard": "^13.1",
"symfony/dom-crawler": "^8.1",
"symplify/easy-coding-standard": "^13.2",
"tomasvotruba/unused-public": "^2.2",
"tracy/tracy": "^2.12"
},
"time": "2026-06-07T12:35:00+00:00",
"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"
}
},
@ -303,7 +308,7 @@
],
"support": {
"issues": "https://github.com/TomasVotruba/type-coverage/issues",
"source": "https://github.com/TomasVotruba/type-coverage/tree/2.2.2"
"source": "https://github.com/TomasVotruba/type-coverage/tree/2.3.0"
},
"funding": [
{
@ -316,6 +321,75 @@
}
],
"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,
@ -324,6 +398,7 @@
"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' => '33eb9b136b2312642002f4287bbdb796233c54b5',
'reference' => '4f08958e05d5bfc330b2f0b05252a7b7ddbe0558',
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@ -13,7 +13,7 @@
'__root__' => array(
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => '33eb9b136b2312642002f4287bbdb796233c54b5',
'reference' => '4f08958e05d5bfc330b2f0b05252a7b7ddbe0558',
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@ -38,31 +38,40 @@
'dev_requirement' => true,
),
'phpstan/phpstan' => array(
'pretty_version' => '2.2.5',
'version' => '2.2.5.0',
'reference' => '909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0',
'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.2.2',
'version' => '2.2.2.0',
'reference' => '25f298265c823e8fb0505169135855e1e6a91021',
'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

@ -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

@ -1,16 +1,16 @@
-----BEGIN PGP SIGNATURE-----
iQIzBAABCgAdFiEEynwsejDI6OEnSoR2UcZzBf/C5cAFAmpJ+fMACgkQUcZzBf/C
5cCr2w/9EwgQ9tw1wifi7D6DXBZe5wZiscozNE72vEdbvA5CrbSe8iwUULCBnkey
Jx8bpz6g3FY1cUtIboEhwdOObQXLcctYYeolvGZFk6pvtvxCs4POmj0t3bjduhCh
pbygjlaT6FZ7U5DmfioAd27t7JXJ0d6WwkBm3dV4OWMiGKidi3TU43YkO2zecrN5
dV0hmOm2ewoXW5lGKp2eSo27BRXfb95IjNuqEiSuFUB/OA68Wbe0QIi/ZS7sUbhD
4mBpl7Fo2ZhLAb6DyY3GKdJ3lCuzn/nbH26uPPVHvO/0tG2B5CIz2lIZlsSFsTmI
HTBH8em3M9+aFsPAXi5qDl1qA5v+6jewmSnnQaAfdXfVBr+K0lNxQGFaONA6QZHa
PEYWadTfDUNVlK7q5Kz71q9C5mDYSCBU6Q42KsZZ6NGnqYP/KSCyIPz5LfXLRyG5
JUbZTaSFZZGf6YQfFTi26QpQSGlVOk2rHp3Pia/XVD4+xbQSjBaomQnrRFiEohKm
NESTEdIJgszpBChNzPtNwx3J4RUo9jYn3TmAL2v0LuuUHckY7byuL8ec0bgxD1Qb
XmtzLstiO9MUAslbjS50rX9okUUOXawaCu2QSkCbpdkwXaG/+DGoOH7PU70Bzi/B
JXFDezIvA8VQV3cC2y2MlZgSqEdac/o6o/hQ42nDqvIFpDQ5Vrg=
=YaiL
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

@ -7,4 +7,9 @@ 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]);
->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]
);

View File

@ -6,26 +6,30 @@
"keywords": ["static analysis", "phpstan-extension"],
"require": {
"php": "^8.4",
"phpstan/phpstan": "^2.2"
"phpstan/phpstan": "^2.2",
"webmozart/assert": "^1.11 || ^2.1"
},
"require-dev": {
"phpstan/extension-installer": "^1.4",
"phpunit/phpunit": "^12.5",
"symplify/easy-coding-standard": "^13.1",
"rector/rector": "^2.4",
"phpunit/phpunit": "^13.2",
"symplify/easy-coding-standard": "^13.2",
"rector/rector": "^2.5",
"tracy/tracy": "^2.12",
"tomasvotruba/unused-public": "^2.2",
"rector/jack": "^1.0",
"shipmonk/composer-dependency-analyser": "^1.8"
"shipmonk/composer-dependency-analyser": "^1.8",
"symfony/dom-crawler": "^8.1"
},
"autoload": {
"psr-4": {
"TomasVotruba\\TypeCoverage\\": "src"
"TomasVotruba\\TypeCoverage\\": "src",
"Rector\\TypePerfect\\": "packages/type-perfect/src"
}
},
"autoload-dev": {
"psr-4": {
"TomasVotruba\\TypeCoverage\\Tests\\": "tests"
"TomasVotruba\\TypeCoverage\\Tests\\": "tests",
"Rector\\TypePerfect\\Tests\\": "packages/type-perfect/tests"
}
},
"scripts": {
@ -43,7 +47,8 @@
"extra": {
"phpstan": {
"includes": [
"config/extension.neon"
"config/extension.neon",
"packages/type-perfect/config/extension.neon"
]
}
}

View File

@ -0,0 +1,25 @@
The MIT License
---------------
Copyright (c) 2020 Tomas Votruba (https://tomasvotruba.com)
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,94 @@
parametersSchema:
# see https://doc.nette.org/en/schema for configuration
type_perfect: structure([
narrow_param: bool()
narrow_return: bool()
no_mixed: bool()
null_over_false: bool()
# replace for no_mixed
no_mixed_property: bool()
no_mixed_caller: bool()
no_param_type_removal: bool()
no_array_access_on_object: bool()
no_isset_on_object: bool()
no_empty_on_object: bool()
])
# defaults
parameters:
type_perfect:
narrow_param: false
narrow_return: false
no_mixed: false
null_over_false: false
# priority override of no_mixed
no_mixed_property: false
no_mixed_caller: false
no_param_type_removal: false
no_array_access_on_object: false
no_isset_on_object: false
no_empty_on_object: false
rules:
# turn on by the group name
# no_param_type_removal
- Rector\TypePerfect\Rules\NoParamTypeRemovalRule
# no_array_access_on_object
- Rector\TypePerfect\Rules\NoArrayAccessOnObjectRule
# no_isset_on_object
- Rector\TypePerfect\Rules\NoIssetOnObjectRule
# no_empty_on_object
- Rector\TypePerfect\Rules\NoEmptyOnObjectRule
# narrow_param
- Rector\TypePerfect\Rules\NarrowPublicClassMethodParamTypeRule
- Rector\TypePerfect\Rules\NarrowPrivateClassMethodParamTypeRule
# narrow_return
- Rector\TypePerfect\Rules\NarrowReturnObjectTypeRule
# no_mixed
- Rector\TypePerfect\Rules\NoMixedPropertyFetcherRule
- Rector\TypePerfect\Rules\NoMixedMethodCallerRule
# null_over_false
- Rector\TypePerfect\Rules\ReturnNullOverFalseRule
services:
-
factory: Rector\TypePerfect\Configuration
arguments:
- %type_perfect%
- Rector\TypePerfect\NodeFinder\ClassMethodNodeFinder
- Rector\TypePerfect\NodeFinder\MethodCallNodeFinder
- Rector\TypePerfect\NodeFinder\ReturnNodeFinder
- Rector\TypePerfect\PhpDoc\ApiDocStmtAnalyzer
- Rector\TypePerfect\Printer\NodeComparator
- Rector\TypePerfect\Reflection\ReflectionParser
- Rector\TypePerfect\Reflection\MethodNodeAnalyser
- Rector\TypePerfect\Matcher\Collector\PublicClassMethodMatcher
- Rector\TypePerfect\Matcher\ClassMethodCallReferenceResolver
- Rector\TypePerfect\Printer\CollectorMetadataPrinter
- Rector\TypePerfect\Guard\EmptyIssetGuard
# for NarrowPublicClassMethodParamTypeByCallerTypeRule
-
class: Rector\TypePerfect\Collector\ClassMethod\PublicClassMethodParamTypesCollector
tags: [phpstan.collector]
-
class: Rector\TypePerfect\Collector\MethodCall\MethodCallArgTypesCollector
tags: [phpstan.collector]
-
class: Rector\TypePerfect\Collector\MethodCallableNode\MethodCallableCollector
tags:
- phpstan.collector

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Collector\ClassMethod;
use PhpParser\Node;
use PhpParser\Node\Stmt\ClassMethod;
use PHPStan\Analyser\Scope;
use PHPStan\Collectors\Collector;
use PHPStan\Reflection\ClassReflection;
use Rector\TypePerfect\Configuration;
use Rector\TypePerfect\Matcher\Collector\PublicClassMethodMatcher;
use Rector\TypePerfect\PhpDoc\ApiDocStmtAnalyzer;
use Rector\TypePerfect\Printer\CollectorMetadataPrinter;
/**
* @implements Collector<ClassMethod, array{class-string, string, string, int}>
*/
final readonly class PublicClassMethodParamTypesCollector implements Collector
{
public function __construct(
private ApiDocStmtAnalyzer $apiDocStmtAnalyzer,
private PublicClassMethodMatcher $publicClassMethodMatcher,
private CollectorMetadataPrinter $collectorMetadataPrinter,
private Configuration $configuration
) {
}
public function getNodeType(): string
{
return ClassMethod::class;
}
/**
* @param ClassMethod $node
* @return array{class-string, string, string, int}|null
*/
public function processNode(Node $node, Scope $scope): ?array
{
if (! $this->configuration->isNarrowParamEnabled()) {
return null;
}
if ($node->params === []) {
return null;
}
if ($this->publicClassMethodMatcher->shouldSkipClassMethod($node)) {
return null;
}
// only if the class has no parents/implementers, to avoid class method required by contracts
$classReflection = $scope->getClassReflection();
if (! $classReflection instanceof ClassReflection) {
return null;
}
if ($this->apiDocStmtAnalyzer->hasApiDoc($node, $classReflection)) {
return null;
}
if ($this->publicClassMethodMatcher->shouldSkipClassReflection($classReflection)) {
return null;
}
$methodName = $node->name->toString();
// is this method required by parent contract? skip it
if ($this->publicClassMethodMatcher->isUsedByParentClassOrInterface($classReflection, $methodName)) {
return null;
}
$printedParamTypesString = $this->collectorMetadataPrinter->printParamTypesToString(
$node,
$classReflection,
$scope
);
return [$classReflection->getName(), $methodName, $printedParamTypesString, $node->getLine()];
}
}

View File

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Collector\MethodCall;
use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Identifier;
use PHPStan\Analyser\Scope;
use PHPStan\Collectors\Collector;
use PHPStan\Reflection\ExtendedMethodReflection;
use Rector\TypePerfect\Configuration;
use Rector\TypePerfect\Matcher\ClassMethodCallReferenceResolver;
use Rector\TypePerfect\Printer\CollectorMetadataPrinter;
use Rector\TypePerfect\ValueObject\MethodCallReference;
/**
* @implements Collector<MethodCall, array{string, string}>
*/
final readonly class MethodCallArgTypesCollector implements Collector
{
public function __construct(
private ClassMethodCallReferenceResolver $classMethodCallReferenceResolver,
private CollectorMetadataPrinter $collectorMetadataPrinter,
private Configuration $configuration
) {
}
public function getNodeType(): string
{
return MethodCall::class;
}
/**
* @param MethodCall $node
* @return array{string, string}|null
*/
public function processNode(Node $node, Scope $scope): ?array
{
if (! $this->configuration->isNarrowParamEnabled()) {
return null;
}
if ($node->isFirstClassCallable() || $node->getArgs() === [] || ! $node->name instanceof Identifier) {
return null;
}
$methodCalledOnType = $scope->getType($node->var);
$methodReflection = $scope->getMethodReflection($methodCalledOnType, $node->name->name);
if (! $methodReflection instanceof ExtendedMethodReflection) {
return null;
}
$classMethodCallReference = $this->classMethodCallReferenceResolver->resolve($node, $scope, true);
if (! $classMethodCallReference instanceof MethodCallReference) {
return null;
}
$classMethodReference = $this->createClassMethodReference($classMethodCallReference);
$stringArgTypesString = $this->collectorMetadataPrinter->printArgTypesAsString(
$node,
$methodReflection,
$scope
);
return [$classMethodReference, $stringArgTypesString];
}
private function createClassMethodReference(MethodCallReference $classMethodCallReference): string
{
$className = $classMethodCallReference->getClass();
$methodName = $classMethodCallReference->getMethod();
return $className . '::' . $methodName;
}
}

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Collector\MethodCallableNode;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Collectors\Collector;
use PHPStan\Node\MethodCallableNode;
use Rector\TypePerfect\Configuration;
use Rector\TypePerfect\Matcher\ClassMethodCallReferenceResolver;
use Rector\TypePerfect\ValueObject\MethodCallReference;
/**
* @implements Collector<MethodCallableNode, array<string>|null>
*
* PHPStan has special node for first class callables of MethodCall
*
* @see https://github.com/phpstan/phpstan-src/blob/511c1e435fb43b8eb0ac310e6aa3230147963790/src/Analyser/NodeScopeResolver.php#L1936
*/
final readonly class MethodCallableCollector implements Collector
{
public function __construct(
private ClassMethodCallReferenceResolver $classMethodCallReferenceResolver,
private Configuration $configuration
) {
}
public function getNodeType(): string
{
return MethodCallableNode::class;
}
/**
* @param MethodCallableNode $node
* @return array{string}|null
*/
public function processNode(Node $node, Scope $scope): ?array
{
if (! $this->configuration->isNarrowParamEnabled()) {
return null;
}
$classMethodCallReference = $this->classMethodCallReferenceResolver->resolve($node, $scope, true);
if (! $classMethodCallReference instanceof MethodCallReference) {
return null;
}
$classMethodReference = $this->createClassMethodReference($classMethodCallReference);
// special case that should skip everything
return [$classMethodReference];
}
private function createClassMethodReference(MethodCallReference $classMethodCallReference): string
{
$className = $classMethodCallReference->getClass();
$methodName = $classMethodCallReference->getMethod();
return $className . '::' . $methodName;
}
}

View File

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect;
final class Configuration
{
/**
* @param array<string, mixed> $parameters
*/
public function __construct(
private array $parameters
) {
// enabed by default in tests
if (defined('PHPUNIT_COMPOSER_INSTALL')) {
$this->parameters['narrow_param'] = true;
$this->parameters['narrow_return'] = true;
$this->parameters['no_mixed'] = true;
$this->parameters['null_over_false'] = true;
$this->parameters['no_param_type_removal'] = true;
$this->parameters['no_array_access_on_object'] = true;
$this->parameters['no_isset_on_object'] = true;
$this->parameters['no_empty_on_object'] = true;
}
}
public function isNarrowParamEnabled(): bool
{
return $this->parameters['narrow_param'] ?? false;
}
public function isNarrowReturnEnabled(): bool
{
return $this->parameters['narrow_return'] ?? false;
}
public function isNoMixedPropertyEnabled(): bool
{
if ($this->parameters['no_mixed_property']) {
return true;
}
return $this->parameters['no_mixed'] ?? false;
}
public function isNoMixedCallerEnabled(): bool
{
if ($this->parameters['no_mixed_caller']) {
return true;
}
return $this->parameters['no_mixed'] ?? false;
}
public function isNoFalsyReturnEnabled(): bool
{
return $this->parameters['null_over_false'] ?? false;
}
public function isNoParamTypeRemovalEnabled(): bool
{
return $this->parameters['no_param_type_removal'] ?? false;
}
public function isNoArrayAccessOnObjectEnabled(): bool
{
return $this->parameters['no_array_access_on_object'] ?? false;
}
public function isNoIssetOnObjectEnabled(): bool
{
return $this->parameters['no_isset_on_object'] ?? false;
}
public function isNoEmptyOnObjectEnabled(): bool
{
return $this->parameters['no_empty_on_object'] ?? false;
}
}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Enum\Types;
final class ResolvedTypes
{
public const string UNKNOWN_TYPES = 'unknown_types';
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Guard;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\ArrayDimFetch;
use PhpParser\Node\Expr\Variable;
use PHPStan\Analyser\Scope;
use PHPStan\Type\TypeCombinator;
final class EmptyIssetGuard
{
public function isLegal(Expr $expr, Scope $scope): bool
{
if ($expr instanceof ArrayDimFetch) {
return true;
}
if (! $expr instanceof Variable) {
return true;
}
if ($expr->name instanceof Expr) {
return true;
}
if (! $scope->hasVariableType($expr->name)->yes()) {
return true;
}
$varType = $scope->getNativeType($expr);
$varType = TypeCombinator::removeNull($varType);
return $varType->getObjectClassNames() === [];
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Matcher;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Node\MethodCallableNode;
use PHPStan\Type\ThisType;
use PHPStan\Type\TypeCombinator;
use Rector\TypePerfect\ValueObject\MethodCallReference;
final class ClassMethodCallReferenceResolver
{
public function resolve(
MethodCall|MethodCallableNode $methodCallOrMethodCallable,
Scope $scope,
bool $allowThisType
): ?MethodCallReference {
if ($methodCallOrMethodCallable instanceof MethodCallableNode) {
$methodName = $methodCallOrMethodCallable->getName();
$variable = $methodCallOrMethodCallable->getVar();
} else {
$methodName = $methodCallOrMethodCallable->name;
$variable = $methodCallOrMethodCallable->var;
}
if ($methodName instanceof Expr) {
return null;
}
$callerType = $scope->getType($variable);
// remove optional nullable type
if (TypeCombinator::containsNull($callerType)) {
$callerType = TypeCombinator::removeNull($callerType);
}
if (! $allowThisType && $callerType instanceof ThisType) {
return null;
}
if (count($callerType->getObjectClassNames()) !== 1) {
return null;
}
// move to the class where method is defined, e.g. parent class defines the method, so it should be checked there
$className = $callerType->getObjectClassNames()[0];
$methodNameString = $methodName->toString();
return new MethodCallReference($className, $methodNameString);
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Matcher\Collector;
use PhpParser\Comment\Doc;
use PhpParser\Node\Stmt\ClassMethod;
use PHPStan\Reflection\ClassReflection;
final class PublicClassMethodMatcher
{
/**
* @var string[]
*/
private const array SKIPPED_TYPES = [
'PHPUnit\Framework\TestCase',
'Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator',
];
public function shouldSkipClassReflection(ClassReflection $classReflection): bool
{
// skip interface as required, traits as unable to detect for sure
if (! $classReflection->isClass()) {
return true;
}
return array_any(self::SKIPPED_TYPES, fn (string $skippedType): bool => $classReflection->is($skippedType));
}
public function isUsedByParentClassOrInterface(ClassReflection $classReflection, string $methodName): bool
{
// is this method required by parent contract? skip it
foreach ($classReflection->getInterfaces() as $parentInterfaceReflection) {
if ($parentInterfaceReflection->hasMethod($methodName)) {
return true;
}
}
return array_any($classReflection->getParents(), fn (ClassReflection $parentClassReflection): bool => $parentClassReflection->hasMethod($methodName));
}
public function shouldSkipClassMethod(ClassMethod $classMethod): bool
{
if ($classMethod->isMagic()) {
return true;
}
if ($classMethod->isStatic()) {
return true;
}
// skip attributes
if ($classMethod->attrGroups !== []) {
return true;
}
if (! $classMethod->isPublic()) {
return true;
}
$doc = $classMethod->getDocComment();
// skip symfony action
return $doc instanceof Doc && str_contains($doc->getText(), '@Route');
}
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\NodeFinder;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Identifier;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassMethod;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use Rector\TypePerfect\Reflection\ReflectionParser;
final readonly class ClassMethodNodeFinder
{
public function __construct(
private ReflectionParser $reflectionParser,
) {
}
public function findByMethodCall(MethodCall $methodCall, Scope $scope): ?ClassMethod
{
$classReflection = $scope->getClassReflection();
if (! $classReflection instanceof ClassReflection) {
return null;
}
$classLike = $this->reflectionParser->parseClassReflection($classReflection);
if (! $classLike instanceof Class_) {
return null;
}
if (! $methodCall->name instanceof Identifier) {
return null;
}
$methodCallName = $methodCall->name->toString();
$classMethod = $classLike->getMethod($methodCallName);
if (! $classMethod instanceof ClassMethod) {
return null;
}
if (! $classMethod->isPrivate()) {
return null;
}
return $classMethod;
}
}

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\NodeFinder;
use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Stmt\Class_;
use PhpParser\NodeFinder;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use Rector\TypePerfect\Printer\NodeComparator;
use Rector\TypePerfect\Reflection\ReflectionParser;
use Webmozart\Assert\Assert;
final readonly class MethodCallNodeFinder
{
private NodeFinder $nodeFinder;
public function __construct(
private ReflectionParser $reflectionParser,
private NodeComparator $nodeComparator,
) {
$this->nodeFinder = new NodeFinder();
}
/**
* @return MethodCall[]
*/
public function findUsages(MethodCall $methodCall, Scope $scope): array
{
$classReflection = $scope->getClassReflection();
if (! $classReflection instanceof ClassReflection) {
return [];
}
$classLike = $this->reflectionParser->parseClassReflection($classReflection);
if (! $classLike instanceof Class_) {
return [];
}
$methodCalls = $this->nodeFinder->find($classLike, function (Node $node) use ($methodCall): bool {
if (! $node instanceof MethodCall) {
return false;
}
if (! $this->nodeComparator->areNodesEqual($node->var, $methodCall->var)) {
return false;
}
return $this->nodeComparator->areNodesEqual($node->name, $methodCall->name);
});
Assert::allIsInstanceOf($methodCalls, MethodCall::class);
return $methodCalls;
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\NodeFinder;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\FunctionLike;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Return_;
use PhpParser\NodeTraverser;
use Rector\TypePerfect\NodeVisitor\CallableNodeVisitor;
final readonly class ReturnNodeFinder
{
public function findOnlyReturnsExpr(ClassMethod $classMethod): Expr|null
{
$returns = $this->findReturnsWithValues($classMethod);
if (count($returns) !== 1) {
return null;
}
$onlyReturn = $returns[0];
return $onlyReturn->expr;
}
/**
* @return Return_[]
*/
public function findReturnsWithValues(ClassMethod $classMethod): array
{
$returns = [];
$this->traverseNodesWithCallable((array) $classMethod->stmts, static function (
Node $node
) use (&$returns) {
// skip different scope
if ($node instanceof FunctionLike) {
return NodeTraverser::DONT_TRAVERSE_CURRENT_AND_CHILDREN;
}
if (! $node instanceof Return_) {
return null;
}
if (! $node->expr instanceof Expr) {
return null;
}
$returns[] = $node;
});
return $returns;
}
/**
* @param callable(Node $node): (int|Node|null) $callable
* @param Node[] $nodes
*/
private function traverseNodesWithCallable(array $nodes, callable $callable): void
{
$nodeTraverser = new NodeTraverser();
$callableNodeVisitor = new CallableNodeVisitor($callable);
$nodeTraverser->addVisitor($callableNodeVisitor);
$nodeTraverser->traverse($nodes);
}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\NodeVisitor;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Stmt;
use PhpParser\Node\Stmt\Expression;
use PhpParser\NodeVisitorAbstract;
final class CallableNodeVisitor extends NodeVisitorAbstract
{
/**
* @var callable(Node): (int|Node|null)
*/
private $callable;
/**
* @param callable(Node $node): (int|Node|null) $callable
*/
public function __construct(callable $callable)
{
$this->callable = $callable;
}
public function enterNode(Node $node): int|Node|null
{
$originalNode = $node;
$callable = $this->callable;
/** @var int|Node|null $newNode */
$newNode = $callable($node);
if ($originalNode instanceof Stmt && $newNode instanceof Expr) {
return new Expression($newNode);
}
return $newNode;
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\PhpDoc;
use PhpParser\Comment\Doc;
use PhpParser\Node\Stmt\ClassMethod;
use PHPStan\PhpDoc\ResolvedPhpDocBlock;
use PHPStan\Reflection\ClassReflection;
final class ApiDocStmtAnalyzer
{
public function hasApiDoc(ClassMethod $classMethod, ClassReflection $classReflection): bool
{
if ($this->hasClassReflectionApiDoc($classReflection)) {
return true;
}
$docComment = $classMethod->getDocComment();
if (! $docComment instanceof Doc) {
return false;
}
return str_contains($docComment->getText(), '@api');
}
private function hasClassReflectionApiDoc(ClassReflection $classReflection): bool
{
if (! $classReflection->getResolvedPhpDoc() instanceof ResolvedPhpDocBlock) {
return false;
}
$resolvedPhpDocBlock = $classReflection->getResolvedPhpDoc();
return str_contains($resolvedPhpDocBlock->getPhpDocString(), '@api');
}
}

View File

@ -0,0 +1,248 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Printer;
use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Identifier;
use PhpParser\Node\IntersectionType as NodeIntersectionType;
use PhpParser\Node\Name;
use PhpParser\Node\Name\FullyQualified;
use PhpParser\Node\NullableType;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\UnionType;
use PhpParser\PrettyPrinter\Standard;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\ExtendedMethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\ClosureType;
use PHPStan\Type\Generic\GenericObjectType;
use PHPStan\Type\IntegerRangeType;
use PHPStan\Type\IntersectionType;
use PHPStan\Type\MixedType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\ThisType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\UnionType as PHPStanUnionType;
use PHPStan\Type\VerbosityLevel;
use Rector\TypePerfect\Enum\Types\ResolvedTypes;
final readonly class CollectorMetadataPrinter
{
private Standard $standard;
public function __construct(
) {
$this->standard = new Standard();
}
public function printArgTypesAsString(
MethodCall $methodCall,
ExtendedMethodReflection $extendedMethodReflection,
Scope $scope
): string {
$parametersAcceptor = ParametersAcceptorSelector::selectFromArgs(
$scope,
$methodCall->getArgs(),
$extendedMethodReflection->getVariants(),
$extendedMethodReflection->getNamedArgumentsVariants()
);
$parameters = $parametersAcceptor->getParameters();
$stringArgTypes = [];
foreach ($methodCall->getArgs() as $i => $arg) {
$argType = $scope->getType($arg->value);
// we have no idea, nothing we can do
if ($argType instanceof MixedType) {
return ResolvedTypes::UNKNOWN_TYPES;
}
if ($argType instanceof IntersectionType) {
return ResolvedTypes::UNKNOWN_TYPES;
}
if ($argType instanceof GenericObjectType) {
return ResolvedTypes::UNKNOWN_TYPES;
}
if ($argType instanceof PHPStanUnionType) {
return ResolvedTypes::UNKNOWN_TYPES;
}
if ($argType instanceof ThisType) {
$argType = new ObjectType($argType->getClassName());
}
if (array_key_exists($i, $parameters)) {
$defaultValueType = $parameters[$i]->getDefaultValue();
if ($defaultValueType instanceof Type) {
$argType = TypeCombinator::union($argType, $defaultValueType);
}
}
$printedArgType = $this->printTypeToString($argType);
$printedArgType = $this->normalizeDateTime($printedArgType);
$stringArgTypes[] = $printedArgType;
}
return implode('|', $stringArgTypes);
}
public function printParamTypesToString(
ClassMethod $classMethod,
ClassReflection $classReflection,
Scope $scope
): string {
$className = $classReflection->getName();
$parametersReflection = [];
if ($classReflection->hasMethod($classMethod->name->name)) {
$methodReflection = $classReflection->getMethod($classMethod->name->name, $scope);
$variants = $methodReflection->getVariants();
if (count($variants) === 1) {
$parametersReflection = $variants[0]->getParameters();
}
}
$printedParamTypes = [];
foreach ($classMethod->params as $i => $param) {
if ($param->type === null) {
$printedParamTypes[] = '';
continue;
}
$phpdocType = null;
if (array_key_exists($i, $parametersReflection)) {
$paramphpdocType = $parametersReflection[$i]->getPhpDocType();
if (! $paramphpdocType instanceof MixedType) {
$phpdocType = $paramphpdocType;
}
}
if ($phpdocType instanceof Type) {
$printedParamType = $this->printTypeToString($phpdocType);
} else {
$paramType = $this->transformSelfToClassName($param->type, $className);
if ($paramType instanceof NullableType) {
// unite to phpstan type
$paramType = new UnionType([$paramType->type, new Identifier('null')]);
}
if ($paramType instanceof UnionType || $paramType instanceof NodeIntersectionType) {
$paramType = $this->resolveSortedTypes($paramType, $className);
}
$printedParamType = $this->standard->prettyPrint([$paramType]);
$printedParamType = str_replace('\Closure', 'callable', $printedParamType);
$printedParamType = ltrim($printedParamType, '\\');
$printedParamType = str_replace('|\\', '|', $printedParamType);
}
// to avoid DateTime vs DateTimeImmutable vs DateTimeInterface conflicts
$printedParamType = $this->normalizeDateTime($printedParamType);
$printedParamTypes[] = $printedParamType;
}
return implode('|', $printedParamTypes);
}
private function transformSelfToClassName(Node $node, ?string $className): Node
{
if (! $node instanceof Name || $className === null) {
return $node;
}
if ($node->toString() !== 'self') {
return $node;
}
return new FullyQualified($className);
}
private function resolveSortedTypes(
UnionType|NodeIntersectionType $paramType,
?string $className
): UnionType|NodeIntersectionType {
$typeNames = [];
foreach ($paramType->types as $type) {
if ($type instanceof NodeIntersectionType) {
foreach ($type->types as $intersectionType) {
/** @var Identifier|Name $intersectionType */
$intersectionType = $this->transformSelfToClassName($intersectionType, $className);
$typeNames[] = (string) $intersectionType;
}
continue;
}
/** @var Identifier|Name $type */
$type = $this->transformSelfToClassName($type, $className);
$typeNames[] = (string) $type;
}
sort($typeNames);
$types = [];
foreach ($typeNames as $typeName) {
$types[] = new Identifier($typeName);
}
if ($paramType instanceof NodeIntersectionType) {
return new NodeIntersectionType($types);
}
return new UnionType($types);
}
private function printTypeToString(Type $type): string
{
if ($type->isClassString()->yes()) {
return 'string';
}
if ($type->isArray()->yes()) {
return 'array';
}
if ($type->isBoolean()->yes()) {
return 'bool';
}
if ($type instanceof IntegerRangeType) {
return 'int';
}
if ($type instanceof ClosureType) {
return 'callable';
}
if (count($type->getEnumCases()) === 1) {
return $type->getEnumCases()[0]
->getClassName();
}
return $type->describe(VerbosityLevel::typeOnly());
}
private function normalizeDateTime(string $printedType): string
{
if ($printedType === 'DateTimeImmutable') {
return 'DateTimeInterface';
}
if ($printedType === 'DateTime') {
return 'DateTimeInterface';
}
return $printedType;
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Printer;
use PhpParser\Node;
use PHPStan\Node\Printer\Printer;
final readonly class NodeComparator
{
public function __construct(
private Printer $printer
) {
}
public function areNodesEqual(Node $firstNode, Node $secondNode): bool
{
// remove comments from nodes
$firstNode->setAttribute('comments', null);
$secondNode->setAttribute('comments', null);
return $this->printer->prettyPrint([$firstNode]) === $this->printer->prettyPrint([$secondNode]);
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Reflection;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\MethodReflection;
final class MethodNodeAnalyser
{
public function hasParentVendorLock(Scope $scope, string $methodName): bool
{
return $this->matchFirstParentClassMethod($scope, $methodName) instanceof MethodReflection;
}
public function matchFirstParentClassMethod(Scope $scope, string $methodName): ?MethodReflection
{
$classReflection = $scope->getClassReflection();
if (! $classReflection instanceof ClassReflection) {
return null;
}
// the classes have highter priority, e.g. priority in class covariance
foreach ($classReflection->getParents() as $parentClassReflection) {
if ($parentClassReflection->hasNativeMethod($methodName)) {
return $parentClassReflection->getNativeMethod($methodName);
}
}
foreach ($classReflection->getAncestors() as $ancestorClassReflection) {
if ($classReflection === $ancestorClassReflection) {
continue;
}
if (! $ancestorClassReflection->hasNativeMethod($methodName)) {
continue;
}
return $ancestorClassReflection->getNativeMethod($methodName);
}
return null;
}
}

View File

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Reflection;
use PhpParser\Node;
use PhpParser\Node\Stmt\ClassLike;
use PhpParser\NodeFinder;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\NameResolver;
use PhpParser\Parser;
use PhpParser\ParserFactory;
use PHPStan\Reflection\ClassReflection;
use Throwable;
final class ReflectionParser
{
/**
* @var array<string, ClassLike>
*/
private array $classesByFilename = [];
private readonly Parser $parser;
public function __construct()
{
$parserFactory = new ParserFactory();
$this->parser = $parserFactory->createForNewestSupportedVersion();
}
public function parseClassReflection(ClassReflection $classReflection): ?ClassLike
{
$fileName = $classReflection->getFileName();
if ($fileName === null) {
return null;
}
return $this->parseFilenameToClass($fileName);
}
private function parseFilenameToClass(string $fileName): ClassLike|null
{
if (isset($this->classesByFilename[$fileName])) {
return $this->classesByFilename[$fileName];
}
try {
/** @var string $fileContents */
$fileContents = file_get_contents($fileName);
$stmts = $this->parser->parse($fileContents);
if (! is_array($stmts)) {
return null;
}
// complete namespacedName variables
$nodeTraverser = new NodeTraverser();
$nodeTraverser->addVisitor(new NameResolver());
$nodeTraverser->traverse($stmts);
} catch (Throwable) {
// not reachable
return null;
}
$classLike = $this->findFirstClassLike($stmts);
if (! $classLike instanceof ClassLike) {
return null;
}
$this->classesByFilename[$fileName] = $classLike;
return $classLike;
}
/**
* @param Node[] $nodes
*/
private function findFirstClassLike(array $nodes): ?ClassLike
{
$nodeFinder = new NodeFinder();
return $nodeFinder->findFirstInstanceOf($nodes, ClassLike::class);
}
}

View File

@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Rules;
use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Name\FullyQualified;
use PhpParser\Node\Param;
use PhpParser\Node\Stmt\ClassMethod;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleError;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\Generic\GenericObjectType;
use PHPStan\Type\Generic\TemplateType;
use PHPStan\Type\IntersectionType;
use PHPStan\Type\MixedType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\UnionType;
use PHPStan\Type\VerbosityLevel;
use Rector\TypePerfect\Configuration;
use Rector\TypePerfect\NodeFinder\ClassMethodNodeFinder;
use Rector\TypePerfect\NodeFinder\MethodCallNodeFinder;
/**
* @see \Rector\TypePerfect\Tests\Rules\NarrowPrivateClassMethodParamTypeRule\NarrowPrivateClassMethodParamTypeRuleTest
* @implements Rule<MethodCall>
*/
final readonly class NarrowPrivateClassMethodParamTypeRule implements Rule
{
public const string ERROR_MESSAGE = 'Parameter %d should use "%s" type as the only type passed to this method';
public function __construct(
private Configuration $configuration,
private MethodCallNodeFinder $methodCallNodeFinder,
private ClassMethodNodeFinder $classMethodNodeFinder
) {
}
/**
* @return class-string<Node>
*/
public function getNodeType(): string
{
return MethodCall::class;
}
/**
* @param MethodCall $node
* @return RuleError[]
*/
public function processNode(Node $node, Scope $scope): array
{
if (! $this->configuration->isNarrowParamEnabled() || $node->isFirstClassCallable()) {
return [];
}
$args = $node->getArgs();
if ($args === []) {
return [];
}
if (! $node->var instanceof Variable) {
return [];
}
if (! is_string($node->var->name)) {
return [];
}
if ($node->var->name !== 'this') {
return [];
}
return $this->validateArgVsParamTypes($args, $node, $scope);
}
/**
* @param Arg[] $args
* @return RuleError[]
*/
private function validateArgVsParamTypes(array $args, MethodCall $methodCall, Scope $scope): array
{
$methodCallUses = $this->methodCallNodeFinder->findUsages($methodCall, $scope);
if (count($methodCallUses) > 1) {
return [];
}
$classMethod = $this->classMethodNodeFinder->findByMethodCall($methodCall, $scope);
if (! $classMethod instanceof ClassMethod) {
return [];
}
/** @var Param[] $params */
$params = $classMethod->getParams();
$errorMessages = [];
foreach ($args as $position => $arg) {
$param = $params[$position] ?? [];
if (! $param instanceof Param) {
continue;
}
if ($param->variadic) {
return [];
}
$paramRuleError = $this->validateParam($param, $position, $arg->value, $scope);
if (! $paramRuleError instanceof RuleError) {
continue;
}
// @todo test double failed type
$errorMessages[] = $paramRuleError;
}
return $errorMessages;
}
private function validateParam(Param $param, int $position, Expr $expr, Scope $scope): ?RuleError
{
$type = $param->type;
// @todo some static type mapper from php-parser to PHPStan?
if (! $type instanceof FullyQualified) {
return null;
}
$argType = $scope->getType($expr);
if ($argType instanceof MixedType) {
return null;
}
if ($argType instanceof TemplateType) {
return null;
}
// not solveable yet, work with PHP 8 code only
if ($argType instanceof UnionType) {
return null;
}
if ($argType instanceof IntersectionType || $argType instanceof GenericObjectType) {
return null;
}
$objectType = new ObjectType($type->toString());
if ($objectType->equals($argType)) {
return null;
}
$classReflection = $objectType->getClassReflection();
if ($classReflection instanceof ClassReflection && $classReflection->isAbstract()) {
return null;
}
// handle weird type substration cases
$paramTypeAsString = $objectType->describe(VerbosityLevel::typeOnly());
$argTypeAsString = $argType->describe(VerbosityLevel::typeOnly());
if ($paramTypeAsString === $argTypeAsString) {
return null;
}
$errorMessage = sprintf(self::ERROR_MESSAGE, $position + 1, $argTypeAsString);
return RuleErrorBuilder::message($errorMessage)
->identifier('typePerfect.narrowPrivateClassMethodParamType')
->line($param->getLine())
->build();
}
}

View File

@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Rules;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\CollectedDataNode;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleError;
use PHPStan\Rules\RuleErrorBuilder;
use Rector\TypePerfect\Collector\ClassMethod\PublicClassMethodParamTypesCollector;
use Rector\TypePerfect\Collector\MethodCall\MethodCallArgTypesCollector;
use Rector\TypePerfect\Collector\MethodCallableNode\MethodCallableCollector;
use Rector\TypePerfect\Configuration;
use Rector\TypePerfect\Enum\Types\ResolvedTypes;
/**
* @see \Rector\TypePerfect\Tests\Rules\NarrowPublicClassMethodParamTypeRule\NarrowPublicClassMethodParamTypeRuleTest
*
* @implements Rule<CollectedDataNode>
*/
final readonly class NarrowPublicClassMethodParamTypeRule implements Rule
{
public const string ERROR_MESSAGE = 'Parameters should have "%s" types as the only types passed to this method';
public function __construct(
private Configuration $configuration
) {
}
/**
* @return class-string<Node>
*/
public function getNodeType(): string
{
return CollectedDataNode::class;
}
/**
* @param CollectedDataNode $node
* @return RuleError[]
*/
public function processNode(Node $node, Scope $scope): array
{
if (! $this->configuration->isNarrowParamEnabled()) {
return [];
}
$publicClassMethodCollector = $node->get(PublicClassMethodParamTypesCollector::class);
$classMethodReferenceToArgTypes = $this->resolveClassMethodReferenceToArgTypes($node);
$ruleErrors = [];
foreach ($publicClassMethodCollector as $filePath => $declarations) {
foreach ($declarations as [$className, $methodName, $paramTypesString, $line]) {
$uniqueCollectedArgTypesString = $this->resolveUniqueArgTypesString(
$classMethodReferenceToArgTypes,
$className,
$methodName
);
if ($uniqueCollectedArgTypesString === null) {
continue;
}
if ($paramTypesString === $uniqueCollectedArgTypesString) {
continue;
}
$ruleErrors[] = RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $uniqueCollectedArgTypesString))
->identifier('typePerfect.narrowPublicClassMethodParamType')
->file($filePath)
->line($line)
->build();
}
}
return $ruleErrors;
}
/**
* @return array<string, string[]>
*/
private function resolveClassMethodReferenceToArgTypes(CollectedDataNode $collectedDataNode): array
{
$methodCallArgTypesByFilePath = $collectedDataNode->get(MethodCallArgTypesCollector::class);
// these should be skipped completely, as we have no idea what is being passed there
$methodCallablesByFilePath = $collectedDataNode->get(MethodCallableCollector::class);
$methodFirstClassCallables = $this->flattenCollectedData($methodCallablesByFilePath);
// group call references and types
$classMethodReferenceToTypes = [];
foreach ($methodCallArgTypesByFilePath as $methodCallArgTypes) {
foreach ($methodCallArgTypes as [$classMethodReference, $argTypesString]) {
// skip it
if (in_array($classMethodReference, $methodFirstClassCallables, true)) {
continue;
}
$classMethodReferenceToTypes[$classMethodReference][] = $argTypesString;
}
}
// resolve unique values
foreach ($classMethodReferenceToTypes as $key => $value) {
$classMethodReferenceToTypes[$key] = array_unique($value);
}
return $classMethodReferenceToTypes;
}
/**
* @param array<string, string[]> $classMethodReferenceToArgTypes
*/
private function resolveUniqueArgTypesString(
array $classMethodReferenceToArgTypes,
string $className,
string $methodName
): ?string {
$currentClassMethodReference = $className . '::' . $methodName;
$collectedArgTypes = $classMethodReferenceToArgTypes[$currentClassMethodReference] ?? null;
if ($collectedArgTypes === null) {
return null;
}
// we need exactly one type
if (count($collectedArgTypes) !== 1) {
return null;
}
// one of the arg types could not be resolved, we're not sure
if (in_array(ResolvedTypes::UNKNOWN_TYPES, $collectedArgTypes, true)) {
return null;
}
return $collectedArgTypes[0];
}
/**
* @param array<string, list<(array<string>|null)>> $methodCallablesByFilePath
* @return string[]
*/
private function flattenCollectedData(array $methodCallablesByFilePath): array
{
$methodFirstClassCallables = [];
foreach ($methodCallablesByFilePath as $methodCallables) {
foreach ($methodCallables as $methodCallable) {
// the collector returns null for nodes it cannot resolve
if (! isset($methodCallable[0])) {
continue;
}
$methodFirstClassCallables[] = $methodCallable[0];
}
}
return $methodFirstClassCallables;
}
}

View File

@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Rules;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Name\FullyQualified;
use PhpParser\Node\Stmt\ClassMethod;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleError;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
use Rector\TypePerfect\Configuration;
use Rector\TypePerfect\NodeFinder\ReturnNodeFinder;
use Rector\TypePerfect\Reflection\MethodNodeAnalyser;
/**
* @see \Rector\TypePerfect\Tests\Rules\NarrowReturnObjectTypeRule\NarrowReturnObjectTypeRuleTest
*
* @implements Rule<ClassMethod>
*/
final readonly class NarrowReturnObjectTypeRule implements Rule
{
public const string ERROR_MESSAGE = 'Provide more specific return type "%s" over abstract one';
public function __construct(
private ReturnNodeFinder $returnNodeFinder,
private MethodNodeAnalyser $methodNodeAnalyser,
private Configuration $configuration
) {
}
/**
* @return class-string<Node>
*/
public function getNodeType(): string
{
return ClassMethod::class;
}
/**
* @param ClassMethod $node
* @return RuleError[]
*/
public function processNode(Node $node, Scope $scope): array
{
if (! $this->configuration->isNarrowReturnEnabled()) {
return [];
}
if (! $node->returnType instanceof FullyQualified) {
return [];
}
if ($this->shouldSkipScope($scope)) {
return [];
}
$returnObjectType = new ObjectType($node->returnType->toString());
if ($this->shouldSkipReturnObjectType($returnObjectType)) {
return [];
}
$methodName = $node->name->toString();
if ($this->methodNodeAnalyser->hasParentVendorLock($scope, $methodName)) {
return [];
}
$returnExpr = $this->returnNodeFinder->findOnlyReturnsExpr($node);
if (! $returnExpr instanceof Expr) {
return [];
}
$returnExprType = $scope->getType($returnExpr);
if ($this->shouldSkipReturnExprType($returnExprType)) {
return [];
}
if ($returnObjectType->equals($returnExprType)) {
return [];
}
// is subtype?
if (! $returnObjectType->isSuperTypeOf($returnExprType)->yes()) {
return [];
}
if (count($returnExprType->getObjectClassNames()) !== 1) {
return [];
}
$errorMessage = sprintf(self::ERROR_MESSAGE, $returnExprType->getObjectClassNames()[0]);
return [
RuleErrorBuilder::message($errorMessage)
->identifier('typePerfect.narrowReturnObjectType')
->build(),
];
}
private function shouldSkipReturnObjectType(ObjectType $objectType): bool
{
$classReflection = $objectType->getClassReflection();
if (! $classReflection instanceof ClassReflection) {
return true;
}
// cannot be more precise if final class
return $classReflection->isFinal();
}
private function shouldSkipScope(Scope $scope): bool
{
$classReflection = $scope->getClassReflection();
if (! $classReflection instanceof ClassReflection) {
return true;
}
return ! $classReflection->isClass();
}
private function shouldSkipReturnExprType(Type $type): bool
{
if (count($type->getObjectClassNames()) !== 1) {
return true;
}
if (count($type->getObjectClassReflections()) !== 1) {
return true;
}
return $type->getObjectClassReflections()[0]
->isAnonymous();
}
}

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Rules;
use PhpParser\Node;
use PhpParser\Node\Expr\ArrayDimFetch;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleError;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
use Rector\TypePerfect\Configuration;
/**
* @see \Rector\TypePerfect\Tests\Rules\NoArrayAccessOnObjectRule\NoArrayAccessOnObjectRuleTest
* @implements Rule<ArrayDimFetch>
*/
final readonly class NoArrayAccessOnObjectRule implements Rule
{
public const string ERROR_MESSAGE = 'Use explicit methods over array access on object';
/**
* @var string[]
*/
private const array ALLOWED_CLASSES = [
'SplFixedArray',
'SimpleXMLElement',
'Iterator',
'WeakMap',
'Aws\ResultInterface',
'Symfony\Component\DomCrawler\AbstractUriElement',
'Symfony\Component\Form\FormInterface',
'Symfony\Component\OptionsResolver\Options',
];
public function __construct(
private Configuration $configuration,
) {
}
/**
* @return class-string<Node>
*/
public function getNodeType(): string
{
return ArrayDimFetch::class;
}
/**
* @param ArrayDimFetch $node
* @return RuleError[]
*/
public function processNode(Node $node, Scope $scope): array
{
if (! $this->configuration->isNoArrayAccessOnObjectEnabled()) {
return [];
}
$varType = $scope->getType($node->var);
if (! $varType instanceof ObjectType) {
return [];
}
if ($this->isAllowedObjectType($varType)) {
return [];
}
return [
RuleErrorBuilder::message(self::ERROR_MESSAGE)
->identifier('typePerfect.noArrayAccessOnObject')
->build(),
];
}
private function isAllowedObjectType(ObjectType $objectType): bool
{
return array_any(self::ALLOWED_CLASSES, fn (string $allowedClass): bool => $objectType->isInstanceOf($allowedClass)->yes());
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Rules;
use PhpParser\Node;
use PhpParser\Node\Expr\Empty_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleError;
use PHPStan\Rules\RuleErrorBuilder;
use Rector\TypePerfect\Configuration;
use Rector\TypePerfect\Guard\EmptyIssetGuard;
/**
* @implements Rule<Empty_>
*/
final readonly class NoEmptyOnObjectRule implements Rule
{
public const string ERROR_MESSAGE = 'Use instanceof instead of empty() on object';
public function __construct(
private EmptyIssetGuard $emptyIssetGuard,
private Configuration $configuration,
) {
}
public function getNodeType(): string
{
return Empty_::class;
}
/**
* @param Empty_ $node
* @return RuleError[]
*/
public function processNode(Node $node, Scope $scope): array
{
if (! $this->configuration->isNoEmptyOnObjectEnabled()) {
return [];
}
if ($this->emptyIssetGuard->isLegal($node->expr, $scope)) {
return [];
}
return [
RuleErrorBuilder::message(self::ERROR_MESSAGE)
->identifier('typePerfect.noEmptyOnObject')
->build(),
];
}
}

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Rules;
use PhpParser\Node;
use PhpParser\Node\Expr\Isset_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleError;
use PHPStan\Rules\RuleErrorBuilder;
use Rector\TypePerfect\Configuration;
use Rector\TypePerfect\Guard\EmptyIssetGuard;
/**
* @see \Rector\TypePerfect\Tests\Rules\NoIssetOnObjectRule\NoIssetOnObjectRuleTest
* @implements Rule<Isset_>
*/
final readonly class NoIssetOnObjectRule implements Rule
{
public const string ERROR_MESSAGE = 'Use instanceof instead of isset() on object';
public function __construct(
private EmptyIssetGuard $emptyIssetGuard,
private Configuration $configuration,
) {
}
public function getNodeType(): string
{
return Isset_::class;
}
/**
* @param Isset_ $node
*
* @return RuleError[]
*/
public function processNode(Node $node, Scope $scope): array
{
if (! $this->configuration->isNoIssetOnObjectEnabled()) {
return [];
}
foreach ($node->vars as $var) {
if ($this->emptyIssetGuard->isLegal($var, $scope)) {
continue;
}
return [
RuleErrorBuilder::message(self::ERROR_MESSAGE)
->identifier('typePerfect.noIssetOnObject')
->build(),
];
}
return [];
}
}

View File

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Rules;
use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Node\Printer\Printer;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleError;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ErrorType;
use PHPStan\Type\MixedType;
use Rector\TypePerfect\Configuration;
/**
* @see \Rector\TypePerfect\Tests\Rules\NoMixedMethodCallerRule\NoMixedMethodCallerRuleTest
* @implements Rule<MethodCall>
*/
final readonly class NoMixedMethodCallerRule implements Rule
{
public const string ERROR_MESSAGE = 'Mixed variable in a `%s->...()` can skip important errors. Make sure the type is known';
public function __construct(
private Printer $printer,
private Configuration $configuration,
) {
}
/**
* @return class-string<Node>
*/
public function getNodeType(): string
{
return MethodCall::class;
}
/**
* @param MethodCall $node
* @return RuleError[]
*/
public function processNode(Node $node, Scope $scope): array
{
if (! $this->configuration->isNoMixedCallerEnabled()) {
return [];
}
$callerType = $scope->getType($node->var);
if (! $callerType instanceof MixedType) {
return [];
}
if ($callerType instanceof ErrorType) {
return [];
}
// if error, skip as well for false positive
if ($this->isPreviousTypeErrorType($node, $scope)) {
return [];
}
$printedMethodCall = $this->printer->prettyPrintExpr($node->var);
return [
RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $printedMethodCall))
->identifier('typePerfect.noMixedMethodCaller')
->build(),
];
}
private function isPreviousTypeErrorType(MethodCall $methodCall, Scope $scope): bool
{
$currentMethodCall = $methodCall;
while ($currentMethodCall->var instanceof MethodCall) {
$previousType = $scope->getType($currentMethodCall->var);
if ($previousType instanceof ErrorType) {
return true;
}
$currentMethodCall = $currentMethodCall->var;
}
return false;
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Rules;
use PhpParser\Node;
use PhpParser\Node\Expr\PropertyFetch;
use PHPStan\Analyser\Scope;
use PHPStan\Node\Printer\Printer;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleError;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\MixedType;
use Rector\TypePerfect\Configuration;
/**
* @see \Rector\TypePerfect\Tests\Rules\NoMixedPropertyFetcherRule\NoMixedPropertyFetcherRuleTest
* @implements Rule<PropertyFetch>
*/
final readonly class NoMixedPropertyFetcherRule implements Rule
{
public const string ERROR_MESSAGE = 'Mixed property fetch in a "%s->..." can skip important errors. Make sure the type is known';
public function __construct(
private Printer $printer,
private Configuration $configuration,
) {
}
/**
* @return class-string<Node>
*/
public function getNodeType(): string
{
return PropertyFetch::class;
}
/**
* @param PropertyFetch $node
* @return RuleError[]
*/
public function processNode(Node $node, Scope $scope): array
{
if (! $this->configuration->isNoMixedPropertyEnabled()) {
return [];
}
$callerType = $scope->getType($node->var);
if (! $callerType instanceof MixedType) {
return [];
}
$printedVar = $this->printer->prettyPrintExpr($node->var);
return [
RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $printedVar))
->identifier('typePerfect.noMixedPropertyFetcher')
->build(),
];
}
}

View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Rules;
use PhpParser\Node;
use PhpParser\Node\Stmt\ClassMethod;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\Php\PhpMethodReflection;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleError;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\MixedType;
use PHPStan\Type\Type;
use Rector\TypePerfect\Configuration;
use Rector\TypePerfect\Reflection\MethodNodeAnalyser;
/**
* @see \Rector\TypePerfect\Tests\Rules\ForbiddenParamTypeRemovalRule\ForbiddenParamTypeRemovalRuleTest
* @implements Rule<ClassMethod>
*/
final readonly class NoParamTypeRemovalRule implements Rule
{
public const string ERROR_MESSAGE = 'Removing parent param type is forbidden';
public function __construct(
private MethodNodeAnalyser $methodNodeAnalyser,
private Configuration $configuration,
) {
}
/**
* @return class-string<Node>
*/
public function getNodeType(): string
{
return ClassMethod::class;
}
/**
* @param ClassMethod $node
* @return RuleError[]
*/
public function processNode(Node $node, Scope $scope): array
{
if (! $this->configuration->isNoParamTypeRemovalEnabled()) {
return [];
}
if ($node->params === []) {
return [];
}
$classMethodName = (string) $node->name;
if ($classMethodName === '__construct') {
return [];
}
$parentClassMethodReflection = $this->methodNodeAnalyser->matchFirstParentClassMethod($scope, $classMethodName);
if (! $parentClassMethodReflection instanceof PhpMethodReflection) {
return [];
}
foreach ($node->params as $paramPosition => $param) {
if ($param->type !== null) {
continue;
}
$parentParamType = $this->resolveParentParamType($parentClassMethodReflection, $paramPosition);
if ($parentParamType instanceof MixedType) {
continue;
}
// removed param type!
return [
RuleErrorBuilder::message(self::ERROR_MESSAGE)
->identifier('typePerfect.noParamTypeRemoval')
->build(),
];
}
return [];
}
private function resolveParentParamType(PhpMethodReflection $phpMethodReflection, int $paramPosition): Type
{
foreach ($phpMethodReflection->getVariants() as $extendedParametersAcceptor) {
foreach ($extendedParametersAcceptor->getParameters() as $parentParamPosition => $parameterReflectionWithPhpDoc) {
if ($paramPosition !== $parentParamPosition) {
continue;
}
return $parameterReflectionWithPhpDoc->getNativeType();
}
}
return new MixedType();
}
}

View File

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Rules;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Return_;
use PhpParser\NodeFinder;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleError;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\Constant\ConstantBooleanType;
use Rector\TypePerfect\Configuration;
/**
* @implements Rule<ClassMethod>
*/
final readonly class ReturnNullOverFalseRule implements Rule
{
/**
* @api
*/
public const string ERROR_MESSAGE = 'Returning false in non return bool class method. Use null with type|null instead or add bool return type';
private NodeFinder $nodeFinder;
public function __construct(
private Configuration $configuration,
) {
$this->nodeFinder = new NodeFinder();
}
/**
* @return class-string<Node>
*/
public function getNodeType(): string
{
return ClassMethod::class;
}
/**
* @param ClassMethod $node
* @return RuleError[]
*/
public function processNode(Node $node, Scope $scope): array
{
if (! $this->configuration->isNoFalsyReturnEnabled()) {
return [];
}
if ($node->stmts === null) {
return [];
}
if ($node->returnType instanceof Node) {
return [];
}
/** @var Return_[] $returns */
$returns = $this->nodeFinder->findInstanceOf($node->stmts, Return_::class);
$hasFalseType = false;
$hasTrueType = false;
foreach ($returns as $return) {
if (! $return->expr instanceof Expr) {
continue;
}
$exprType = $scope->getType($return->expr);
if (! $exprType instanceof ConstantBooleanType) {
if ($exprType->isBoolean()->yes()) {
return [];
}
continue;
}
if ($exprType->getValue()) {
$hasTrueType = true;
continue;
}
$hasFalseType = true;
}
if (! $hasTrueType && $hasFalseType) {
return [
RuleErrorBuilder::message(self::ERROR_MESSAGE)
->identifier('typePerfect.nullOverFalse')
->build(),
];
}
return [];
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\ValueObject;
final readonly class MethodCallReference
{
public function __construct(
private string $class,
private string $method
) {
}
public function getClass(): string
{
return $this->class;
}
public function getMethod(): string
{
return $this->method;
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Tests\Rules\NarrowPrivateClassMethodParamTypeRule\Fixture;
use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use Rector\TypePerfect\Tests\Rules\NarrowPrivateClassMethodParamTypeRule\Source\AnotherClassWithRun;
use Rector\TypePerfect\Tests\Rules\PreferredClassRule\Fixture\SomeStaticCall;
class DifferentClassSameMethodCallName
{
/**
* @param SomeStaticCall|MethodCall $node
*/
public function process(AnotherClassWithRun $anotherClassWithRun)
{
$anotherClassWithRun->run($anotherClassWithRun);
}
/**
* @param SomeStaticCall|MethodCall $node
*/
private function run(Node $node)
{
if ($node->name instanceof MethodCall) {
$this->run($node->name);
}
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Tests\Rules\NarrowPrivateClassMethodParamTypeRule\Fixture;
use PhpParser\Node;
class DoubleShot
{
public function run(Node\Arg $arg, Node\Param $param)
{
$this->isCheck($arg, $param);
}
private function isCheck(\PhpParser\Node $arg, \PhpParser\Node $param)
{
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Tests\Rules\NarrowPrivateClassMethodParamTypeRule\Fixture;
use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
class Fixture
{
public function run(Node $node)
{
if ($node instanceof MethodCall) {
$this->isCheck($node);
}
}
private function isCheck(Node $node)
{
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace SkipAbstractBase;
use Rector\TypePerfect\Tests\Rules\NarrowPrivateClassMethodParamTypeRule\Source\ConceptBase;
use Rector\TypePerfect\Tests\Rules\NarrowPrivateClassMethodParamTypeRule\Source\ConceptImpl1;
class MyService {
public function run(ConceptImpl1 $arg)
{
$this->isCheck($arg);
}
private function isCheck(ConceptBase $arg)
{
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Tests\Rules\NarrowPrivateClassMethodParamTypeRule\Fixture;
use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
class SkipAlreadyCorrectType
{
public function run(Node $node)
{
if ($node instanceof MethodCall) {
$this->isCheck($node);
}
}
private function isCheck(MethodCall $node)
{
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Tests\Rules\NarrowPrivateClassMethodParamTypeRule\Fixture;
use PhpParser\Node\Expr\Assign;
use PhpParser\Node\Stmt\Return_;
final class SkipCorrectUnionType
{
public function run(\PhpParser\Node $node)
{
if (! $node instanceof Assign && ! $node instanceof Return_) {
return [];
}
if (! $this->isIncludeOnceOrRequireOnce($node)) {
return [];
}
return [];
}
/**
* @param Assign|Return_ $node
*/
private function isIncludeOnceOrRequireOnce(\PhpParser\Node $node)
{
return true;
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Tests\Rules\NarrowPrivateClassMethodParamTypeRule\Fixture;
use PHPStan\Node\FileNode;
use Rector\Core\PhpParser\Node\CustomNode\FileWithoutNamespace;
final class SkipDuplicatedCallOfSameMethodWithComment2
{
public function firstMethod()
{
$this->printFile(new FileWithoutNamespace([]));
}
private function printFile(\PhpParser\Node $node)
{
}
public function secondMethod()
{
// some comment
$this->printFile(new FileNode([]));
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Tests\Rules\NarrowPrivateClassMethodParamTypeRule\Fixture;
use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
/**
* @template T of MethodCall
*/
class SkipGenericType
{
/**
* @param T $node
* @return void
*/
public function run(Node $node)
{
$this->getsAGeneric($node);
}
/**
* @param T $node
* @return void
*/
private function getsAGeneric(Node $node)
{
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Tests\Rules\NarrowPrivateClassMethodParamTypeRule\Fixture;
use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
class SkipMayOverrideArg
{
public function run(Node $node)
{
if ($node instanceof MethodCall) {
// on this part, $node may be overriden
$node = $this->mayOverrideNode();
$this->isCheck($node);
}
}
private function isCheck(Node $node)
{
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Tests\Rules\NarrowPrivateClassMethodParamTypeRule\Fixture;
use PhpParser\Node;
class SkipMixed
{
public function run($arg)
{
$this->isCheck($arg);
}
private function isCheck(\PhpParser\Node $arg)
{
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Tests\Rules\NarrowPrivateClassMethodParamTypeRule\Fixture;
use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\PropertyFetch;
class SkipMultipleUsed
{
public function run(Node $node)
{
if ($node instanceof MethodCall) {
$this->isCheck($node);
}
if ($node instanceof PropertyFetch) {
$this->isCheck($node);
}
}
private function isCheck(Node $node)
{
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Rector\TypePerfect\Tests\Rules\NarrowPrivateClassMethodParamTypeRule\Fixture;
use PhpParser\Node;
final class SkipNoArgs
{
public function run()
{
$this->execute();
}
}

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