Passed
Pull Request — master (#71)
by Sergei
04:01 queued 01:38
created

VarDumper   F

Complexity

Total Complexity 79

Size/Duplication

Total Lines 515
Duplicated Lines 0 %

Test Coverage

Coverage 94.44%

Importance

Changes 29
Bugs 4 Features 0
Metric Value
eloc 205
c 29
b 4
f 0
dl 0
loc 515
ccs 204
cts 216
cp 0.9444
rs 2.08
wmc 79

20 Methods

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

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