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