Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
61.78% covered (warning)
61.78%
97 / 157
15.79% covered (danger)
15.79%
3 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
PHPUtils
61.78% covered (warning)
61.78%
97 / 157
15.79% covered (danger)
15.79%
3 / 19
276.55
0.00% covered (danger)
0.00%
0 / 1
 jsonEncode
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 jsonDecode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 makeSet
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 lastItem
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 pushArray
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 safeSubstr
92.50% covered (success)
92.50%
37 / 40
0.00% covered (danger)
0.00%
0 / 1
8.03
 assertValidUTF8
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 reStrip
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
7
 encodeURIComponent
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 sortArray
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 arrayEquals
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
9.02
 iterable_to_array
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 stripPrefix
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 stripSuffix
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 deprecated
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 filterDeprecationForTest
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 clearDeprecationFilters
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCallerDescription
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
72
 formatCallerDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Utils;
5
6use Wikimedia\Assert\Assert;
7use Wikimedia\Assert\UnreachableException;
8
9/**
10 * This file contains Parsoid-independent PHP helper functions.
11 * Over time, more functions can be migrated out of various other files here.
12 */
13class PHPUtils {
14    /**
15     * FIXME: Copied from FormatJson.php in core
16     *
17     * Characters problematic in JavaScript.
18     *
19     * @note These are listed in ECMA-262 (5.1 Ed.), ยง7.3 Line Terminators along with U+000A (LF)
20     *       and U+000D (CR). However, PHP already escapes LF and CR according to RFC 4627.
21     */
22    private const BAD_CHARS = [
23        "\u{2028}", // U+2028 LINE SEPARATOR
24        "\u{2029}", // U+2029 PARAGRAPH SEPARATOR
25    ];
26
27    /**
28     * FIXME: Copied from FormatJson.php in core
29     *
30     * Escape sequences for characters listed in FormatJson::BAD_CHARS.
31     */
32    private const BAD_CHARS_ESCAPED = [
33        '\u2028', // U+2028 LINE SEPARATOR
34        '\u2029', // U+2029 PARAGRAPH SEPARATOR
35    ];
36
37    /**
38     * FIXME: Core has FormatJson::encode that does a more comprehensive job
39     *
40     * json_encode wrapper function
41     * - unscapes slashes and unicode
42     *
43     * @param mixed $o
44     * @return string
45     */
46    public static function jsonEncode( $o ): string {
47        $str = json_encode( $o, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR );
48        $str = str_replace( self::BAD_CHARS, self::BAD_CHARS_ESCAPED, $str );
49        return $str;
50    }
51
52    /**
53     * FIXME: Core has FormatJson::parse that does a more comprehensive job
54     * json_decode wrapper function
55     * @param string $str String to decode into the json object
56     * @param bool $assoc Controls whether to parse as an an associative array - defaults to true
57     * @return mixed
58     */
59    public static function jsonDecode( string $str, bool $assoc = true ) {
60        return json_decode( $str, $assoc );
61    }
62
63    /**
64     * Convert array to associative array usable as a read-only Set.
65     *
66     * @param list<string> $a
67     * @return array<string,true>
68     */
69    public static function makeSet( array $a ): array {
70        return array_fill_keys( $a, true );
71    }
72
73    /**
74     * Helper to get last item of the array
75     * @param mixed[] $a
76     * @return mixed
77     */
78    public static function lastItem( array $a ) {
79        // Tim Starling recommends not using end() for perf reasons
80        // since apparently it can be O(n) where the refcount on the
81        // array is > 1.
82        //
83        // Note that end() is usable in non-array scenarios. But, in our case,
84        // we are almost always dealing with arrays, so this helper probably
85        // better for cases where we aren't sure the array isn't shared.
86        return $a[count( $a ) - 1] ?? null;
87    }
88
89    /**
90     * Append an array to an accumulator using the most efficient method
91     * available. Pushing N elements onto $dest is guaranteed to be O(N).
92     *
93     * See https://w.wiki/3zvE
94     *
95     * @param array &$dest Destination array
96     * @param array ...$sources Arrays to merge
97     */
98    public static function pushArray( array &$dest, array ...$sources ): void {
99        if ( count( $sources ) === 0 ) {
100            return;
101        }
102        // If the number of elements to be pushed is greater than the size
103        // of the destination, then we can just use PHP's native array_merge
104        // since the size of $dest is also O(N).
105        $sourceCount = array_sum( array_map( static fn ( $s ) => count( $s ), $sources ) );
106        if ( count( $dest ) < $sourceCount ) {
107            $dest = array_merge( $dest, ...$sources );
108            return;
109        }
110        // ...otherwise append each item in turn to $dest.
111        foreach ( $sources as $source ) {
112            foreach ( $source as $item ) {
113                $dest[] = $item;
114            }
115        }
116    }
117
118    /**
119     * Return a substring, asserting that it is valid UTF-8.
120     * By default we assume the full string was valid UTF-8, which allows
121     * us to look at the first and last bytes to make this check.
122     * You can check the entire string if you are feeling paranoid; it
123     * will take O(N) time (where N is the length of the substring) but
124     * so does the substring operation.
125     *
126     * If the substring would start beyond the end of the string or
127     * end before the start of the string, then this function will
128     * return the empty string (as would JavaScript); note that the
129     * native `substr` would return `false` in this case.
130     *
131     * Using this helper instead of native `substr` is
132     * useful during the PHP port to verify that we don't break up
133     * Unicode codepoints by the switch from JavaScript UCS-2 offsets
134     * to PHP UTF-8 byte offsets.
135     *
136     * @param string $s The (sub)string to check
137     * @param int $start The starting offset (in bytes). If negative, the
138     *  offset is counted from the end of the string.
139     * @param ?int $length (optional) The maximum length of the returned
140     *  string. If negative, the end position is counted from the end of
141     *  the string.
142     * @param bool $checkEntireString Whether to do a slower verification
143     *   of the entire string, not just the edges. Defaults to false.
144     * @return string The checked substring
145     */
146    public static function safeSubstr(
147        string $s, int $start, ?int $length = null,
148        bool $checkEntireString = false
149    ): string {
150        if ( $length === null ) {
151            $ss = substr( $s, $start );
152        } else {
153            $ss = substr( $s, $start, $length );
154        }
155        if ( $ss === '' ) {
156            return $ss;
157        }
158        $firstChar = ord( $ss[0] );
159        Assert::invariant(
160            ( $firstChar & 0xC0 ) !== 0x80,
161            'Bad UTF-8 at start of string'
162        );
163        $i = 0;
164        // This next loop won't step off the front of the string because we've
165        // already asserted that the first character is not 10xx xxxx
166        do {
167            $i--;
168            Assert::invariant(
169                $i > -5,
170                // This should never happen, assuming the original string
171                // was valid UTF-8
172                'Bad UTF-8 at end of string (>4 byte sequence)'
173            );
174            $lastChar = ord( $ss[$i] );
175        } while ( ( $lastChar & 0xC0 ) === 0x80 );
176        if ( ( $lastChar & 0x80 ) === 0 ) {
177            Assert::invariant(
178                // This shouldn't happen, assuming original string was valid
179                $i === -1, 'Bad UTF-8 at end of string (1 byte sequence)'
180            );
181        } elseif ( ( $lastChar & 0xE0 ) === 0xC0 ) {
182            Assert::invariant(
183                $i === -2, 'Bad UTF-8 at end of string (2 byte sequence)'
184            );
185        } elseif ( ( $lastChar & 0xF0 ) === 0xE0 ) {
186            Assert::invariant(
187                $i === -3, 'Bad UTF-8 at end of string (3 byte sequence)'
188            );
189        } elseif ( ( $lastChar & 0xF8 ) === 0xF0 ) {
190            Assert::invariant(
191                $i === -4, 'Bad UTF-8 at end of string (4 byte sequence)'
192            );
193        } else {
194            throw new UnreachableException(
195                // This shouldn't happen, assuming original string was valid
196                'Bad UTF-8 at end of string'
197            );
198        }
199        if ( $checkEntireString ) {
200            // We did the head/tail checks first because they give better
201            // diagnostics in the common case where we broke UTF-8 by
202            // the substring operation.
203            self::assertValidUTF8( $ss );
204        }
205        return $ss;
206    }
207
208    /**
209     * Helper for verifying a valid UTF-8 encoding.  Using
210     * safeSubstr() is a more efficient way of doing this check in
211     * most places, where you can assume that the original string was
212     * valid UTF-8.  This function does a complete traversal of the
213     * string, in time proportional to the length of the string.
214     *
215     * @param string $s The string to check.
216     */
217    public static function assertValidUTF8( string $s ): void {
218        // Slow complete O(N) check for UTF-8 validity
219        $r = preg_match( '//u', $s );
220        Assert::invariant(
221            $r === 1,
222            'Bad UTF-8 (full string verification)'
223        );
224    }
225
226    /**
227     * Helper for joining pieces of regular expressions together.  This
228     * safely strips delimiters from regular expression strings, while
229     * ensuring that the result is safely escaped for the new delimiter
230     * you plan to use (see the `$delimiter` argument to `preg_quote`).
231     * Note that using a meta-character for the new delimiter can lead to
232     * unexpected results; for example, if you use `!` then escaping
233     * `(?!foo)` will break the regular expression.
234     *
235     * @param string $re The regular expression to strip
236     * @param ?string $newDelimiter Optional delimiter which will be
237     *   used when recomposing this stripped regular expression into a
238     *   new regular expression.
239     * @return string The regular expression without delimiters or flags
240     */
241    public static function reStrip(
242        string $re, ?string $newDelimiter = null
243    ): string {
244        static $delimiterPairs = [
245            '(' => ')',
246            '[' => ']',
247            '{' => '}',
248            '<' => '>',
249        ];
250        // Believe it or not, PHP allows leading whitespace in the $re
251        // tested with C's "isspace", which is [ \f\n\r\t\v]
252        $re = preg_replace( '/^[ \f\n\r\t\v]+/', '', $re );
253        Assert::invariant( strlen( $re ) > 0, "empty regexp" );
254        $startDelimiter = $re[0];
255        // PHP actually supports balanced delimiters (ie open paren on left
256        // and close paren on right).
257        $endDelimiter = $delimiterPairs[$startDelimiter] ?? $startDelimiter;
258        $endDelimiterPos = strrpos( $re, $endDelimiter );
259        Assert::invariant(
260            $endDelimiterPos !== false && $endDelimiterPos > 0,
261            "can't find end delimiter"
262        );
263        $flags = substr( $re, $endDelimiterPos + 1 );
264        Assert::invariant(
265            preg_match( '/^[imsxADSUXJu \n]*$/D', $flags ) === 1,
266            "unexpected flags"
267        );
268        $stripped = substr( $re, 1, $endDelimiterPos - 1 );
269        if (
270            $newDelimiter === null ||
271            $startDelimiter === $newDelimiter ||
272            $endDelimiter === $newDelimiter
273        ) {
274            return $stripped; // done!
275        }
276        $newCloseDelimiter = $delimiterPairs[$startDelimiter] ?? $startDelimiter;
277        // escape the new delimiter
278        preg_match_all( '/[^\\\\]|\\\\./s', $stripped, $matches );
279        return implode( '', array_map( static function ( $c ) use ( $newDelimiter, $newCloseDelimiter ) {
280            return ( $c === $newDelimiter || $c === $newCloseDelimiter )
281                ? ( '\\' . $c ) : $c;
282        }, $matches[0] ) );
283    }
284
285    /**
286     * JS-compatible encodeURIComponent function
287     * FIXME: See T221147 (for a post-port update)
288     *
289     * @param string $str
290     * @return string
291     */
292    public static function encodeURIComponent( string $str ): string {
293        $revert = [ '%21' => '!', '%2A' => '*', '%27' => "'", '%28' => '(', '%29' => ')' ];
294        return strtr( rawurlencode( $str ), $revert );
295    }
296
297    /**
298     * Sort keys in an array, recursively, for better reproducibility.
299     * (This is especially useful before serializing as JSON.)
300     *
301     * @param mixed &$array
302     */
303    public static function sortArray( &$array ): void {
304        if ( !is_array( $array ) ) {
305            return;
306        }
307        ksort( $array );
308        foreach ( $array as &$v ) {
309            if ( is_array( $v ) ) {
310                self::sortArray( $v );
311            }
312        }
313    }
314
315    /**
316     * Compare the contents of two arrays for equality, given a
317     * equality comparison function for elements of the array.
318     * Arrays are equal if they have the same size and element values
319     * for equal keys are equal.
320     *
321     * For convenience, `null` can be passed in as well, and the function
322     * only returns true if the other argument is also `null`.  This avoids
323     * `null` checks in the caller in many cases.
324     *
325     * @param ?array $arrA
326     * @param ?array $arrB
327     * @param callable(mixed,mixed):bool $elementEquals A function to compare
328     *  non-null elements of $arrA and $arrB for equality.
329     * @return bool True if $arrA and $arrB are equal.
330     */
331    public static function arrayEquals( ?array $arrA, ?array $arrB, callable $elementEquals ): bool {
332        if ( $arrA === null ) {
333            return $arrB === null;
334        }
335        if ( $arrB === null ) {
336            return false;
337        }
338        if ( count( $arrA ) !== count( $arrB ) ) {
339            return false;
340        }
341        foreach ( $arrA as $i => $elemA ) {
342            $elemB = $arrB[$i] ?? null;
343            if ( $elemA === null ) {
344                if ( $elemB !== null ) {
345                    return false;
346                }
347            } elseif ( $elemB === null ) {
348                return false;
349            } elseif ( !( $elementEquals( $elemA, $elemB ) ) ) {
350                return false;
351            }
352        }
353        return true;
354    }
355
356    /**
357     * Convert an iterable to an array.
358     *
359     * This function is similar to *but not the same as* the built-in
360     * iterator_to_array, because arrays are iterable but not Traversable!
361     *
362     * This function is also present in the wmde/iterable-functions library,
363     * but it's short enough that we don't need to pull in an entire new
364     * dependency here.
365     *
366     * @see https://stackoverflow.com/questions/44587973/php-iterable-to-array-or-traversable
367     * @see https://github.com/wmde/iterable-functions/blob/master/src/functions.php
368     *
369     * @template T
370     * @param iterable<T> $iterable
371     * @return array<T>
372     */
373    public static function iterable_to_array( iterable $iterable ): array { // phpcs:ignore MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName,Generic.Files.LineLength.TooLong
374        if ( is_array( $iterable ) ) {
375            return $iterable;
376        }
377        '@phan-var \Traversable $iterable'; // @var \Traversable $iterable
378        return iterator_to_array( $iterable );
379    }
380
381    /**
382     * If a string starts with a given prefix, remove the prefix. Otherwise,
383     * return the original string. Like preg_replace( "/^$prefix/", '', $subject )
384     * except about 1.14x faster in the replacement case and 2x faster in
385     * the no-op case.
386     *
387     * Note: adding type declarations to the parameters adds an overhead of 3%.
388     * The benchmark above was without type declarations.
389     *
390     * @param string $subject
391     * @param string $prefix
392     * @return string
393     */
394    public static function stripPrefix( $subject, $prefix ) {
395        if ( str_starts_with( $subject, $prefix ) ) {
396            return substr( $subject, strlen( $prefix ) );
397        } else {
398            return $subject;
399        }
400    }
401
402    /**
403     * If a string ends with a given suffix, remove the suffix. Otherwise,
404     * return the original string. Like preg_replace( "/$suffix$/", '', $subject )
405     * except faster.
406     *
407     * @param string $subject
408     * @param string $suffix
409     * @return string
410     */
411    public static function stripSuffix( $subject, $suffix ) {
412        if ( str_ends_with( $subject, $suffix ) ) {
413            return substr( $subject, 0, -strlen( $suffix ) );
414        } else {
415            return $subject;
416        }
417    }
418
419    /**
420     * @var array<string,true> Keys are regexes
421     */
422    private static $deprecationFilters = [];
423
424    /**
425     * Logs a warning that a deprecated feature was used.
426     *
427     * Where possible, SiteConfig::deprecated() should be used instead,
428     * which will use similar capabilities in the host environment.
429     *
430     * @param string $function Feature that is deprecated.
431     * @param string $version Version of Parsoid that the feature
432     *  was deprecated in
433     * @param int $callerOffset How far up the call stack is the original
434     *  caller. 2 = function that called the function that called
435     *  PHPUtils::deprecated()
436     * @internal External code should use similar facilities in the host
437     */
438    public static function deprecated( string $function, string $version, int $callerOffset = 2 ) {
439        $msg = "Use of $function was deprecated in Parsoid $version.";
440        $callerDescription = self::getCallerDescription( $callerOffset );
441        $callerFunc = $callerDescription['func'];
442
443        // Check to see if there already was a warning about this function
444        static $deprecationWarnings = [];
445        if ( isset( $deprecationWarnings[$msg][$callerFunc] ) ) {
446            return;
447        }
448        $deprecationWarnings[$msg][$callerFunc] = true;
449
450        $msg = self::formatCallerDescription( $msg, $callerDescription );
451
452        foreach ( self::$deprecationFilters as $filter => $ignore ) {
453            if ( preg_match( $filter, $msg ) ) {
454                return;
455            }
456        }
457
458        trigger_error( $msg, E_USER_DEPRECATED );
459    }
460
461    /**
462     * Deprecation messages matching the supplied regex will be suppressed.
463     * Use this to filter deprecation warnings when testing deprecated code.
464     *
465     * @param string $regex
466     */
467    public static function filterDeprecationForTest(
468        string $regex
469    ): void {
470        if ( !( defined( 'MW_PHPUNIT_TEST' ) || defined( 'MW_PARSER_TEST' ) ) ) {
471            throw new \LogicException( __METHOD__ . ' can only be used in tests' );
472        }
473        self::$deprecationFilters[$regex] = true;
474    }
475
476    /**
477     * Clear all deprecation filters.
478     */
479    public static function clearDeprecationFilters() {
480        self::$deprecationFilters = [];
481    }
482
483    // The below two methods are copied verbatim from MWDebug.php in
484    // MediaWiki.  They probably don't have to be carefully synchronized,
485    // because when we're running in integrated mode SiteConfig::deprecated()
486    // will be overridden to use wfDeprecated in the integrated MediaWiki.
487
488    /**
489     * Get an array describing the calling function at a specified offset.
490     *
491     * @param int $callerOffset How far up the callstack is the original
492     *    caller. 0 = function that called getCallerDescription()
493     * @return array Array with two keys: 'file' and 'func'
494     */
495    private static function getCallerDescription( $callerOffset ) {
496        $callers = function_exists( 'debug_backtrace' ) ? debug_backtrace() : [];
497
498        if ( isset( $callers[$callerOffset] ) ) {
499            $callerfile = $callers[$callerOffset];
500            if ( isset( $callerfile['file'] ) && isset( $callerfile['line'] ) ) {
501                $file = $callerfile['file'] . ' at line ' . $callerfile['line'];
502            } else {
503                $file = '(internal function)';
504            }
505        } else {
506            $file = '(unknown location)';
507        }
508
509        if ( isset( $callers[$callerOffset + 1] ) ) {
510            $callerfunc = $callers[$callerOffset + 1];
511            $func = '';
512            if ( isset( $callerfunc['class'] ) ) {
513                $func .= $callerfunc['class'] . '::';
514            }
515            if ( isset( $callerfunc['function'] ) ) {
516                $func .= $callerfunc['function'];
517            }
518        } else {
519            $func = 'unknown';
520        }
521
522        return [ 'file' => $file, 'func' => $func ];
523    }
524
525    /**
526     * Append a caller description to an error message
527     *
528     * @param string $msg
529     * @param array $caller Caller description from getCallerDescription()
530     * @return string
531     */
532    private static function formatCallerDescription( $msg, $caller ) {
533        // When changing this, update the below parseCallerDescription() method  as well.
534        return $msg . ' [Called from ' . $caller['func'] . ' in ' . $caller['file'] . ']';
535    }
536
537}