Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
3.31% covered (danger)
3.31%
4 / 121
30.00% covered (danger)
30.00%
3 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
StringUtils
3.33% covered (danger)
3.33%
4 / 120
30.00% covered (danger)
30.00%
3 / 10
1141.54
0.00% covered (danger)
0.00%
0 / 1
 isUtf8
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 delimiterExplode
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
72
 hungryDelimiterReplace
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 delimiterReplaceCallback
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
182
 delimiterReplace
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 replaceMarkup
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 isValidPCRERegex
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 escapeRegexReplacement
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 explode
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 unpack
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace Wikimedia\StringUtils;
4
5use ArrayIterator;
6use InvalidArgumentException;
7use Wikimedia\Assert\Assert;
8use Wikimedia\UnpackFailedException;
9
10/**
11 * Methods to play with strings.
12 *
13 * @license GPL-2.0-or-later
14 * @file
15 */
16
17/**
18 * A collection of static methods to play with strings.
19 */
20class StringUtils {
21    /**
22     * Test whether a string is valid UTF-8.
23     *
24     * The function check for invalid byte sequences, overlong encoding but
25     * not for different normalisations.
26     *
27     * @note In MediaWiki 1.21, this function did not provide proper UTF-8 validation.
28     * In particular, the pure PHP code path did not in fact check for overlong forms.
29     * Beware of this when backporting code to that version of MediaWiki.
30     *
31     * @since 1.21
32     * @param string $value String to check
33     * @return bool Whether the given $value is a valid UTF-8 encoded string
34     */
35    public static function isUtf8( $value ) {
36        return mb_check_encoding( (string)$value, 'UTF-8' );
37    }
38
39    /**
40     * Explode a string, but ignore any instances of the separator inside
41     * the given start and end delimiters, which may optionally nest.
42     * The delimiters are literal strings, not regular expressions.
43     * @param string $startDelim Start delimiter
44     * @param string $endDelim End delimiter
45     * @param string $separator Separator string for the explode.
46     * @param string $subject Subject string to explode.
47     * @param bool $nested True iff the delimiters are allowed to nest.
48     * @return ArrayIterator
49     */
50    public static function delimiterExplode( $startDelim, $endDelim, $separator,
51        $subject, $nested = false ) {
52        $inputPos = 0;
53        $lastPos = 0;
54        $depth = 0;
55        $encStart = preg_quote( $startDelim, '!' );
56        $encEnd = preg_quote( $endDelim, '!' );
57        $encSep = preg_quote( $separator, '!' );
58        $len = strlen( $subject );
59        $m = [];
60        $exploded = [];
61        while (
62            $inputPos < $len &&
63            preg_match(
64                "!$encStart|$encEnd|$encSep!S", $subject, $m,
65                PREG_OFFSET_CAPTURE, $inputPos
66            )
67        ) {
68            $match = $m[0][0];
69            $matchPos = $m[0][1];
70            $inputPos = $matchPos + strlen( $match );
71            if ( $match === $separator ) {
72                if ( $depth === 0 ) {
73                    $exploded[] = substr(
74                        $subject, $lastPos, $matchPos - $lastPos
75                    );
76                    $lastPos = $inputPos;
77                }
78            } elseif ( $match === $startDelim ) {
79                if ( $depth === 0 || $nested ) {
80                    $depth++;
81                }
82            } else {
83                $depth--;
84            }
85        }
86        $exploded[] = substr( $subject, $lastPos );
87        // This method could be rewritten in the future to avoid creating an
88        // intermediate array, since the return type is just an iterator.
89        return new ArrayIterator( $exploded );
90    }
91
92    /**
93     * Perform an operation equivalent to `preg_replace()`
94     *
95     * Matches this code:
96     *
97     *     preg_replace( "!$startDelim(.*?)$endDelim!", $replace, $subject );
98     *
99     * ..except that it's worst-case O(N) instead of O(N^2). Compared to delimiterReplace(), this
100     * implementation is fast but memory-hungry and inflexible. The memory requirements are such
101     * that I don't recommend using it on anything but guaranteed small chunks of text.
102     *
103     * @param string $startDelim
104     * @param string $endDelim
105     * @param string $replace
106     * @param string $subject
107     * @return string
108     */
109    public static function hungryDelimiterReplace( $startDelim, $endDelim, $replace, $subject ) {
110        $segments = explode( $startDelim, $subject );
111        $output = array_shift( $segments );
112        foreach ( $segments as $s ) {
113            $endDelimPos = strpos( $s, $endDelim );
114            if ( $endDelimPos === false ) {
115                $output .= $startDelim . $s;
116            } else {
117                $output .= $replace . substr( $s, $endDelimPos + strlen( $endDelim ) );
118            }
119        }
120
121        return $output;
122    }
123
124    /**
125     * Perform an operation equivalent to `preg_replace_callback()`
126     *
127     * Matches this code:
128     *
129     *     preg_replace_callback( "!$startDelim(.*)$endDelim!s$flags", $callback, $subject );
130     *
131     * If the start delimiter ends with an initial substring of the end delimiter,
132     * e.g. in the case of C-style comments, the behavior differs from the model
133     * regex. In this implementation, the end must share no characters with the
134     * start, so e.g. `/*\/` is not considered to be both the start and end of a
135     * comment. `/*\/xy/*\/` is considered to be a single comment with contents `/xy/`.
136     *
137     * The implementation of delimiterReplaceCallback() is slower than hungryDelimiterReplace()
138     * but uses far less memory. The delimiters are literal strings, not regular expressions.
139     *
140     * @param string $startDelim Start delimiter
141     * @param string $endDelim End delimiter
142     * @param callable $callback Function to call on each match
143     * @param string $subject
144     * @param string $flags Regular expression flags
145     * @return string
146     */
147    private static function delimiterReplaceCallback( $startDelim, $endDelim, $callback,
148        $subject, $flags = ''
149    ) {
150        $inputPos = 0;
151        $outputPos = 0;
152        $contentPos = 0;
153        $output = '';
154        $foundStart = false;
155        $encStart = preg_quote( $startDelim, '!' );
156        $encEnd = preg_quote( $endDelim, '!' );
157        $strcmp = !str_contains( $flags, 'i' ) ? 'strcmp' : 'strcasecmp';
158        $endLength = strlen( $endDelim );
159        $m = [];
160
161        while ( $inputPos < strlen( $subject ) &&
162            preg_match( "!($encStart)|($encEnd)!S$flags", $subject, $m, PREG_OFFSET_CAPTURE, $inputPos )
163        ) {
164            $tokenOffset = $m[0][1];
165            if ( $m[1][0] != '' ) {
166                if ( $foundStart &&
167                    $strcmp( $endDelim, substr( $subject, $tokenOffset, $endLength ) ) == 0
168                ) {
169                    # An end match is present at the same location
170                    $tokenType = 'end';
171                    $tokenLength = $endLength;
172                } else {
173                    $tokenType = 'start';
174                    $tokenLength = strlen( $m[0][0] );
175                }
176            } elseif ( $m[2][0] != '' ) {
177                $tokenType = 'end';
178                $tokenLength = strlen( $m[0][0] );
179            } else {
180                throw new InvalidArgumentException( 'Invalid delimiter given to ' . __METHOD__ );
181            }
182
183            if ( $tokenType == 'start' ) {
184                # Only move the start position if we haven't already found a start
185                # This means that START START END matches outer pair
186                if ( !$foundStart ) {
187                    # Found start
188                    $inputPos = $tokenOffset + $tokenLength;
189                    # Write out the non-matching section
190                    $output .= substr( $subject, $outputPos, $tokenOffset - $outputPos );
191                    $outputPos = $tokenOffset;
192                    $contentPos = $inputPos;
193                    $foundStart = true;
194                } else {
195                    # Move the input position past the *first character* of START,
196                    # to protect against missing END when it overlaps with START
197                    $inputPos = $tokenOffset + 1;
198                }
199            } elseif ( $tokenType == 'end' ) {
200                if ( $foundStart ) {
201                    # Found match
202                    $output .= $callback( [
203                        substr( $subject, $outputPos, $tokenOffset + $tokenLength - $outputPos ),
204                        substr( $subject, $contentPos, $tokenOffset - $contentPos )
205                    ] );
206                    $foundStart = false;
207                } else {
208                    # Non-matching end, write it out
209                    $output .= substr( $subject, $inputPos, $tokenOffset + $tokenLength - $outputPos );
210                }
211                $inputPos = $outputPos = $tokenOffset + $tokenLength;
212            } else {
213                throw new InvalidArgumentException( 'Invalid delimiter given to ' . __METHOD__ );
214            }
215        }
216        if ( $outputPos < strlen( $subject ) ) {
217            $output .= substr( $subject, $outputPos );
218        }
219
220        return $output;
221    }
222
223    /**
224     * Perform an operation equivalent to `preg_replace()` with flags.
225     *
226     * Matches this code:
227     *
228     *     preg_replace( "!$startDelim(.*)$endDelim!$flags", $replace, $subject );
229     *
230     * @param string $startDelim Start delimiter regular expression
231     * @param string $endDelim End delimiter regular expression
232     * @param string $replace Replacement string. May contain $1, which will be
233     *  replaced by the text between the delimiters
234     * @param string $subject String to search
235     * @param string $flags Regular expression flags
236     * @return string The string with the matches replaced
237     */
238    public static function delimiterReplace(
239        $startDelim, $endDelim, $replace, $subject, $flags = ''
240    ) {
241        return self::delimiterReplaceCallback(
242            $startDelim, $endDelim,
243            static function ( array $matches ) use ( $replace ) {
244                return strtr( $replace, [ '$0' => $matches[0], '$1' => $matches[1] ] );
245            },
246            $subject, $flags
247        );
248    }
249
250    /**
251     * More or less "markup-safe" str_replace()
252     * Ignores any instances of the separator inside `<...>`
253     * @param string $search
254     * @param string $replace
255     * @param string $text
256     * @return string
257     */
258    public static function replaceMarkup( $search, $replace, $text ) {
259        $placeholder = "\x00";
260
261        // Remove placeholder instances
262        $text = str_replace( $placeholder, '', $text );
263
264        // Replace instances of the separator inside HTML-like tags with the placeholder
265        $cleaned = self::delimiterReplaceCallback(
266            '<', '>',
267            static function ( array $matches ) use ( $search, $placeholder ) {
268                return str_replace( $search, $placeholder, $matches[0] );
269            },
270            $text
271        );
272
273        // Explode, then put the replaced separators back in
274        $cleaned = str_replace( $search, $replace, $cleaned );
275        $text = str_replace( $placeholder, $search, $cleaned );
276
277        return $text;
278    }
279
280    /**
281     * Utility function to check if the given string is a valid PCRE regex. Avoids
282     * manually calling suppressWarnings and restoreWarnings, and provides a
283     * one-line solution without the need to use @.
284     *
285     * @since 1.34
286     * @param string $string The string you want to check being a valid regex
287     * @return bool
288     */
289    public static function isValidPCRERegex( $string ) {
290        // @phan-suppress-next-line PhanParamSuspiciousOrder False positive
291        return @preg_match( $string, '' ) !== false; // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
292    }
293
294    /**
295     * Escape a string to make it suitable for inclusion in a preg_replace()
296     * replacement parameter.
297     *
298     * @param string $string
299     * @return string
300     */
301    public static function escapeRegexReplacement( $string ) {
302        $string = str_replace( '\\', '\\\\', $string );
303        return str_replace( '$', '\\$', $string );
304    }
305
306    /**
307     * Workalike for explode() with limited memory usage.
308     *
309     * @param string $separator
310     * @param string $subject
311     * @return ArrayIterator|ExplodeIterator
312     */
313    public static function explode( $separator, $subject ) {
314        if ( substr_count( $subject, $separator ) > 1000 ) {
315            return new ExplodeIterator( $separator, $subject );
316        } else {
317            return new ArrayIterator( explode( $separator, $subject ) );
318        }
319    }
320
321    /**
322     * Wrapper around php's unpack.
323     *
324     * @param string $format The format string (See php's docs)
325     * @param string $data A binary string of binary data
326     * @param int|false $length The minimum length of $data or false. This is to
327     *     prevent reading beyond the end of $data. false to disable the check.
328     *
329     * Also be careful when using this function to read unsigned 32 bit integer
330     * because php might make it negative.
331     *
332     * @throws UnpackFailedException If $data not long enough, or if unpack fails
333     * @return array Associative array of the extracted data
334     * @since 1.42
335     */
336    public static function unpack( string $format, string $data, $length = false ): array {
337        Assert::parameterType( [ 'integer', 'false' ], $length, '$length' );
338        if ( $length !== false ) {
339            $realLen = strlen( $data );
340            if ( $realLen < $length ) {
341                throw new UnpackFailedException( "Tried to unpack a "
342                    . "string of length $realLen, but needed one "
343                    . "of at least length $length."
344                );
345            }
346        }
347
348        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
349        $result = @unpack( $format, $data );
350
351        if ( $result === false ) {
352            // If it cannot extract the packed data.
353            throw new UnpackFailedException( "unpack could not unpack binary data" );
354        }
355        return $result;
356    }
357}
358
359/** @deprecated class alias since 1.44 */
360class_alias( StringUtils::class, 'StringUtils' );