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