Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.07% covered (warning)
66.07%
74 / 112
13.33% covered (danger)
13.33%
2 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
PHPUtils
66.07% covered (warning)
66.07%
74 / 112
13.33% covered (danger)
13.33%
2 / 15
90.47
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 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 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
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 safeSubstr
90.48% covered (success)
90.48%
38 / 42
0.00% covered (danger)
0.00%
0 / 1
9.07
 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 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 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 / 1
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
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Utils;
5
6use InvalidArgumentException;
7use Wikimedia\Assert\Assert;
8use Wikimedia\Assert\UnreachableException;
9
10/**
11 * This file contains Parsoid-independent PHP helper functions.
12 * Over time, more functions can be migrated out of various other files here.
13 * @module
14 */
15
16class PHPUtils {
17    /**
18     * Convert a counter to a Base64 encoded string.
19     * Padding is stripped. \,+ are replaced with _,- respectively.
20     * Warning: Max integer is 2^31 - 1 for bitwise operations.
21     * @param int $n
22     * @return string
23     */
24    public static function counterToBase64( int $n ): string {
25        $str = '';
26        do {
27            $str = chr( $n & 0xff ) . $str;
28            $n >>= 8;
29        } while ( $n > 0 );
30        return rtrim( strtr( base64_encode( $str ), '+/', '-_' ), '=' );
31    }
32
33    /**
34     * FIXME: Copied from FormatJson.php in core
35     *
36     * Characters problematic in JavaScript.
37     *
38     * @note These are listed in ECMA-262 (5.1 Ed.), ยง7.3 Line Terminators along with U+000A (LF)
39     *       and U+000D (CR). However, PHP already escapes LF and CR according to RFC 4627.
40     */
41    private const BAD_CHARS = [
42        "\u{2028}", // U+2028 LINE SEPARATOR
43        "\u{2029}", // U+2029 PARAGRAPH SEPARATOR
44    ];
45
46    /**
47     * FIXME: Copied from FormatJson.php in core
48     *
49     * Escape sequences for characters listed in FormatJson::BAD_CHARS.
50     */
51    private const BAD_CHARS_ESCAPED = [
52        '\u2028', // U+2028 LINE SEPARATOR
53        '\u2029', // U+2029 PARAGRAPH SEPARATOR
54    ];
55
56    /**
57     * FIXME: Core has FormatJson::encode that does a more comprehensive job
58     *
59     * json_encode wrapper function
60     * - unscapes slashes and unicode
61     *
62     * @param mixed $o
63     * @return string
64     */
65    public static function jsonEncode( $o ): string {
66        $str = json_encode( $o, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
67        if ( $str === false ) {
68            // Do this manually until JSON_THROW_ON_ERROR is available
69            throw new InvalidArgumentException( 'JSON encoding failed.' );
70        }
71        $str = str_replace( self::BAD_CHARS, self::BAD_CHARS_ESCAPED, $str );
72        return $str;
73    }
74
75    /**
76     * FIXME: Core has FormatJson::parse that does a more comprehensive job
77     * json_decode wrapper function
78     * @param string $str String to decode into the json object
79     * @param bool $assoc Controls whether to parse as an an associative array - defaults to true
80     * @return mixed
81     */
82    public static function jsonDecode( string $str, bool $assoc = true ) {
83        return json_decode( $str, $assoc );
84    }
85
86    /**
87     * Convert array to associative array usable as a read-only Set.
88     *
89     * @param array $a
90     * @return array
91     */
92    public static function makeSet( array $a ): array {
93        return array_fill_keys( $a, true );
94    }
95
96    /**
97     * Helper to get last item of the array
98     * @param mixed[] $a
99     * @return mixed
100     */
101    public static function lastItem( array $a ) {
102        // Tim Starling recommends not using end() for perf reasons
103        // since apparently it can be O(n) where the refcount on the
104        // array is > 1.
105        //
106        // Note that end() is usable in non-array scenarios. But, in our case,
107        // we are almost always dealing with arrays, so this helper probably
108        // better for cases where we aren't sure the array isn't shared.
109        return $a[count( $a ) - 1] ?? null;
110    }
111
112    /**
113     * Append an array to an accumulator using the most efficient method
114     * available. Makes sure that accumulation is O(n).
115     *
116     * See https://w.wiki/3zvE
117     *
118     * @param array &$dest Destination array
119     * @param array $source Array to merge
120     */
121    public static function pushArray( array &$dest, array $source ): void {
122        if ( count( $dest ) < count( $source ) ) {
123            $dest = array_merge( $dest, $source );
124        } else {
125            foreach ( $source as $item ) {
126                $dest[] = $item;
127            }
128        }
129    }
130
131    /**
132     * Return a substring, asserting that it is valid UTF-8.
133     * By default we assume the full string was valid UTF-8, which allows
134     * us to look at the first and last bytes to make this check.
135     * You can check the entire string if you are feeling paranoid; it
136     * will take O(N) time (where N is the length of the substring) but
137     * so does the substring operation.
138     *
139     * If the substring would start beyond the end of the string or
140     * end before the start of the string, then this function will
141     * return the empty string (as would JavaScript); note that the
142     * native `substr` would return `false` in this case.
143     *
144     * Using this helper instead of native `substr` is
145     * useful during the PHP port to verify that we don't break up
146     * Unicode codepoints by the switch from JavaScript UCS-2 offsets
147     * to PHP UTF-8 byte offsets.
148     *
149     * @param string $s The (sub)string to check
150     * @param int $start The starting offset (in bytes). If negative, the
151     *  offset is counted from the end of the string.
152     * @param ?int $length (optional) The maximum length of the returned
153     *  string. If negative, the end position is counted from the end of
154     *  the string.
155     * @param bool $checkEntireString Whether to do a slower verification
156     *   of the entire string, not just the edges. Defaults to false.
157     * @return string The checked substring
158     */
159    public static function safeSubstr(
160        string $s, int $start, ?int $length = null,
161        bool $checkEntireString = false
162    ): string {
163        if ( $length === null ) {
164            $ss = substr( $s, $start );
165        } else {
166            $ss = substr( $s, $start, $length );
167        }
168        if ( $ss === false ) {
169            $ss = '';
170        }
171        if ( strlen( $ss ) === 0 ) {
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 $k => $v ) {
325            self::sortArray( $array[$k] );
326        }
327    }
328
329    /**
330     * Convert an iterable to an array.
331     *
332     * This function is similar to *but not the same as* the built-in
333     * iterator_to_array, because arrays are iterable but not Traversable!
334     *
335     * This function is also present in the wmde/iterable-functions library,
336     * but it's short enough that we don't need to pull in an entire new
337     * dependency here.
338     *
339     * @see https://stackoverflow.com/questions/44587973/php-iterable-to-array-or-traversable
340     * @see https://github.com/wmde/iterable-functions/blob/master/src/functions.php
341     *
342     * @phan-template T
343     * @param iterable<T> $iterable
344     * @return array<T>
345     */
346    public static function iterable_to_array( iterable $iterable ): array { // phpcs:ignore MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName,Generic.Files.LineLength.TooLong
347        if ( is_array( $iterable ) ) {
348            return $iterable;
349        }
350        '@phan-var \Traversable $iterable'; // @var \Traversable $iterable
351        return iterator_to_array( $iterable );
352    }
353
354    /**
355     * Indicate that the code which calls this function is intended to be
356     * unreachable.
357     *
358     * This is a workaround for T247093; this has been moved upstream
359     * into wikimedia/assert.
360     *
361     * @param string $reason
362     * @return never
363     * @deprecated Just throw an UnreachableException instead.
364     */
365    public static function unreachable( string $reason = "should never happen" ) {
366        throw new UnreachableException( $reason );
367    }
368
369    /**
370     * If a string starts with a given prefix, remove the prefix. Otherwise,
371     * return the original string. Like preg_replace( "/^$prefix/", '', $subject )
372     * except about 1.14x faster in the replacement case and 2x faster in
373     * the no-op case.
374     *
375     * Note: adding type declarations to the parameters adds an overhead of 3%.
376     * The benchmark above was without type declarations.
377     *
378     * @param string $subject
379     * @param string $prefix
380     * @return string
381     */
382    public static function stripPrefix( $subject, $prefix ) {
383        if ( str_starts_with( $subject, $prefix ) ) {
384            return substr( $subject, strlen( $prefix ) );
385        } else {
386            return $subject;
387        }
388    }
389
390    /**
391     * If a string ends with a given suffix, remove the suffix. Otherwise,
392     * return the original string. Like preg_replace( "/$suffix$/", '', $subject )
393     * except faster.
394     *
395     * @param string $subject
396     * @param string $suffix
397     * @return string
398     */
399    public static function stripSuffix( $subject, $suffix ) {
400        if ( str_ends_with( $subject, $suffix ) ) {
401            return substr( $subject, 0, -strlen( $suffix ) );
402        } else {
403            return $subject;
404        }
405    }
406
407}