Passed
Pull Request — master (#71)
by Sergei
03:02
created

VarDumper   F

Complexity

Total Complexity 79

Size/Duplication

Total Lines 507
Duplicated Lines 0 %

Test Coverage

Coverage 94.2%

Importance

Changes 28
Bugs 4 Features 0
Metric Value
eloc 198
dl 0
loc 507
ccs 195
cts 207
cp 0.942
rs 2.08
c 28
b 4
f 0
wmc 79

20 Methods

Rating   Name   Duplication   Size   Complexity  
A create() 0 3 1
A exportDateTime() 0 7 1
A withOffset() 0 6 1
A __construct() 0 2 1
B exportPrimitives() 0 59 9
A getObjectId() 0 3 1
D exportInternal() 0 62 21
A asPrimitives() 0 3 1
A getPropertyName() 0 16 4
A exportVariable() 0 3 1
A getObjectProperties() 0 8 3
A asJson() 0 10 2
A dump() 0 3 1
A asString() 0 10 2
A exportObject() 0 30 5
A exportObjectFallback() 0 20 6
C dumpInternal() 0 59 15
A export() 0 5 1
A exportClosure() 0 7 2
A getObjectDescription() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like VarDumper often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use VarDumper, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\VarDumper;
6
7
use __PHP_Incomplete_Class;
8
use Closure;
9
use DateTimeInterface;
10
use Exception;
11
use IteratorAggregate;
12
use JsonException;
13
use JsonSerializable;
14
use ReflectionException;
15
use ReflectionObject;
16
use Yiisoft\Arrays\ArrayableInterface;
17
18
use function array_keys;
19
use function get_class;
20
use function gettype;
21
use function highlight_string;
22
use function method_exists;
23
use function next;
24
use function preg_replace;
25
use function spl_object_id;
26
use function str_repeat;
27
use function strtr;
28
use function trim;
29
use function var_export;
30
31
/**
32
 * VarDumper provides enhanced versions of the PHP functions {@see var_dump()} and {@see var_export()}.
33
 * It can:
34
 *
35
 * - Correctly identify the recursively referenced objects in a complex object structure.
36
 * - Recursively control depth to avoid indefinite recursive display of some peculiar variables.
37
 * - Export closures and objects.
38
 * - Highlight output.
39
 * - Format output.
40
 */
41
final class VarDumper
42
{
43
    public const VAR_TYPE_PROPERTY = '$__type__$';
44
    public const OBJECT_ID_PROPERTY = '$__id__$';
45
    public const OBJECT_CLASS_PROPERTY = '$__class__$';
46
    public const DEPTH_LIMIT_EXCEEDED_PROPERTY = '$__depth_limit_exceeded__$';
47
48
    public const VAR_TYPE_ARRAY = 'array';
49
    public const VAR_TYPE_OBJECT = 'object';
50
    public const VAR_TYPE_RESOURCE = 'resource';
51
    /**
52
     * @var string[] Variables using in closure scope.
53
     */
54
    private array $useVarInClosures = [];
55
    private bool $serializeObjects = true;
56
    /**
57
     * @var string Offset to use to indicate nesting level.
58
     */
59
    private string $offset = '    ';
60
    private static ?ClosureExporter $closureExporter = null;
61
62
    /**
63
     * @param mixed $variable Variable to dump.
64
     */
65 147
    private function __construct(private mixed $variable)
66
    {
67 147
    }
68
69
    /**
70
     * @param mixed $variable Variable to dump.
71
     *
72
     * @return static An instance containing variable to dump.
73
     */
74 147
    public static function create(mixed $variable): self
75
    {
76 147
        return new self($variable);
77
    }
78
79
    /**
80
     * Prints a variable.
81
     *
82
     * This method achieves the similar functionality as {@see var_dump()} and {@see print_r()}
83
     * but is more robust when handling complex objects.
84
     *
85
     * @param mixed $variable Variable to be dumped.
86
     * @param int $depth Maximum depth that the dumper should go into the variable. Defaults to 10.
87
     * @param bool $highlight Whether the result should be syntax-highlighted.
88
     *
89
     * @throws ReflectionException
90
     */
91 6
    public static function dump(mixed $variable, int $depth = 10, bool $highlight = true): void
92
    {
93 6
        echo self::create($variable)->asString($depth, $highlight);
94
    }
95
96
    /**
97
     * Sets offset string to use to indicate nesting level.
98
     *
99
     * @param string $offset The offset string.
100
     *
101
     * @return static New instance with a given offset.
102
     */
103
    public function withOffset(string $offset): self
104
    {
105
        $new = clone $this;
106
        $new->offset = $offset;
107
108
        return $new;
109
    }
110
111
    /**
112
     * Dumps a variable in terms of a string.
113
     *
114
     * This method achieves the similar functionality as {@see var_dump()} and {@see print_r()}
115
     * but is more robust when handling complex objects.
116
     *
117
     * @param int $depth Maximum depth that the dumper should go into the variable. Defaults to 10.
118
     * @param bool $highlight Whether the result should be syntax-highlighted.
119
     *
120
     * @throws ReflectionException
121
     *
122
     * @return string The string representation of the variable.
123
     */
124 33
    public function asString(int $depth = 10, bool $highlight = false): string
125
    {
126 33
        $output = $this->dumpInternal($this->variable, true, $depth, 0);
127
128 33
        if ($highlight) {
129 1
            $result = highlight_string("<?php\n" . $output, true);
130 1
            $output = preg_replace('/&lt;\\?php<br \\/>/', '', $result, 1);
131
        }
132
133 33
        return $output;
134
    }
135
136
    /**
137
     * Dumps a variable as a JSON string.
138
     *
139
     * This method provides similar functionality to the {@see json_encode()}
140
     * but is more robust when handling complex objects.
141
     *
142
     * @param bool $format Use whitespaces in returned data to format it
143
     * @param int $depth Maximum depth that the dumper should go into the variable. Defaults to 10.
144
     *
145
     * @throws JsonException
146
     *
147
     * @return string The json representation of the variable.
148
     */
149 30
    public function asJson(bool $format = true, int $depth = 10): string
150
    {
151
        /** @var mixed $output */
152 30
        $output = $this->asPrimitives($depth);
153
154 30
        if ($format) {
155 30
            return json_encode($output, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
156
        }
157
158
        return json_encode($output, JSON_THROW_ON_ERROR);
159
    }
160
161
    /**
162
     * Dumps the variable as PHP primitives in JSON decoded style.
163
     *
164
     * @param int $depth Maximum depth that the dumper should go into the variable. Defaults to 10.
165
     *
166
     * @throws ReflectionException
167
     *
168
     * @return mixed
169
     */
170 60
    public function asPrimitives(int $depth = 10): mixed
171
    {
172 60
        return $this->exportPrimitives($this->variable, $depth, 0);
173
    }
174
175
    /**
176
     * Exports a variable as a string containing PHP code.
177
     *
178
     * The string is a valid PHP expression that can be evaluated by PHP parser
179
     * and the evaluation result will give back the variable value.
180
     *
181
     * This method is similar to {@see var_export()}. The main difference is that
182
     * it generates more compact string representation using short array syntax.
183
     *
184
     * It also handles closures with {@see ClosureExporter} and objects
185
     * by using the PHP functions {@see serialize()} and {@see unserialize()}.
186
     *
187
     * @param bool $format Whatever to format code.
188
     * @param string[] $useVariables Array of variables used in `use` statement (['$params', '$config'])
189
     * @param bool $serializeObjects If it is true all objects will be serialized except objects with closure(s). If it
190
     * is false only objects of internal classes will be serialized.
191
     *
192
     * @throws ReflectionException
193
     *
194
     * @return string A PHP code representation of the variable.
195
     */
196 56
    public function export(bool $format = true, array $useVariables = [], bool $serializeObjects = true): string
197
    {
198 56
        $this->useVarInClosures = $useVariables;
199 56
        $this->serializeObjects = $serializeObjects;
200 56
        return $this->exportInternal($this->variable, $format, 0);
201
    }
202
203
    /**
204
     * @param mixed $var Variable to be dumped.
205
     * @param bool $format Whatever to format code.
206
     * @param int $depth Maximum depth.
207
     * @param int $level Current depth.
208
     *
209
     * @throws ReflectionException
210
     *
211
     * @return string
212
     */
213 33
    private function dumpInternal(mixed $var, bool $format, int $depth, int $level): string
214
    {
215 33
        switch (gettype($var)) {
216 33
            case 'resource':
217 32
            case 'resource (closed)':
218 1
                return '{resource}';
219 32
            case 'NULL':
220 1
                return 'null';
221 31
            case 'array':
222 6
                if ($depth <= $level) {
223 1
                    return '[...]';
224
                }
225
226 5
                if (empty($var)) {
227 2
                    return '[]';
228
                }
229
230 3
                $output = '';
231 3
                $keys = array_keys($var);
232 3
                $spaces = str_repeat($this->offset, $level);
233 3
                $output .= '[';
234
235 3
                foreach ($keys as $name) {
236 3
                    if ($format) {
237 3
                        $output .= "\n" . $spaces . $this->offset;
238
                    }
239 3
                    $output .= $this->exportVariable($name);
240 3
                    $output .= ' => ';
241 3
                    $output .= $this->dumpInternal($var[$name], $format, $depth, $level + 1);
242
                }
243
244 3
                return $format
245 3
                    ? $output . "\n" . $spaces . ']'
246 3
                    : $output . ']';
247 29
            case 'object':
248 17
                if ($var instanceof Closure) {
249 11
                    return $this->exportClosure($var);
250
                }
251 8
                if ($var instanceof DateTimeInterface) {
252 1
                    return $this->exportDateTime($var);
253
                }
254
255 7
                if ($depth <= $level) {
256 1
                    return $this->getObjectDescription($var) . ' (...)';
257
                }
258
259 6
                $spaces = str_repeat($this->offset, $level);
260 6
                $output = $this->getObjectDescription($var) . "\n" . $spaces . '(';
261 6
                $objectProperties = $this->getObjectProperties($var);
262
263
                /** @psalm-var mixed $value */
264 6
                foreach ($objectProperties as $name => $value) {
265 4
                    $propertyName = strtr(trim((string) $name), "\0", '::');
266 4
                    $output .= "\n" . $spaces . $this->offset . '[' . $propertyName . '] => ';
267 4
                    $output .= $this->dumpInternal($value, $format, $depth, $level + 1);
268
                }
269 6
                return $output . "\n" . $spaces . ')';
270
            default:
271 14
                return $this->exportVariable($var);
272
        }
273
    }
274
275
    /**
276
     * @param mixed $variable Variable to be exported.
277
     * @param bool $format Whatever to format code.
278
     * @param int $level Current depth.
279
     *
280
     * @throws ReflectionException
281
     *
282
     * @return string
283
     */
284 56
    private function exportInternal(mixed $variable, bool $format, int $level): string
285
    {
286 56
        $spaces = str_repeat($this->offset, $level);
287 56
        switch (gettype($variable)) {
288 56
            case 'NULL':
289 2
                return 'null';
290 54
            case 'array':
291 9
                if (empty($variable)) {
292 2
                    return '[]';
293
                }
294
295 7
                $keys = array_keys($variable);
296 7
                $outputKeys = $keys !== array_keys($keys);
297 7
                $output = '[';
298
299 7
                foreach ($keys as $key) {
300 7
                    if ($format) {
301 4
                        $output .= "\n" . $spaces . $this->offset;
302
                    }
303 7
                    if ($outputKeys) {
304 3
                        $output .= $this->exportVariable($key);
305 3
                        $output .= ' => ';
306
                    }
307 7
                    $output .= $this->exportInternal($variable[$key], $format, $level + 1);
308 7
                    if ($format || next($keys) !== false) {
309 6
                        $output .= ',';
310
                    }
311
                }
312
313 7
                return $format
314 4
                    ? $output . "\n" . $spaces . ']'
315 7
                    : $output . ']';
316 52
            case 'object':
317 35
                if ($variable instanceof Closure) {
318 25
                    return $this->exportClosure($variable, $level);
319
                }
320 15
                if ($variable instanceof DateTimeInterface) {
321 4
                    return $this->exportDateTime($variable);
322
                }
323
324
325 11
                $reflectionObject = new ReflectionObject($variable);
326
                try {
327 11
                    if ($this->serializeObjects || $reflectionObject->isInternal() || $reflectionObject->isAnonymous()) {
328 9
                        return "unserialize({$this->exportVariable(serialize($variable))})";
329
                    }
330
331 2
                    return $this->exportObject($variable, $reflectionObject, $format, $level);
332 6
                } catch (Exception) {
333
                    // Serialize may fail, for example: if object contains a `\Closure` instance so we use a fallback.
334 6
                    if ($this->serializeObjects && !$reflectionObject->isInternal() && !$reflectionObject->isAnonymous()) {
335
                        try {
336 4
                            return $this->exportObject($variable, $reflectionObject, $format, $level);
337
                        } catch (Exception) {
338
                            return $this->exportObjectFallback($variable, $format, $level);
339
                        }
340
                    }
341
342 2
                    return $this->exportObjectFallback($variable, $format, $level);
343
                }
344
            default:
345 19
                return $this->exportVariable($variable);
346
        }
347
    }
348
349
    /**
350
     *
351
     * @throws ReflectionException
352
     *
353
     * @return mixed
354
     * @psalm-param mixed $var
355
     */
356 60
    private function exportPrimitives(mixed $var, int $depth, int $level): mixed
357
    {
358 60
        switch (gettype($var)) {
359 60
            case 'resource':
360 58
            case 'resource (closed)':
361
                /** @var resource $var */
362 4
                $id = get_resource_id($var);
363 4
                $type = get_resource_type($var);
364
365 4
                return [
366 4
                    self::VAR_TYPE_PROPERTY => self::VAR_TYPE_RESOURCE,
367 4
                    'id' => $id,
368 4
                    'type' => $type,
369 4
                    'closed' => !is_resource($var),
370 4
                ];
371 56
            case 'array':
372 12
                if ($depth <= $level) {
373 2
                    return [
374 2
                        self::VAR_TYPE_PROPERTY => self::VAR_TYPE_ARRAY,
375 2
                        self::DEPTH_LIMIT_EXCEEDED_PROPERTY => true,
376 2
                    ];
377
                }
378
379
                /** @psalm-suppress MissingClosureReturnType */
380 12
                return array_map(fn ($value) => $this->exportPrimitives($value, $depth, $level + 1), $var);
381 52
            case 'object':
382 34
                if ($var instanceof Closure) {
383 20
                    return $this->exportClosure($var);
384
                }
385
386 16
                $objectClass = $var::class;
387 16
                $objectId = $this->getObjectId($var);
388 16
                if ($depth <= $level) {
389 2
                    return [
390 2
                        self::VAR_TYPE_PROPERTY => self::VAR_TYPE_OBJECT,
391 2
                        self::OBJECT_ID_PROPERTY => $objectId,
392 2
                        self::OBJECT_CLASS_PROPERTY => $objectClass,
393 2
                        self::DEPTH_LIMIT_EXCEEDED_PROPERTY => true,
394 2
                    ];
395
                }
396
397 16
                $objectProperties = $this->getObjectProperties($var);
398
399 16
                $output = [
400 16
                    self::OBJECT_ID_PROPERTY => $objectId,
401 16
                    self::OBJECT_CLASS_PROPERTY => $objectClass,
402 16
                ];
403
                /**
404
                 * @psalm-var mixed $value
405
                 * @psalm-var string $name
406
                 */
407 16
                foreach ($objectProperties as $name => $value) {
408 14
                    $propertyName = $this->getPropertyName($name);
409
                    /** @psalm-suppress MixedAssignment */
410 14
                    $output[$propertyName] = $this->exportPrimitives($value, $depth, $level + 1);
411
                }
412 16
                return $output;
413
            default:
414 28
                return $var;
415
        }
416
    }
417
418 20
    private function getPropertyName(string|int $property): string
419
    {
420 20
        if (is_int($property)) {
421 2
            return (string) $property;
422
        }
423 18
        $property = str_replace("\0", '::', trim($property));
424
425 18
        if (str_starts_with($property, '*::')) {
426
            return substr($property, 3);
427
        }
428
429 18
        if (($pos = strpos($property, '::')) !== false) {
430 6
            return substr($property, $pos + 2);
431
        }
432
433 12
        return $property;
434
    }
435
436
    /**
437
     * @throws ReflectionException
438
     *
439
     * @return string
440
     */
441 2
    private function exportObjectFallback(object $variable, bool $format, int $level): string
442
    {
443 2
        if ($variable instanceof ArrayableInterface) {
444
            return $this->exportInternal($variable->toArray(), $format, $level);
445
        }
446
447 2
        if ($variable instanceof JsonSerializable) {
448
            return $this->exportInternal($variable->jsonSerialize(), $format, $level);
449
        }
450
451 2
        if ($variable instanceof IteratorAggregate) {
452
            return $this->exportInternal(iterator_to_array($variable), $format, $level);
453
        }
454
455
        /** @psalm-suppress RedundantCondition */
456 2
        if ('__PHP_Incomplete_Class' !== $variable::class && method_exists($variable, '__toString')) {
457
            return $this->exportVariable($variable->__toString());
458
        }
459
460 2
        return $this->exportVariable(self::create($variable)->asString());
461
    }
462
463 6
    private function exportObject(object $variable, ReflectionObject $reflectionObject, bool $format, int $level): string
464
    {
465 6
        $spaces = str_repeat($this->offset, $level);
466 6
        $objectProperties = $this->getObjectProperties($variable);
467 6
        $class = $variable::class;
468 6
        $use = $this->useVarInClosures === [] ? '' : ' use (' . implode(', ', $this->useVarInClosures) . ')';
469 6
        $lines = ['(static function ()' . $use . ' {',];
470 6
        if ($reflectionObject->getConstructor() === null) {
471 2
            $lines = [...$lines, $this->offset . '$object = new ' . $class . '();', $this->offset . '(function ()' . $use . ' {'];
472
        } else {
473 4
            $lines = [...$lines, $this->offset . '$class = new \ReflectionClass(\'' . $class . '\');', $this->offset . '$object = $class->newInstanceWithoutConstructor();', $this->offset . '(function ()' . $use . ' {'];
474
        }
475 6
        $endLines = [
476 6
            $this->offset . '})->bindTo($object, \'' . $class . '\')();',
477 6
            '',
478 6
            $this->offset . 'return $object;',
479 6
            '})()',
480 6
        ];
481
482
        /**
483
         * @psalm-var mixed $value
484
         * @psalm-var string $name
485
         */
486 6
        foreach ($objectProperties as $name => $value) {
487 6
            $propertyName = $this->getPropertyName($name);
488 6
            $lines[] = $this->offset . $this->offset . '$this->' . $propertyName . ' = ' .
489 6
                $this->exportInternal($value, $format, $level + 2) . ';';
490
        }
491
492 6
        return implode("\n" . ($format ? $spaces : ''), array_merge($lines, $endLines));
493
    }
494
495
    /**
496
     * Exports a {@see \Closure} instance.
497
     *
498
     * @param Closure $closure Closure instance.
499
     *
500
     * @throws ReflectionException
501
     *
502
     * @return string
503
     */
504 56
    private function exportClosure(Closure $closure, int $level = 0): string
505
    {
506 56
        if (self::$closureExporter === null) {
507 1
            self::$closureExporter = new ClosureExporter();
508
        }
509
510 56
        return self::$closureExporter->export($closure, $level);
0 ignored issues
show
Bug introduced by
The method export() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

510
        return self::$closureExporter->/** @scrutinizer ignore-call */ export($closure, $level);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
511
    }
512
513
    /**
514
     * @return string
515
     */
516 40
    private function exportVariable(mixed $variable): string
517
    {
518 40
        return var_export($variable, true);
519
    }
520
521 7
    private function getObjectDescription(object $object): string
522
    {
523 7
        return $object::class . '#' . $this->getObjectId($object);
524
    }
525
526 23
    private function getObjectId(object $object): string
527
    {
528 23
        return (string) spl_object_id($object);
529
    }
530
531 28
    private function getObjectProperties(object $var): array
532
    {
533 28
        if (!$var instanceof __PHP_Incomplete_Class && method_exists($var, '__debugInfo')) {
534
            /** @var array $var */
535 3
            $var = $var->__debugInfo();
536
        }
537
538 28
        return (array) $var;
539
    }
540
541 5
    private function exportDateTime(DateTimeInterface $variable): string
542
    {
543 5
        return sprintf(
544 5
            "new \%s('%s', new \DateTimeZone('%s'))",
545 5
            $variable::class,
546 5
            $variable->format(DateTimeInterface::RFC3339_EXTENDED),
547 5
            $variable->getTimezone()->getName()
548 5
        );
549
    }
550
}
551