Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 218
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
TokenizerUtils
0.00% covered (danger)
0.00%
0 / 218
0.00% covered (danger)
0.00%
0 / 14
13806
0.00% covered (danger)
0.00%
0 / 1
 internalFlatten
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 flattenIfArray
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 flattenString
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 flattenStringlist
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
 getAttrVal
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 buildTableTokens
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
306
 buildXMLTag
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 inlineBreaks
0.00% covered (danger)
0.00%
0 / 83
0.00% covered (danger)
0.00%
0 / 1
3306
 popComments
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
156
 getAutoUrlTerminatingChars
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 enforceParserResourceLimits
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 protectAttrs
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 resetAnnotationIncludeRegex
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 parsoidFragmentMarkerToTokens
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Utilities used in the tokenizer.
4 * @module wt2html/tokenizer_utils
5 */
6
7declare( strict_types = 1 );
8
9namespace Wikimedia\Parsoid\Wt2Html;
10
11use Wikimedia\Parsoid\Config\Env;
12use Wikimedia\Parsoid\Core\Source;
13use Wikimedia\Parsoid\Core\SourceRange;
14use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
15use Wikimedia\Parsoid\NodeData\DataParsoid;
16use Wikimedia\Parsoid\NodeData\TempData;
17use Wikimedia\Parsoid\Tokens\CommentTk;
18use Wikimedia\Parsoid\Tokens\EndTagTk;
19use Wikimedia\Parsoid\Tokens\SelfclosingTagTk;
20use Wikimedia\Parsoid\Tokens\TagTk;
21use Wikimedia\Parsoid\Tokens\Token;
22use Wikimedia\Parsoid\Utils\DOMDataUtils;
23use Wikimedia\Parsoid\Utils\PHPUtils;
24use Wikimedia\Parsoid\Utils\PipelineUtils;
25use Wikimedia\Parsoid\Wikitext\Consts;
26
27class TokenizerUtils {
28    private static ?string $protectAttrsRegExp = null;
29    private static ?string $inclAnnRegExp = null;
30
31    /**
32     * @param mixed $e
33     * @param ?array &$res
34     * @return mixed (same type as $e)
35     * @throws \Exception
36     */
37    private static function internalFlatten( $e, ?array &$res ) {
38        // Don't bother flattening if we dont have an array
39        if ( !is_array( $e ) ) {
40            return $e;
41        }
42
43        foreach ( $e as $i => $v ) {
44            if ( is_array( $v ) ) {
45                // Change in assumption from a shallow array to a nested array.
46                $res ??= array_slice( $e, 0, $i );
47                self::internalFlatten( $v, $res );
48            } elseif ( $res !== null ) {
49                $res[] = $v;
50            }
51        }
52
53        return $res ?? $e;
54    }
55
56    /**
57     * If $a is an array, this recursively flattens all nested arrays.
58     * @param mixed $a
59     * @return mixed
60     */
61    public static function flattenIfArray( $a ) {
62        return self::internalFlatten( $a, $res );
63    }
64
65    /**
66     * FIXME: document
67     * @param array $c
68     * @return non-empty-string|list<non-empty-string|Token>
69     */
70    public static function flattenString( $c ) {
71        $out = self::flattenStringlist( $c );
72        if ( count( $out ) === 1 && is_string( $out[0] ) ) {
73            return $out[0];
74        } else {
75            return $out;
76        }
77    }
78
79    /**
80     * FIXME: document
81     *
82     * @param array $c
83     *
84     * @return list<non-empty-string|Token>
85     */
86    public static function flattenStringlist( array $c ): array {
87        $out = [];
88        $text = '';
89        $c = self::flattenIfArray( $c );
90        for ( $i = 0, $l = count( $c );  $i < $l;  $i++ ) {
91            $ci = $c[$i];
92            if ( is_string( $ci ) ) {
93                if ( $ci !== '' ) {
94                    $text .= $ci;
95                }
96            } else {
97                if ( $text !== '' ) {
98                    $out[] = $text;
99                    $text = '';
100                }
101                $out[] = $ci;
102            }
103        }
104        if ( $text !== '' ) {
105            $out[] = $text;
106        }
107        return $out;
108    }
109
110    /**
111     * @template T
112     * @param T $value
113     * @param int $start start of TSR range
114     * @param int $end end of TSR range
115     * @param ?Source $source The Source for the TSR range
116     * @return array{value: T, srcOffsets: SourceRange}
117     */
118    public static function getAttrVal( $value, int $start, int $end, ?Source $source ): array {
119        return [ 'value' => $value, 'srcOffsets' => new SourceRange( $start, $end, $source ) ];
120    }
121
122    /**
123     * Build a token array representing <tag>$content</tag> alongwith
124     * appropriate attributes and TSR info set on the tokens.
125     *
126     * @param string $pegSource
127     * @param string $tagName
128     * @param string $wtChar
129     * @param ?array $attrInfo
130     * @param SourceRange $tsr
131     * @param int $endPos
132     * @param mixed $content
133     * @param bool $addEndTag
134     * @return array (of tokens)
135     */
136    public static function buildTableTokens(
137        string $pegSource, string $tagName, string $wtChar, ?array $attrInfo,
138        SourceRange $tsr, int $endPos, $content, bool $addEndTag = false
139    ): array {
140        $dp = new DataParsoid;
141        $dp->tsr = $tsr;
142
143        if ( $tagName === 'td' ) {
144            if ( !$attrInfo ) {
145                // Add a flag that indicates that the tokenizer didn't
146                // encounter a "|...|" attribute box. This is useful when
147                // deciding which <td>/<th> cells need attribute fixups.
148                $dp->setTempFlag( TempData::TABLE_CELL_WITH_NO_ATTRIBUTE_SYNTAX );
149            } else {
150                if ( !$attrInfo[0] && $attrInfo[1] === "" ) {
151                    // FIXME: Skip comments between the two "|" chars
152                    // [ [], "", "|"] => "||" syntax for first <td> on line
153                    $dp->setTempFlag( TempData::NON_MERGEABLE_TABLE_CELL );
154                    $dp->setTempFlag( TempData::TABLE_CELL_WITH_NO_ATTRIBUTE_SYNTAX );
155                }
156            }
157        } elseif ( $tagName === 'th' ) {
158            if ( !$attrInfo ) {
159                // Add a flag that indicates that the tokenizer didn't
160                // encounter a "!...|" attribute box. This is useful when
161                // deciding which <td>/<th> cells need attribute fixups.
162                $dp->setTempFlag( TempData::TABLE_CELL_WITH_NO_ATTRIBUTE_SYNTAX );
163
164                // FIXME: Skip comments between the two "!" chars
165                // "!!foo" in sol context parses as <th>!foo</th>
166                if (
167                    is_string( $content[0][0] ?? null ) &&
168                    str_starts_with( $content[0][0], "!" )
169                ) {
170                    $dp->setTempFlag( TempData::NON_MERGEABLE_TABLE_CELL );
171                }
172            }
173        }
174
175        $attrs = [];
176        if ( $attrInfo ) {
177            $dp->getTemp()->attrSrc = substr(
178                $pegSource, $tsr->start, $tsr->end - $tsr->start - strlen( $attrInfo[2] )
179            );
180            $attrs = $attrInfo[0];
181            if ( !$attrs ) {
182                $dp->startTagSrc = $wtChar . $attrInfo[1];
183            }
184            if ( ( !$attrs && $attrInfo[2] ) || $attrInfo[2] !== '|' ) {
185                // Variation from default
186                // 1. Separator present with an empty attribute block
187                // 2. Not "|"
188                $dp->attrSepSrc = $attrInfo[2];
189            }
190        } else {
191            $dp->getTemp()->attrSrc = '';
192        }
193
194        // We consider 1 (or 5) the start because the table_data_tag and table_heading_tag
195        // rules don't include the pipe (or `{{!}}`) so it isn't accounted for in the tsr passed
196        // to this function.  The rules making use of those rules do some extra
197        // bookkeeping to adjust for that on the start token returned from this
198        // function.  Of course, table_caption_tag doesn't follow that same pattern
199        // but that isn't a concern here.
200        $atSrcStart = preg_match(
201            '/^\s*([|!]|\{\{!\}\})$/', substr( $pegSource, 0, $tsr->start )
202        );
203        if ( $tagName !== 'caption' && $atSrcStart ) {
204            $dp->setTempFlag( TempData::AT_SRC_START );
205        }
206
207        $tokens = [ new TagTk( $tagName, $attrs, $dp ) ];
208        PHPUtils::pushArray( $tokens, $content );
209
210        if ( $addEndTag ) {
211            $dataParsoid = new DataParsoid;
212            $dataParsoid->tsr = new SourceRange( $endPos, $endPos, $tsr->source );
213            $tokens[] = new EndTagTk( $tagName, [], $dataParsoid );
214        } else {
215            // We rely on our tree builder to close the table cell (td/th) as needed.
216            // We cannot close the cell here because cell content can come from
217            // multiple parsing contexts and we cannot close the tag in the same
218            // parsing context in which the td was opened:
219            //   Ex: {{1x|{{!}}foo}}{{1x|bar}} has to output <td>foobar</td>
220            //
221            // Previously a meta marker was added here for DSR computation, but
222            // that's complicated now that marker meta handling has been removed
223            // from ComputeDSR.
224        }
225
226        return $tokens;
227    }
228
229    /**
230     * Build a token representing <tag>, <tag />, or </tag>
231     * with appropriate attributes set on the token.
232     *
233     * @param string $name
234     * @param string $lcName
235     * @param array $attribs
236     * @param mixed $endTag
237     * @param bool $selfClose
238     * @param SourceRange $tsr
239     * @return Token
240     */
241    public static function buildXMLTag( string $name, string $lcName, array $attribs, $endTag,
242        bool $selfClose, SourceRange $tsr
243    ): Token {
244        $da = new DataParsoid;
245        $da->tsr = $tsr;
246        $da->stx = 'html';
247
248        if ( $name !== $lcName ) {
249            $da->srcTagName = $name;
250        }
251
252        if ( $endTag !== null ) {
253            $tok = new EndTagTk( $lcName, $attribs, $da );
254        } elseif ( $selfClose ) {
255            $da->selfClose = true;
256            $tok = new SelfclosingTagTk( $lcName, $attribs, $da );
257        } else {
258            $tok = new TagTk( $lcName, $attribs, $da );
259        }
260
261        return $tok;
262    }
263
264    /**
265     * Inline breaks, flag-enabled rule which detects end positions for
266     * active higher-level rules in inline and other nested rules.
267     * Those inner rules are then exited, so that the outer rule can
268     * handle the end marker.
269     * @param string $input
270     * @param int $pos
271     * @param array $stops
272     * @param Env $env
273     * @return bool
274     * @throws \Exception
275     */
276    public static function inlineBreaks( string $input, int $pos, array $stops, Env $env ): bool {
277        $c = $input[$pos];
278        $c2 = $input[$pos + 1] ?? '';
279
280        switch ( $c ) {
281            case '=':
282                if ( $stops['arrow'] && $c2 === '>' ) {
283                    return true;
284                }
285                $htmlOrEmpty = ( $stops['tagType'] === 'html' || $stops['tagType'] === '' );
286                if ( $stops['equal'] && $htmlOrEmpty ) {
287                    return true;
288                }
289                if ( $stops['h'] ) {
290                    if ( self::$inclAnnRegExp === null ) {
291                        $tags = array_merge(
292                            [ 'noinclude', 'includeonly', 'onlyinclude' ],
293                            $env->getSiteConfig()->getAnnotationTags()
294                        );
295                        self::$inclAnnRegExp = '|<\/?(?:' . implode( '|', $tags ) . ')>';
296                    }
297                    return ( $pos === strlen( $input ) - 1
298                        // possibly more equals followed by spaces or comments
299                        || preg_match( '/^=*(?:[ \t]|<\!--(?:(?!-->).)*-->'
300                                . self::$inclAnnRegExp . ')*(?:[\r\n]|$)/sD',
301                            substr( $input, $pos + 1 ) ) );
302                }
303                return false;
304
305            case '|':
306                $htmlOrEmpty = ( $stops['tagType'] === 'html' || $stops['tagType'] === '' );
307                return $htmlOrEmpty && (
308                    $stops['templateArg']
309                    || $stops['tableCellArg']
310                    || ( $stops['linkdesc'] && $stops['tagType'] !== 'html' )
311                    || ( $stops['table']
312                        && $pos < strlen( $input ) - 1
313                        && preg_match( '/[}|]/', $c2 ) )
314                    || ( $stops['table']
315                        && substr( $input, $pos, 6 ) === '|{{!}}' )
316                );
317
318            case '!':
319                return $stops['th']
320                    && $stops['preproc'] !== '}}'
321                    && !$stops['linkdesc']
322                    && $c2 === '!';
323
324            case '{':
325                // {{!}} pipe templates..
326                // FIXME: Presumably these should mix with and match | above.
327                return ( $stops['tableCellArg']
328                        && substr( $input, $pos, 5 ) === '{{!}}' )
329                    || ( $stops['table']
330                        && substr( $input, $pos, 10 ) === '{{!}}{{!}}' )
331                    || ( $stops['table']
332                        && substr( $input, $pos, 6 ) === '{{!}}|' );
333
334            case '}':
335                $preproc = $stops['preproc'];
336                return ( $c2 === '}' && $preproc === '}}' )
337                    || ( $c2 === '-' && $preproc === '}-' );
338
339            case ':':
340                return $stops['colon']
341                    && !$stops['extlink']
342                    && !$stops['linkdesc']
343                    // ':' inside -{ .. }- or {{ .. }} should
344                    // not trigger the colon break
345                    && $stops['preproc'] !== '}-'
346                    && $stops['preproc'] !== '}}';
347
348            case ';':
349                return $stops['semicolon'];
350
351            case "\r":
352                return $stops['table']
353                    && preg_match( '/\r\n?\s*[!|]/', substr( $input, $pos ) );
354
355            case "\n":
356                // The code below is just a manual / efficient
357                // version of this check.
358                //
359                // stops.table && /^\n\s*[!|]/.test(input.substr(pos));
360                //
361                // It eliminates a substr on the string and eliminates
362                // a potential perf problem since "\n" and the inline_breaks
363                // test is common during tokenization.
364                if ( !$stops['table'] || $stops['linkdesc'] ) {
365                    return false;
366                }
367
368                // Allow leading whitespace in tables
369
370                // Since we switched on 'c' which is input[pos],
371                // we know that input[pos] is "\n".
372                // So, the /^\n/ part of the regexp is already satisfied.
373                // Look for /\s*[!|]/ below.
374                $n = strlen( $input );
375                for ( $i = $pos + 1;  $i < $n;  $i++ ) {
376                    $d = $input[$i];
377                    if ( preg_match( '/[!|]/', $d ) ) {
378                        return true;
379                    } elseif ( !( preg_match( '/\s/', $d ) ) ) {
380                        return false;
381                    }
382                }
383                return false;
384
385            case '[':
386                // 'tableCellArg' check is a special case in php's doTableStuff
387                // added in response to T2553.  If it encounters a `[[`, it bails
388                // on parsing attributes and interprets it all as content.
389                return $stops['tableCellArg'] && $c2 === '[';
390
391            case '-':
392                // Same as above for 'tableCellArg': a special case in doTableStuff,
393                // added as part of T153140
394                return $stops['tableCellArg'] && $c2 === '{';
395
396            case ']':
397                if ( $stops['extlink'] ) {
398                    return true;
399                }
400                return $stops['preproc'] === ']]'
401                    && $c2 === ']';
402
403            default:
404                throw new \RuntimeException( 'Unhandled case!' );
405        }
406    }
407
408    /**
409     * Pop off the end comments, if any.
410     *
411     * @param array &$attrs
412     * @return ?array{buf: array, commentStartPos: int}
413     */
414    public static function popComments( array &$attrs ): ?array {
415        $buf = [];
416        for ( $i = count( $attrs ) - 1;  $i > -1;  $i-- ) {
417            $kv = $attrs[$i];
418            if ( is_string( $kv->k ) && !$kv->v && preg_match( '/^\s*$/D', $kv->k ) ) {
419                // permit whitespace
420                array_unshift( $buf, $kv->k );
421            } elseif ( is_array( $kv->k ) && !$kv->v ) {
422                // all should be comments
423                foreach ( $kv->k as $k ) {
424                    if ( !( $k instanceof CommentTk ) ) {
425                        break 2;
426                    }
427                }
428                array_splice( $buf, 0, 0, $kv->k );
429            } else {
430                break;
431            }
432        }
433        // ensure we found a comment
434        while ( $buf && !( $buf[0] instanceof CommentTk ) ) {
435            array_shift( $buf );
436        }
437        if ( $buf ) {
438            array_splice( $attrs, -count( $buf ), count( $buf ) );
439            return [ 'buf' => $buf, 'commentStartPos' => $buf[0]->dataParsoid->tsr->start ];
440        } else {
441            return null;
442        }
443    }
444
445    /** Get a string containing all the autourl terminating characters (as in legacy parser
446     * Parser.php::makeFreeExternalLink). This list is slightly context-dependent because the
447     * inclusion of the right parenthesis depends on whether the provided character array $arr
448     * contains a left parenthesis.
449     * @param bool $hasLeftParen should be true if the URL in question contains
450     *   a left parenthesis.
451     * @return string
452     */
453    public static function getAutoUrlTerminatingChars( bool $hasLeftParen ): string {
454        $chars = Consts::$strippedUrlCharacters;
455        if ( !$hasLeftParen ) {
456            $chars .= ')';
457        }
458        return $chars;
459    }
460
461    /**
462     * @param Env $env
463     * @param Token|string $token
464     */
465    public static function enforceParserResourceLimits( Env $env, $token ): void {
466        if ( $token instanceof TagTk || $token instanceof SelfclosingTagTk ) {
467            $resource = match ( $token->getName() ) {
468                'listItem' => 'listItem',
469                'template', 'template3' => 'transclusion',
470                'td',
471                'th' => 'tableCell',
472                default => null
473            };
474            if (
475                $resource !== null &&
476                $env->bumpWt2HtmlResourceUse( $resource ) === false
477            ) {
478                // `false` indicates that this bump pushed us over the threshold
479                // We don't want to log every token above that, which would be `null`
480                $env->log( 'warn', "wt2html: $resource limit exceeded" );
481            }
482        }
483    }
484
485    /**
486     * Protect Parsoid-inserted attributes by escaping them to prevent
487     * Parsoid-HTML spoofing in wikitext.
488     *
489     * @param string $name
490     * @return string
491     */
492    public static function protectAttrs( string $name ): string {
493        if ( self::$protectAttrsRegExp === null ) {
494            self::$protectAttrsRegExp = "/^(about|data-mw.*|data-parsoid.*|data-x.*|" .
495                DOMDataUtils::DATA_OBJECT_ATTR_NAME .
496                '|property|rel|typeof)$/i';
497        }
498        return preg_replace( self::$protectAttrsRegExp, 'data-x-$1', $name );
499    }
500
501    /**
502     * Resets $inclAnnRegExp to null to avoid test environment side effects
503     */
504    public static function resetAnnotationIncludeRegex(): void {
505        self::$inclAnnRegExp = null;
506    }
507
508    /**
509     * Expands a parsoid fragment marker to a token array
510     */
511    public static function parsoidFragmentMarkerToTokens(
512        Env $env, Frame $frame, string $marker, SourceRange $tsr
513    ): array {
514        // See PipelineUtils::pFragmentToParsoidFragmentMarkers()
515        // This is an atomic DOM subtree/forest, and so we're going
516        // to process it all the way to DOM.  Contrast with our
517        // handling of a PFragment return value from a parser
518        // function in TemplateHandler, which is processed to tokens only.
519        $pFragment = $env->getPFragment( $marker );
520        $domFragment = $pFragment->asDom(
521            new ParsoidExtensionAPI(
522                $env, [
523                    'wt2html' => [
524                        'frame' => $frame,
525                        'parseOpts' => [
526                            // This fragment comes from a template and it is important to set
527                            // the 'inTemplate' parse option for it.
528                            'inTemplate' => true,
529                            // There might be transclusions within this fragment and we want
530                            // to expand them. Ex: {{1x|<ref>{{my-tpl}}foo</ref>}}
531                            'expandTemplates' => true
532                        ]
533                    ]
534                ]
535            )
536        );
537        $dp = new DataParsoid;
538        $dp->tsr = $tsr;
539        $token = new SelfclosingTagTk( 'template', [], $dp );
540        return PipelineUtils::tunnelDOMThroughTokens( $env, $token, $domFragment, [] );
541    }
542}