Passed
Push — master ( c69a4e...3c6756 )
by Michael
21:44 queued 13:20
created

Utils::detectEncoding()   B

Complexity

Conditions 8
Paths 11

Size

Total Lines 34
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 13
nc 11
nop 1
dl 0
loc 34
rs 8.4444
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * The MIT License (MIT)
7
 *
8
 * Copyright (c) 2013 Jonathan Vollebregt ([email protected]), Rokas Šleinius ([email protected])
9
 *
10
 * Permission is hereby granted, free of charge, to any person obtaining a copy of
11
 * this software and associated documentation files (the "Software"), to deal in
12
 * the Software without restriction, including without limitation the rights to
13
 * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
14
 * the Software, and to permit persons to whom the Software is furnished to do so,
15
 * subject to the following conditions:
16
 *
17
 * The above copyright notice and this permission notice shall be included in all
18
 * copies or substantial portions of the Software.
19
 *
20
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
22
 * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
23
 * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
24
 * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
25
 * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
26
 */
27
28
namespace Kint;
29
30
use Kint\Value\StringValue;
31
use Kint\Value\TraceFrameValue;
32
use ReflectionNamedType;
33
use ReflectionType;
34
use UnexpectedValueException;
35
36
/**
37
 * A collection of utility methods. Should all be static methods with no dependencies.
38
 *
39
 * @psalm-import-type Encoding from StringValue
40
 * @psalm-import-type TraceFrame from TraceFrameValue
41
 */
