Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
35.28% covered (danger)
35.28%
109 / 309
29.03% covered (danger)
29.03%
9 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
TokenUtils
35.28% covered (danger)
35.28%
109 / 309
29.03% covered (danger)
29.03%
9 / 31
9874.86
0.00% covered (danger)
0.00%
0 / 1
 isWikitextBlockTag
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 tagOpensBlockScope
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 tagClosesBlockScope
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isTemplateToken
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isTemplateArgToken
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 isExtensionToken
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 isHTMLTag
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 hasDOMFragmentType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isTableTag
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 isSolTransparentLinkTag
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 isBehaviorSwitch
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 isSolTransparent
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
90
 isAnnotationMetaToken
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isAnnotationStartToken
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 isAnnotationEndToken
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 isTranslationUnitMarker
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 matchTypeOf
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 hasTypeOf
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 dedupeAboutIds
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
90
 shiftTokenTSR
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
702
 resetSource
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 stripEOFTkFromTokens
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 convertOffsets
93.65% covered (success)
93.65%
59 / 63
0.00% covered (danger)
0.00%
0 / 1
32.26
 convertTokenOffsets
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
90
 collectOffsets
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
132
 isEntitySpanToken
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 newlinesToNlTks
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 tokensToString
53.33% covered (warning)
53.33%
24 / 45
0.00% covered (danger)
0.00%
0 / 1
114.47
 kvToHash
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 tokenTrim
10.00% covered (danger)
10.00%
2 / 20
0.00% covered (danger)
0.00%
0 / 1
99.21
 hasTemplateToken
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Utils;
5
6use Wikimedia\Assert\Assert;
7use Wikimedia\Assert\UnreachableException;
8use Wikimedia\Parsoid\Config\Env;
9use Wikimedia\Parsoid\Core\DomSourceRange;
10use Wikimedia\Parsoid\Core\Sanitizer;
11use Wikimedia\Parsoid\Core\Source;
12use Wikimedia\Parsoid\Core\SourceRange;
13use Wikimedia\Parsoid\Tokens\CommentTk;
14use Wikimedia\Parsoid\Tokens\EmptyLineTk;
15use Wikimedia\Parsoid\Tokens\EndTagTk;
16use Wikimedia\Parsoid\Tokens\EOFTk;
17use Wikimedia\Parsoid\Tokens\KV;
18use Wikimedia\Parsoid\Tokens\KVSourceRange;
19use Wikimedia\Parsoid\Tokens\NlTk;
20use Wikimedia\Parsoid\Tokens\PreprocTk;
21use Wikimedia\Parsoid\Tokens\SelfclosingTagTk;
22use Wikimedia\Parsoid\Tokens\TagTk;
23use Wikimedia\Parsoid\Tokens\Token;
24use Wikimedia\Parsoid\Tokens\XMLTagTk;
25use Wikimedia\Parsoid\Wikitext\Consts;
26
27/**
28 * This class contains general utilities for:
29 * (a) querying token properties and token types
30 * (b) manipulating tokens, individually and as collections.
31 */
32class TokenUtils {
33    public const SOL_TRANSPARENT_LINK_REGEX =
34        '/(?:^|\s)mw:PageProp\/(?:Category|redirect|Language)(?=$|\s)/D';
35
36    /**
37     * @param string $name
38     * @return bool
39     */
40    public static function isWikitextBlockTag( string $name ): bool {
41        return isset( Consts::$wikitextBlockElems[$name] );
42    }
43
44    /**
45     * In the legacy parser, these block tags open block-tag scope
46     * See doBlockLevels in the PHP parser (includes/parser/Parser.php).
47     *
48     * @param string $name
49     * @return bool
50     */
51    public static function tagOpensBlockScope( string $name ): bool {
52        return isset( Consts::$blockElems[$name] ) ||
53            isset( Consts::$alwaysBlockElems[$name] );
54    }
55
56    /**
57     * In the legacy parser, these block tags close block-tag scope
58     * See doBlockLevels in the PHP parser (includes/parser/Parser.php).
59     *
60     * @param string $name
61     * @return bool
62     */
63    public static function tagClosesBlockScope( string $name ): bool {
64        return isset( Consts::$antiBlockElems[$name] ) ||
65            isset( Consts::$neverBlockElems[$name] );
66    }
67
68    /**
69     * Is this a template token?
70     * @param Token|string|null $token
71     * @return bool
72     */
73    public static function isTemplateToken( $token ): bool {
74        return $token instanceof SelfclosingTagTk &&
75            in_array( $token->getName(), [ 'template', 'template3', 'templatearg' ], true );
76    }
77
78    /**
79     * Is this a template arg token?
80     * @param Token|string|null $token
81     * @return bool
82     */
83    public static function isTemplateArgToken( $token ): bool {
84        return $token instanceof SelfclosingTagTk && $token->getName() === 'templatearg';
85    }
86
87    /**
88     * Is this an extension token?
89     * @param Token|string|null $token
90     * @return bool
91     */
92    public static function isExtensionToken( $token ): bool {
93        return $token instanceof SelfclosingTagTk && $token->getName() === 'extension';
94    }
95
96    /**
97     * Determine whether the current token was an HTML tag in wikitext.
98     *
99     * @param Token|string|null $token
100     * @return bool
101     */
102    public static function isHTMLTag( $token ): bool {
103        return ( $token instanceof XMLTagTk ) &&
104            isset( $token->dataParsoid->stx ) &&
105            $token->dataParsoid->stx === 'html';
106    }
107
108    /**
109     * Is the token a DOMFragment type value?
110     *
111     * @param Token $token
112     * @return bool
113     */
114    public static function hasDOMFragmentType( Token $token ): bool {
115        return self::matchTypeOf( $token, '#^mw:DOMFragment(/sealed/\w+)?$#D' ) !== null;
116    }
117
118    /**
119     * Is the token a table tag?
120     *
121     * @param Token|string $token
122     * @return bool
123     */
124    public static function isTableTag( $token ): bool {
125        return ( $token instanceof TagTk || $token instanceof EndTagTk ) &&
126            isset( Consts::$HTML['TableTags'][$token->getName()] );
127    }
128
129    /**
130     * Determine if token is a transparent link tag
131     *
132     * @param Token|string $token
133     * @return bool
134     */
135    public static function isSolTransparentLinkTag( $token ): bool {
136        return ( $token instanceof XMLTagTk ) &&
137            $token->getName() === 'link' &&
138            preg_match( self::SOL_TRANSPARENT_LINK_REGEX, $token->getAttributeV( 'rel' ) ?? '' );
139    }
140
141    /**
142     * Does this token represent a behavior switch?
143     *
144     * @param Env $env
145     * @param Token|string $token
146     * @return bool
147     */
148    public static function isBehaviorSwitch( Env $env, $token ): bool {
149        return $token instanceof SelfclosingTagTk && (
150            // Before BehaviorSwitchHandler (ie. PreHandler, etc.)
151            $token->getName() === 'behavior-switch' ||
152            // After BehaviorSwitchHandler
153            // (ie. ListHandler, ParagraphWrapper, etc.)
154            ( $token->getName() === 'meta' &&
155                $token->hasAttribute( 'property' ) &&
156                preg_match( $env->getSiteConfig()->bswPagePropRegexp(),
157                    $token->getAttributeV( 'property' ) ?? '' )
158            ) );
159    }
160
161    /**
162     * This should come close to matching
163     * {@link WTUtils::emitsSolTransparentSingleLineWT},
164     * without the single line caveat.
165     * @param Env $env
166     * @param Token|string $token
167     * @return bool
168     */
169    public static function isSolTransparent( Env $env, $token ): bool {
170        if ( is_string( $token ) ) {
171            return (bool)preg_match( '/^[ \t]*$/D', $token );
172        } elseif (
173            self::isSolTransparentLinkTag( $token ) ||
174            $token instanceof EmptyLineTk ||
175            ( $token instanceof CommentTk && !self::isTranslationUnitMarker( $env, $token ) ) ||
176            self::isBehaviorSwitch( $env, $token )
177        ) {
178            return true;
179        } elseif ( $token instanceof SelfclosingTagTk && $token->getName() === 'meta' ) {
180            return !WTUtils::hasLiteralHTMLMarker( $token->dataParsoid );
181        }
182        return false;
183    }
184
185    /**
186     * @param Token $t
187     * @return bool
188     */
189    public static function isAnnotationMetaToken( Token $t ): bool {
190        return self::matchTypeOf( $t, WTUtils::ANNOTATION_META_TYPE_REGEXP ) !== null;
191    }
192
193    /**
194     * Checks whether the provided meta tag token is an annotation start token
195     * @param Token $t
196     * @return bool
197     */
198    public static function isAnnotationStartToken( Token $t ): bool {
199        $type = self::matchTypeOf( $t, WTUtils::ANNOTATION_META_TYPE_REGEXP );
200        return $type !== null && !str_ends_with( $type, '/End' );
201    }
202
203    /**
204     * Checks whether the provided meta tag token is an annotation end token
205     * @param Token $t
206     * @return bool
207     */
208    public static function isAnnotationEndToken( Token $t ): bool {
209        $type = self::matchTypeOf( $t, WTUtils::ANNOTATION_META_TYPE_REGEXP );
210        return $type !== null && str_ends_with( $type, '/End' );
211    }
212
213    /**
214     * HACK: Returns true if $token looks like a TU marker (<!--T:XXX-->) and if we could be in a
215     * translate-annotated page.
216     * @param Env $env
217     * @param CommentTk $token
218     * @return bool
219     */
220    public static function isTranslationUnitMarker( Env $env, CommentTk $token ): bool {
221        return $env->hasAnnotations &&
222            $env->getSiteConfig()->isAnnotationTag( 'translate' ) &&
223            preg_match( '/^T:/', $token->value ) === 1;
224    }
225
226    /**
227     * Determine whether the token matches the given `typeof` attribute value.
228     *
229     * @param Token $t The token to test
230     * @param string $typeRe Regular expression matching the expected value of
231     *   the `typeof` attribute.
232     * @return ?string The matching `typeof` value, or `null` if there is
233     *   no match.
234     */
235    public static function matchTypeOf( Token $t, string $typeRe ): ?string {
236        $v = $t->getAttributeV( 'typeof' );
237        if ( $v === null ) {
238            return null;
239        }
240        Assert::invariant( is_string( $v ), "Typeof is not simple" );
241        foreach ( preg_split( '/\s+/', $v, -1, PREG_SPLIT_NO_EMPTY ) as $ty ) {
242            $count = preg_match( $typeRe, $ty );
243            Assert::invariant( $count !== false, "Bad regexp" );
244            if ( $count ) {
245                return $ty;
246            }
247        }
248        return null;
249    }
250
251    /**
252     * Determine whether the token matches the given typeof attribute value.
253     *
254     * @param Token $t
255     * @param string $type Expected value of "typeof" attribute, as a literal
256     *   string.
257     * @return bool True if the token matches.
258     */
259    public static function hasTypeOf( Token $t, string $type ): bool {
260        return self::matchTypeOf(
261            $t, '/^' . preg_quote( $type, '/' ) . '$/D'
262        ) !== null;
263    }
264
265    /**
266     * @param Env $env
267     * @param array<mixed> $maybeTokens
268     *   Attribute arrays in tokens may be tokens or something else.
269     */
270    public static function dedupeAboutIds( Env $env, array $maybeTokens ): void {
271        $aboutMap = [];
272        foreach ( $maybeTokens as $t ) {
273            if ( !( $t instanceof Token ) ) {
274                continue;
275            }
276
277            foreach ( $t->attribs ?? [] as $kv ) {
278                if ( $kv->k === 'about' ) {
279                    $aboutMap[$kv->v] ??= $env->newAboutId();
280                    $t->setAttribute( 'about', $aboutMap[$kv->v] );
281                } else {
282                    if ( $kv->k instanceof Token ) {
283                        self::dedupeAboutIds( $env, [ $kv->k ] );
284                    } elseif ( is_array( $kv->k ) ) {
285                        self::dedupeAboutIds( $env, $kv->k );
286                    }
287
288                    if ( $kv->v instanceof Token ) {
289                        self::dedupeAboutIds( $env, [ $kv->v ] );
290                    } elseif ( is_array( $kv->v ) ) {
291                        self::dedupeAboutIds( $env, $kv->v );
292                    }
293                }
294            }
295        }
296    }
297
298    /**
299     * Shift TSR of a token by the requested $offset value and
300     * optionally, update its TSR source.
301     *
302     * At a basic level, "f(wt) = tokens" should be memoizable within the
303     * parser pipeline (since the config, env, etc. are fixed for the request)
304     * no matter where "wt" originated from (top-level or templates). But, embedded
305     * state like tsr offsets, and additional nested state like source ranges
306     * interfere with that memoizability. This method attempts to migrate over
307     * such embedded state reliably.
308     *
309     * NOTE about $offset
310     * ------------------
311     * A null value of $offset resets TSR on all tokens since we cannot
312     * compute a reliable new value of $tsr and the old value of $tsr
313     * should not be used either.
314     *
315     * NOTE about $tsrSource param
316     * ---------------------------
317     * In memoization scenarios where tokens are reused across source frames,
318     * we also need to reset the source objects to the target frame. Doing so
319     * effectively marks all SourceRange objects as belonging to the target frame.
320     * Note that the SourceRange design allows more fine-grained tracking across
321     * nested templates. Parsoid doesn't support that yet => the logic below is correct.
322     * But in a fine-grained tracking scenario, we'll need to either null offsets OR
323     * disable cross-frame memoization OR do more complicated state migration.
324     */
325    public static function shiftTokenTSR( array $tokens, ?int $offset, ?Source $tsrSource = null ): void {
326        // Bail early if we can
327        if ( $offset === 0 && $tsrSource === null ) {
328            return;
329        }
330
331        // update/clear tsr
332        foreach ( $tokens as $t ) {
333            if ( !( $t instanceof XMLTagTk ||
334                $t instanceof NlTk ||
335                $t instanceof CommentTk ||
336                $t instanceof PreprocTk
337            ) ) {
338                continue;
339            }
340
341            $da = $t->dataParsoid;
342            $tsr = $da->tsr ?? null;
343            if ( $tsr ) {
344                if ( $offset !== 0 ) {
345                    $da->tsr = ( $offset === null ) ? null : $tsr->offset( $offset );
346                }
347                if ( $tsrSource ) {
348                    $da->tsr->source = $tsrSource;
349                }
350            }
351
352            if ( $offset !== null ) {
353                if ( isset( $da->extTagOffsets ) ) {
354                    if ( $offset !== 0 ) {
355                        $da->extTagOffsets = $da->extTagOffsets->offset( $offset );
356                    }
357                    if ( $tsrSource ) {
358                        $da->extTagOffsets->source = $tsrSource;
359                    }
360                }
361
362                // SSS FIXME: offset will always be available in
363                // chunky-tokenizer mode in which case we wont have
364                // buggy offsets below.  The null scenario is only
365                // for when the token-stream-patcher attempts to
366                // reparse a string -- it is likely to only patch up
367                // small string fragments and the complicated use cases
368                // below should not materialize.
369                // CSA: token-stream-patcher shouldn't have problems
370                // now that $tsr->source/$frame->srcText is always
371                // accurate?
372
373                // content offsets for ext-links
374                if ( isset( $da->tmp->extLinkContentOffsets ) ) {
375                    if ( $offset !== 0 ) {
376                        $da->tmp->extLinkContentOffsets =
377                            $da->tmp->extLinkContentOffsets->offset( $offset );
378                    }
379                    if ( $tsrSource ) {
380                        $da->tmp->extLinkContentOffsets->source = $tsrSource;
381                    }
382                }
383            }
384
385            // Process attributes
386            foreach ( $t->attribs ?? [] as $a ) {
387                if ( is_array( $a->k ) ) {
388                    self::shiftTokenTSR( $a->k, $offset, $tsrSource );
389                }
390                if ( is_array( $a->v ) ) {
391                    self::shiftTokenTSR( $a->v, $offset, $tsrSource );
392                }
393
394                // src offsets used to set mw:TemplateParams
395                if ( $offset === null ) {
396                    $a->srcOffsets = null;
397                } elseif ( $a->srcOffsets !== null ) {
398                    if ( $offset !== 0 ) {
399                        $a->srcOffsets = $a->srcOffsets->offset( $offset );
400                    }
401                    if ( $tsrSource ) {
402                        $a->srcOffsets->key->source = $tsrSource;
403                        $a->srcOffsets->value->source = $tsrSource;
404                    }
405                }
406            }
407        }
408    }
409
410    public static function resetSource( array $tokens, Source $tsrSource ): void {
411        self::shiftTokenTSR( $tokens, 0, $tsrSource );
412    }
413
414    /**
415     * Strip EOFTk token from token chunk.
416     * The EOFTk is expected to be the last token of the chunk.
417     *
418     * @param array &$tokens
419     * @return array return the modified token array so that this call can be chained
420     */
421    public static function stripEOFTkFromTokens( array &$tokens ): array {
422        $last = array_key_last( $tokens );
423        if ( $last !== null && $tokens[$last] instanceof EOFTk ) {
424            array_pop( $tokens );
425        }
426        return $tokens;
427    }
428
429    /**
430     * Convert string offsets
431     *
432     * Offset types are:
433     *  - 'byte': Bytes (UTF-8 encoding), e.g. PHP `substr()` or `strlen()`.
434     *  - 'char': Unicode code points (encoding irrelevant), e.g. PHP `mb_substr()` or `mb_strlen()`.
435     *  - 'ucs2': 16-bit code units (UTF-16 encoding), e.g. JavaScript `.substring()` or `.length`.
436     *
437     * Offsets that are mid-Unicode character are "rounded" up to the next full
438     * character, i.e. the output offset will always point to the start of a
439     * Unicode code point (or just past the end of the string). Offsets outside
440     * the string are "rounded" to 0 or just-past-the-end.
441     *
442     * @note When constructing the array of offsets to pass to this method,
443     *  populate it with references as `$offsets[] = &$var;`.
444     *
445     * @param string $s Unicode string the offsets are offsets into, UTF-8 encoded.
446     * @param ('byte'|'ucs2'|'char') $from Offset type to convert from.
447     * @param ('byte'|'ucs2'|'char') $to Offset type to convert to.
448     * @param int[] $offsets References to the offsets to convert.
449     */
450    public static function convertOffsets(
451        string $s, string $from, string $to, array $offsets
452    ): void {
453        static $valid = [ 'byte', 'char', 'ucs2' ];
454        if ( !in_array( $from, $valid, true ) ) {
455            throw new \InvalidArgumentException( 'Invalid $from' );
456        }
457        if ( !in_array( $to, $valid, true ) ) {
458            throw new \InvalidArgumentException( 'Invalid $to' );
459        }
460
461        $i = 0;
462        $offsetCt = count( $offsets );
463        if ( $offsetCt === 0 ) { // Nothing to do
464            return;
465        }
466        sort( $offsets, SORT_NUMERIC );
467
468        $bytePos = 0;
469        $ucs2Pos = 0;
470        $charPos = 0;
471
472        $fromPos = &${$from . 'Pos'};  // @phan-suppress-current-line PhanPluginDollarDollar
473        $toPos = &${$to . 'Pos'};  // @phan-suppress-current-line PhanPluginDollarDollar
474
475        $byteLen = strlen( $s );
476        while ( $bytePos < $byteLen ) {
477            // Update offsets that we've reached
478            while ( $offsets[$i] <= $fromPos ) {
479                $offsets[$i] = $toPos;
480                if ( ++$i >= $offsetCt ) {
481                    return;
482                }
483            }
484
485            // Update positions
486            ++$charPos;
487            $c = ord( $s[$bytePos] ) & 0xf8;
488            switch ( $c ) {
489                case 0x00:
490                case 0x08:
491                case 0x10:
492                case 0x18:
493                case 0x20:
494                case 0x28:
495                case 0x30:
496                case 0x38:
497                case 0x40:
498                case 0x48:
499                case 0x50:
500                case 0x58:
501                case 0x60:
502                case 0x68:
503                case 0x70:
504                case 0x78:
505                    ++$bytePos;
506                    ++$ucs2Pos;
507                    break;
508
509                case 0xc0:
510                case 0xc8:
511                case 0xd0:
512                case 0xd8:
513                    $bytePos += 2;
514                    ++$ucs2Pos;
515                    break;
516
517                case 0xe0:
518                case 0xe8:
519                    $bytePos += 3;
520                    ++$ucs2Pos;
521                    break;
522
523                case 0xf0:
524                    $bytePos += 4;
525                    $ucs2Pos += 2;
526                    break;
527
528                default:
529                    throw new \InvalidArgumentException(
530                        bin2hex( $s ) . " (dumped via php bin2hex) is not valid UTF-8" );
531            }
532        }
533
534        // Convert any offsets past the end of the string to the length of the
535        // string.
536        while ( $i < $offsetCt ) {
537            $offsets[$i] = $toPos;
538            ++$i;
539        }
540    }
541
542    /**
543     * Convert offsets in a token array
544     *
545     * @see TokenUtils::convertOffsets()
546     *
547     * @param string $s The offset reference string
548     * @param ('byte'|'ucs2'|'char') $from Offset type to convert from
549     * @param ('byte'|'ucs2'|'char') $to Offset type to convert to
550     * @param array<Token|string|array> $tokens
551     */
552    public static function convertTokenOffsets(
553        string $s, string $from, string $to, array $tokens
554    ): void {
555        $offsets = []; /* @var array<int> $offsets */
556        self::collectOffsets( $tokens, static function ( $sr ) use ( &$offsets ) {
557            if ( $sr instanceof DomSourceRange ) {
558                // Adjust the widths to be actual character offsets
559                if ( $sr->openWidth !== null ) {
560                    Assert::invariant( $sr->start !== null, "width w/o start" );
561                    $sr->openWidth = $sr->start + $sr->openWidth;
562                    $offsets[] =& $sr->openWidth;
563                }
564                if ( $sr->closeWidth !== null ) {
565                    Assert::invariant( $sr->end !== null, "width w/o end" );
566                    $sr->closeWidth = $sr->end - $sr->closeWidth;
567                    $offsets[] =& $sr->closeWidth;
568                }
569            }
570            if ( $sr->start !== null ) {
571                $offsets[] =& $sr->start;
572            }
573            if ( $sr->end !== null ) {
574                $offsets[] =& $sr->end;
575            }
576        } );
577        self::convertOffsets( $s, $from, $to, $offsets );
578        self::collectOffsets( $tokens, static function ( $sr ) {
579            if ( $sr instanceof DomSourceRange ) {
580                // Adjust widths back from being character offsets
581                if ( $sr->openWidth !== null ) {
582                    $sr->openWidth -= $sr->start;
583                }
584                if ( $sr->closeWidth !== null ) {
585                    $sr->closeWidth = $sr->end - $sr->closeWidth;
586                }
587            }
588        } );
589    }
590
591    /**
592     * @param array<Token|string>|array<KV>|KV|Token|DomSourceRange|KVSourceRange|SourceRange|string $input
593     * @param callable $offsetFunc
594     */
595    private static function collectOffsets( $input, callable $offsetFunc ): void {
596        if ( is_array( $input ) ) {
597            foreach ( $input as $token ) {
598                self::collectOffsets( $token, $offsetFunc );
599            }
600        } elseif ( $input instanceof KV ) {
601            self::collectOffsets( $input->k, $offsetFunc );
602            self::collectOffsets( $input->v, $offsetFunc );
603            if ( $input->srcOffsets ) {
604                self::collectOffsets( $input->srcOffsets, $offsetFunc );
605            }
606        } elseif ( $input instanceof Token ) {
607            if ( isset( $input->dataParsoid->tsr ) ) {
608                self::collectOffsets( $input->dataParsoid->tsr, $offsetFunc );
609            }
610            if ( isset( $input->dataParsoid->tmp->extLinkContentOffsets ) ) {
611                self::collectOffsets( $input->dataParsoid->tmp->extLinkContentOffsets, $offsetFunc );
612            }
613            if ( isset( $input->dataParsoid->extTagOffsets ) ) {
614                self::collectOffsets( $input->dataParsoid->extTagOffsets, $offsetFunc );
615            }
616            self::collectOffsets( $input->attribs, $offsetFunc );
617        } elseif ( $input instanceof KVSourceRange ) {
618            self::collectOffsets( $input->key, $offsetFunc );
619            self::collectOffsets( $input->value, $offsetFunc );
620        } elseif ( $input instanceof SourceRange ) {
621            // This includes DomSourceRange
622            $offsetFunc( $input );
623        }
624    }
625
626    /**
627     * Tests whether token represents an HTML entity.
628     * Think `<span typeof="mw:Entity">`.
629     * @param Token|string|null $token
630     * @return bool
631     */
632    public static function isEntitySpanToken( $token ): bool {
633        return $token instanceof TagTk &&
634            $token->getName() === 'span' &&
635            self::hasTypeOf( $token, 'mw:Entity' );
636    }
637
638    /**
639     * Transform `"\n"` and `"\r\n"` in the input string to {@link NlTk} tokens.
640     *
641     * @param string $str
642     * @return non-empty-list<NlTk|string> (interspersed string and NlTk tokens)
643     */
644    public static function newlinesToNlTks( string $str ): array {
645        $ret = [];
646        foreach ( preg_split( '/\r?\n/', $str ) as $i => $tok ) {
647            if ( $i ) {
648                $ret[] = new NlTk( null );
649            }
650            $ret[] = $tok;
651        }
652        return $ret;
653    }
654
655    /**
656     * Flatten/convert a token array into a string.
657     * @param string|Token|array<Token|string> $tokens
658     * @param bool $strict Whether to abort as soon as we find a token we
659     *   can't stringify.
660     * @param array<string,bool> $opts
661     * @param ?int $max Maximum tokens to process
662     * @return string|list{string,array<Token|string>}
663     *   The stringified tokens. If $strict is true, returns a two-element
664     *   array containing string prefix and the remainder of the tokens as
665     *   soon as we encounter something we can't stringify.
666     */
667    public static function tokensToString(
668        $tokens, bool $strict = false, array $opts = [], ?int $max = null
669    ) {
670        if ( is_string( $tokens ) ) {
671            return $tokens;
672        }
673
674        if ( !is_array( $tokens ) ) {
675            $tokens = [ $tokens ];
676        }
677
678        $out = '';
679        for ( $i = 0, $l = count( $tokens ); $i < min( $l, $max ?? $l ); $i++ ) {
680            $token = $tokens[$i];
681            if ( $token === null ) {
682                throw new UnreachableException( "No nulls expected." );
683            } elseif ( $token instanceof KV ) {
684                // Since this function is occasionally called on KV->v,
685                // whose signature recursively includes KV[], a mismatch with
686                // this function, we assert that those values are only
687                // included in safe places that don't intend to stringify
688                // their tokens.
689                throw new UnreachableException( "No KVs expected." );
690            } elseif ( is_string( $token ) ) {
691                $out .= $token;
692            } elseif ( $token instanceof PreprocTk ) {
693                $out .= $token->print( pretty: false );
694            } elseif (
695                $token instanceof CommentTk ||
696                ( empty( $opts['retainNLs'] ) && $token instanceof NlTk )
697            ) {
698                // strip comments and newlines
699            } elseif ( !empty( $opts['stripEmptyLines'] ) && ( $token instanceof EmptyLineTk ) ) {
700                // If requested, strip empty line meta tokens too.
701            } elseif ( !empty( $opts['includeEntities'] ) && self::isEntitySpanToken( $token ) ) {
702                $out .= $token->dataParsoid->src;
703                $i += 2; // Skip child and end tag.
704            } elseif ( $token instanceof TagTk && $token->getName() === 'listItem' ) {
705                $out .= $token->getAttributeKV( 'bullets' )->srcOffsets->value->substr();
706            } elseif (
707                !empty( $opts['includeUrlLink'] ) &&
708                $token instanceof TagTk && $token->getName() === 'a' &&
709                ( $token->dataParsoid->stx ?? '' ) === 'url'
710            ) {
711                // WikiLinkHandler::stringifyOptionTokens includes urllinks
712                // unconditionally but also entity encodes any pipes
713                //
714                // Urlencoding characters not found in the legacy parser's
715                // Parser::EXT_LINK_URL_CLASS is very specific to the use in
716                // the ExternalLinkHandler but if they were part of the
717                // urllink then they were entity encoded in the source and
718                // should be protected for when we try to tokenize as a url
719                $out .= Sanitizer::encodeUrlForExtLink( $tokens[$i + 1] );
720                $i += 2;
721            } elseif (
722                // This option shouldn't be used if the tokens have been
723                // expanded to DOM
724                !empty( $opts['unpackDOMFragments'] ) &&
725                ( $token instanceof TagTk || $token instanceof SelfclosingTagTk ) &&
726                self::hasDOMFragmentType( $token )
727            ) {
728                // Handle dom fragments
729                $domFragment = $token->dataParsoid->html;
730                // Removing the DOMFragment here is case dependent
731                // but should be rare enough when permissible that it can be
732                // ignored.
733                // FIXME: The correct thing to do would be to return
734                // `$domFragment.innerHTML` for the current scenarios where
735                // `unpackDOMFragments` is used (expanded attribute
736                // values and reparses thereof) but we'd need to remove
737                // the span wrapping and typeof annotation of extension
738                // content and nowikis.  Since we're primarily expecting
739                // to find <translate> and <nowiki> here, this will do.
740                $out .= $domFragment->textContent;
741                if ( $token instanceof TagTk ) {
742                    $i += 1; // Skip the EndTagTK
743                    Assert::invariant(
744                        $i >= $l || $tokens[$i] instanceof EndTagTk,
745                        "tag should be followed by endtag"
746                    );
747                }
748            } elseif ( $strict ) {
749                // If strict, return accumulated string on encountering first non-text token
750                return [ $out, array_slice( $tokens, $i ) ];
751            } elseif ( is_array( $token ) ) {
752                Assert::invariant( !$strict, "strict case handled above" );
753                $out .= self::tokensToString( $token, $strict, $opts );
754            }
755        }
756        return $out;
757    }
758
759    /**
760     * Convert an array of key-value pairs into a hash of keys to values.
761     * For duplicate keys, the last entry wins.
762     * @note that numeric key values will be converted by PHP from string to
763     *  int when they are used as array keys.
764     * @param array<KV> $kvs
765     * @return array<string|int,array<Token|string>>|array<string|int,string>
766     */
767    public static function kvToHash( array $kvs ): array {
768        $res = [];
769        foreach ( $kvs as $kv ) {
770            $key = trim( self::tokensToString( $kv->k ) );
771            // SSS FIXME: Temporary fix to handle extensions which use
772            // entities in attribute values. We need more robust handling
773            // of non-string template attribute values in general.
774            $val = self::tokensToString( $kv->v );
775            $res[mb_strtolower( $key )] = self::tokenTrim( $val );
776        }
777        return $res;
778    }
779
780    /**
781     * Trim space and newlines from leading and trailing text tokens.
782     * @param string|Token|(Token|string)[] $tokens
783     * @return string|Token|(Token|string)[]
784     */
785    public static function tokenTrim( $tokens ) {
786        if ( !is_array( $tokens ) ) {
787            return is_string( $tokens ) ? trim( $tokens ) : $tokens;
788        }
789
790        // strip leading space
791        foreach ( $tokens as &$token ) {
792            if ( $token instanceof NlTk ) {
793                $token = '';
794            } elseif ( is_string( $token ) ) {
795                $token = preg_replace( '/^\s+/', '', $token, 1 );
796                if ( $token !== '' ) {
797                    break;
798                }
799            } else {
800                break;
801            }
802        }
803
804        // strip trailing space
805        for ( $i = count( $tokens ); $i--; ) {
806            $token = &$tokens[$i];
807            if ( $token instanceof NlTk ) {
808                $token = ''; // replace newline with empty
809            } elseif ( is_string( $token ) ) {
810                $token = preg_replace( '/\s+$/D', '', $token, 1 );
811                if ( $token !== '' ) {
812                    break;
813                }
814            } else {
815                break;
816            }
817        }
818
819        return $tokens;
820    }
821
822    /**
823     * Detect, if array (or any iterable container) contains template token
824     * @param null|array<string|Token> $tokens
825     * @return bool
826     */
827    public static function hasTemplateToken( $tokens ): bool {
828        return is_array( $tokens ) &&
829            array_any( $tokens, self::isTemplateToken( ... ) );
830    }
831
832}