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