42
final class Utils
43
{
44
    public const BT_STRUCTURE = [
45
        'function' => 'string',
46
        'line' => 'integer',
47
        'file' => 'string',
48
        'class' => 'string',
49
        'object' => 'object',
50
        'type' => 'string',
51
        'args' => 'array',
52
    ];
53
54
    public const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
55
56
    /**
57
     * @var array Character encodings to detect
58
     *
59
     * @see https://secure.php.net/function.mb-detect-order
60
     *
61
     * In practice, mb_detect_encoding can only successfully determine the
62
     * difference between the following common charsets at once without
63
     * breaking things for one of the other charsets:
64
     * - ASCII
65
     * - UTF-8
66
     * - SJIS
67
     * - EUC-JP
68
     *
69
     * The order of the charsets is significant. If you put UTF-8 before ASCII
70
     * it will never match ASCII, because UTF-8 is a superset of ASCII.
71
     * Similarly, SJIS and EUC-JP frequently match UTF-8 strings, so you should
72
     * check UTF-8 first. SJIS and EUC-JP seem to work either way, but SJIS is
73
     * more common so it should probably be first.
74
     *
75
     * While you're free to experiment with other charsets, remember to keep
76
     * this behavior in mind when setting up your char_encodings array.
77
     *
78
     * This depends on the mbstring extension
79
     */
80
    public static array $char_encodings = [
81
        'ASCII',
82
        'UTF-8',
83
    ];
84
85
    /**
86
     * @var array Legacy character encodings to detect
87
     *
88
     * @see https://secure.php.net/function.iconv
89
     *
90
     * Assuming the other encoding checks fail, this will perform a
91
     * simple iconv conversion to check for invalid bytes. If any are
92
     * found it will not match.
93
     *
94
     * This can be useful for ambiguous single byte encodings like
95
     * windows-125x and iso-8859-x which have practically undetectable
96
     * differences because they use every single byte available.
97
     *
98
     * This is *NOT* reliable and should not be trusted implicitly. Since it
99
     * works by triggering and suppressing conversion warnings, your error
100
     * handler may complain.
101
     *
102
     * As with char_encodings, the order of the charsets is significant.
103
     *
104
     * This depends on the iconv extension
105
     */
106
    public static array $legacy_encodings = [];
107
108
    /**
109
     * @var array Path aliases that will be displayed instead of the full path.
110
     *
111
     * Keys are paths, values are replacement strings
112
     *
113
     * Example for laravel:
114
     *
115
     * Utils::$path_aliases = [
116
     *     base_path() => '<BASE>',
117
     *     app_path() => '<APP>',
118
     *     base_path().'/vendor' => '<VENDOR>',
119
     * ];
120
     *
121
     * Defaults to [$_SERVER['DOCUMENT_ROOT'] => '<ROOT>']
122
     *
123
     * @psalm-var array<non-empty-string, string>
124
     */
125
    public static array $path_aliases = [];
126
127
    /**
128
     * @codeCoverageIgnore
129
     *
130
     * @psalm-suppress UnusedConstructor
131
     */
132
    private function __construct()
133
    {
134
    }
135
136
    /**
137
     * Turns a byte value into a human-readable representation.
138
     *
139
     * @param int $value Amount of bytes
140
     *
141
     * @return array Human readable value and unit
142
     *
143
     * @psalm-return array{value: float, unit: 'B'|'KB'|'MB'|'GB'|'TB'}
144
     *
145
     * @psalm-pure
146
     */
147
    public static function getHumanReadableBytes(int $value): array
148
    {
149
        $negative = $value < 0;
150
        $value = \abs($value);
151
152
        if ($value < 1024) {
153
            $i = 0;
154
            $value = \floor($value);
155
        } elseif ($value < 0xFFFCCCCCCCCCCCC >> 40) {
156
            $i = 1;
157
        } elseif ($value < 0xFFFCCCCCCCCCCCC >> 30) {
158
            $i = 2;
159
        } elseif ($value < 0xFFFCCCCCCCCCCCC >> 20) {
160
            $i = 3;
161
        } else {
162
            $i = 4;
163
        }
164
165
        if ($i) {
166
            $value = $value / \pow(1024, $i);
167
        }
168
169
        if ($negative) {
170
            $value *= -1;
171
        }
172
173
        return [
174
            'value' => \round($value, 1),
175
            'unit' => self::BYTE_UNITS[$i],
176
        ];
177
    }
178
179
    /** @psalm-pure */
180
    public static function isSequential(array $array): bool
181
    {
182
        return \array_keys($array) === \range(0, \count($array) - 1);
183
    }
184
185
    /** @psalm-pure */
186
    public static function isAssoc(array $array): bool
187
    {
188
        return (bool) \count(\array_filter(\array_keys($array), 'is_string'));
189
    }
190
191
    /**
192
     * @psalm-assert-if-true list<TraceFrame> $trace
193
     */
194
    public static function isTrace(array $trace): bool
195
    {
196
        if (!self::isSequential($trace)) {
197
            return false;
198
        }
199
200
        $file_found = false;
201
202
        foreach ($trace as $frame) {
203
            if (!\is_array($frame) || !isset($frame['function'])) {
204
                return false;
205
            }
206
207
            if (isset($frame['class']) && !\class_exists($frame['class'], false)) {
208
                return false;
209
            }
210
211
            foreach ($frame as $key => $val) {
212
                if (!isset(self::BT_STRUCTURE[$key])) {
213
                    return false;
214
                }
215
216
                if (\gettype($val) !== self::BT_STRUCTURE[$key]) {
217
                    return false;
218
                }
219
220
                if ('file' === $key) {
221
                    $file_found = true;
222
                }
223
            }
224
        }
225
226
        return $file_found;
227
    }
228
229
    /**
230
     * @psalm-param TraceFrame $frame
231
     *
232
     * @psalm-pure
233
     */
234
    public static function traceFrameIsListed(array $frame, array $matches): bool
235
    {
236
        if (isset($frame['class'])) {
237
            $called = [\strtolower($frame['class']), \strtolower($frame['function'])];
238
        } else {
239
            $called = \strtolower($frame['function']);
240
        }
241
242
        return \in_array($called, $matches, true);
243
    }
244
245
    /** @psalm-pure */
246
    public static function normalizeAliases(array $aliases): array
247
    {
248
        foreach ($aliases as $index => $alias) {
249
            if (\is_array($alias) && 2 === \count($alias)) {
250
                $alias = \array_values(\array_filter($alias, 'is_string'));
251
252
                if (2 === \count($alias) && self::isValidPhpName($alias[1]) && self::isValidPhpNamespace($alias[0])) {
253
                    $aliases[$index] = [
254
                        \strtolower(\ltrim($alias[0], '\\')),
255
                        \strtolower($alias[1]),
256
                    ];
257
                } else {
258
                    unset($aliases[$index]);
259
                    continue;
260
                }
261
            } elseif (\is_string($alias)) {
262
                if (self::isValidPhpNamespace($alias)) {
263
                    $alias = \explode('\\', \strtolower($alias));
264
                    $aliases[$index] = \end($alias);
265
                } else {
266
                    unset($aliases[$index]);
267
                    continue;
268
                }
269
            } else {
270
                unset($aliases[$index]);
271
            }
272
        }
273
274
        return \array_values($aliases);
275
    }
276
277
    /** @psalm-pure */
278
    public static function isValidPhpName(string $name): bool
279
    {
280
        return (bool) \preg_match('/^[a-zA-Z_\\x80-\\xff][a-zA-Z0-9_\\x80-\\xff]*$/', $name);
281
    }
282
283
    /** @psalm-pure */
284
    public static function isValidPhpNamespace(string $ns): bool
285
    {
286
        $parts = \explode('\\', $ns);
287
        if ('' === \reset($parts)) {
288
            \array_shift($parts);
289
        }
290
291
        if (!\count($parts)) {
292
            return false;
293
        }
294
295
        foreach ($parts as $part) {
296
            if (!self::isValidPhpName($part)) {
297
                return false;
298
            }
299
        }
300
301
        return true;
302
    }
303
304
    /**
305
     * trigger_error before PHP 8.1 truncates the error message at nul
306
     * so we have to sanitize variable strings before using them.
307
     *
308
     * @psalm-pure
309
     */
310
    public static function errorSanitizeString(string $input): string
311
    {
312
        if (KINT_PHP82) {
313
            return $input;
314
        }
315
316
        return \strtok($input, "\0"); // @codeCoverageIgnore
317
    }
318
319
    /** @psalm-pure */
320
    public static function getTypeString(ReflectionType $type): string
321
    {
322
        // @codeCoverageIgnoreStart
323
        // ReflectionType::__toString was deprecated in 7.4 and undeprecated in 8
324
        // and toString doesn't correctly show the nullable ? in the type before 8
325
        if (!KINT_PHP80) {
326
            if (!$type instanceof ReflectionNamedType) {
327
                throw new UnexpectedValueException('ReflectionType on PHP 7 must be ReflectionNamedType');
328
            }
329
330
            $name = $type->getName();
331
            if ($type->allowsNull() && 'mixed' !== $name && false === \strpos($name, '|')) {
332
                $name = '?'.$name;
333
            }
334
335
            return $name;
336
        }
337
        // @codeCoverageIgnoreEnd
338
339
        return (string) $type;
340
    }
341
342
    /**
343
     * @psalm-param Encoding $encoding
344
     */
345
    public static function truncateString(string $input, int $length = PHP_INT_MAX, string $end = '...', $encoding = false): string
346
    {
347
        $endlength = self::strlen($end);
348
349
        if ($endlength >= $length) {
350
            $endlength = 0;
351
            $end = '';
352
        }
353
354
        if (self::strlen($input, $encoding) > $length) {
355
            return self::substr($input, 0, $length - $endlength, $encoding).$end;
356
        }
357
358
        return $input;
359
    }
360
361
    /**
362
     * @psalm-return Encoding
363
     */
364
    public static function detectEncoding(string $string)
365
    {
366
        if (\function_exists('mb_detect_encoding')) {
367
            $ret = \mb_detect_encoding($string, self::$char_encodings, true);
368
            if (false !== $ret) {
369
                return $ret;
370
            }
371
        }
372
373
        // Pretty much every character encoding uses first 32 bytes as control
374
        // characters. If it's not a multi-byte format it's safe to say matching
375
        // any control character besides tab, nl, and cr means it's binary.
376
        if (\preg_match('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]/', $string)) {
377
            return false;
378
        }
379
380
        if (\function_exists('iconv')) {
381
            foreach (self::$legacy_encodings as $encoding) {
382
                // Iconv detection works by triggering
383
                // "Detected an illegal character in input string" notices
384
                // This notice does not become a TypeError with strict_types
385
                // so we don't have to wrap this in a try catch
386
                if (@\iconv($encoding, $encoding, $string) === $string) {
387
                    return $encoding;
388
                }
389
            }
390
        } elseif (!\function_exists('mb_detect_encoding')) { // @codeCoverageIgnore
391
            // If a user has neither mb_detect_encoding, nor iconv, nor the
392
            // polyfills, there's not much we can do about it...
393
            // Pretend it's ASCII and pray the browser renders it properly.
394
            return 'ASCII'; // @codeCoverageIgnore
395
        }
396
397
        return false;
398
    }
399
400
    /**
401
     * @psalm-param Encoding $encoding
402
     */
403
    public static function strlen(string $string, $encoding = false): int
404
    {
405
        if (\function_exists('mb_strlen')) {
406
            if (false === $encoding) {
407
                $encoding = self::detectEncoding($string);
408
            }
409
410
            if (false !== $encoding && 'ASCII' !== $encoding) {
411
                return \mb_strlen($string, $encoding);
412
            }
413
        }
414
415
        return \strlen($string);
416
    }
417
418
    /**
419
     * @psalm-param Encoding $encoding
420
     */
421
    public static function substr(string $string, int $start, ?int $length = null, $encoding = false): string
422
    {
423
        if (\function_exists('mb_substr')) {
424
            if (false === $encoding) {
425
                $encoding = self::detectEncoding($string);
426
            }
427
428
            if (false !== $encoding && 'ASCII' !== $encoding) {
429
                return \mb_substr($string, $start, $length, $encoding);
430
            }
431
        }
432
433
        // Special case for substr/mb_substr discrepancy
434
        if ('' === $string) {
435
            return '';
436
        }
437
438
        return \substr($string, $start, $length ?? PHP_INT_MAX);
439
    }
440
441
    public static function shortenPath(string $file): string
442
    {
443
        $split = \explode('/', \str_replace('\\', '/', $file));
444
445
        $longest_match = 0;
446
        $match = '';
447
448
        foreach (self::$path_aliases as $path => $alias) {
449
            $path = \explode('/', \str_replace('\\', '/', $path));
450
451
            if (\count($path) < 2) {
452
                continue;
453
            }
454
455
            if (\array_slice($split, 0, \count($path)) === $path && \count($path) > $longest_match) {
456
                $longest_match = \count($path);
457
                $match = $alias;
458
            }
459
        }
460
461
        if ($longest_match) {
462
            $suffix = \implode('/', \array_slice($split, $longest_match));
463
464
            if (\preg_match('%^/*$%', $suffix)) {
465
                return $match;
466
            }
467
468
            return $match.'/'.$suffix;
469
        }
470
471
        // fallback to find common path with Kint dir
472
        $kint = \explode('/', \str_replace('\\', '/', KINT_DIR));
473
        $had_real_path_part = false;
474
475
        foreach ($split as $i => $part) {
476
            if (!isset($kint[$i]) || $kint[$i] !== $part) {
477
                if (!$had_real_path_part) {
478
                    break;
479
                }
480
481
                $suffix = \implode('/', \array_slice($split, $i));
482
483
                if (\preg_match('%^/*$%', $suffix)) {
484
                    break;
485
                }
486
487
                $prefix = $i > 1 ? '.../' : '/';
488
489
                return $prefix.$suffix;
490
            }
491
492
            if ($i > 0 && \strlen($kint[$i])) {
493
                $had_real_path_part = true;
494
            }
495
        }
496
497
        return $file;
498
    }
499
500
    public static function composerGetExtras(string $key = 'kint'): array
501
    {
502
        if (0 === \strpos(KINT_DIR, 'phar://')) {
503
            // Only run inside phar file, so skip for code coverage
504
            return []; // @codeCoverageIgnore
505
        }
506
507
        $extras = [];
508
509
        $folder = KINT_DIR.'/vendor';
510
511
        for ($i = 0; $i < 4; ++$i) {
512
            $installed = $folder.'/composer/installed.json';
513
514
            if (\file_exists($installed) && \is_readable($installed)) {
515
                $packages = \json_decode(\file_get_contents($installed), true);
516
517
                if (!\is_array($packages)) {
518
                    continue;
519
                }
520
521
                // Composer 2.0 Compatibility: packages are now wrapped into a "packages" top level key instead of the whole file being the package array
522
                // @see https://getcomposer.org/upgrade/UPGRADE-2.0.md
523
                foreach ($packages['packages'] ?? $packages as $package) {
524
                    if (\is_array($package['extra'][$key] ?? null)) {
525
                        $extras = \array_replace($extras, $package['extra'][$key]);
526
                    }
527
                }
528
529
                $folder = \dirname($folder);
530
531
                if (\file_exists($folder.'/composer.json') && \is_readable($folder.'/composer.json')) {
532
                    $composer = \json_decode(\file_get_contents($folder.'/composer.json'), true);
533
534
                    if (\is_array($composer['extra'][$key] ?? null)) {
535
                        $extras = \array_replace($extras, $composer['extra'][$key]);
536
                    }
537
                }
538
539
                break;
540
            }
541
542
            $folder = \dirname($folder);
543
        }
544
545
        return $extras;
546
    }
547
548
    /**
549
     * @codeCoverageIgnore
550
     */
551
    public static function composerSkipFlags(): void
552
    {
553
        if (\defined('KINT_SKIP_FACADE') && \defined('KINT_SKIP_HELPERS')) {
554
            return;
555
        }
556
557
        $extras = self::composerGetExtras();
558
559
        if (!empty($extras['disable-facade']) && !\defined('KINT_SKIP_FACADE')) {
560
            \define('KINT_SKIP_FACADE', true);
561
        }
562
563
        if (!empty($extras['disable-helpers']) && !\defined('KINT_SKIP_HELPERS')) {
564
            \define('KINT_SKIP_HELPERS', true);
565
        }
566
    }
567
}
568