Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
39.73% covered (danger)
39.73%
87 / 219
15.38% covered (danger)
15.38%
2 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
TreeBuilderStage
39.73% covered (danger)
39.73%
87 / 219
15.38% covered (danger)
15.38%
2 / 13
1591.50
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 resetState
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 processChunk
56.52% covered (warning)
56.52%
13 / 23
0.00% covered (danger)
0.00%
0 / 1
18.22
 finalizeDOM
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
2.69
 kvArrToAttr
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 stashDataAttribs
53.85% covered (warning)
53.85%
7 / 13
0.00% covered (danger)
0.00%
0 / 1
3.88
 processToken
41.38% covered (danger)
41.38%
48 / 116
0.00% covered (danger)
0.00%
0 / 1
397.35
 handleDeletedStartTag
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
90
 insertPlaceholderMeta
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
56
 process
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 processChunkily
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 finalize
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasAfe
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
1<?php
2declare( strict_types = 1 );
3
4/**
5 * Front-end/Wrapper for a particular tree builder, in this case the
6 * parser/tree builder from RemexHtml.  Feed it tokens  and it will build
7 * you a DOM tree and emit an event.
8 */
9
10namespace Wikimedia\Parsoid\Wt2Html\TreeBuilder;
11
12use Generator;
13use Wikimedia\Assert\Assert;
14use Wikimedia\Parsoid\Config\Env;
15use Wikimedia\Parsoid\Core\DOMCompat;
16use Wikimedia\Parsoid\DOM\DocumentFragment;
17use Wikimedia\Parsoid\DOM\Element;
18use Wikimedia\Parsoid\DOM\Node;
19use Wikimedia\Parsoid\NodeData\DataMw;
20use Wikimedia\Parsoid\NodeData\DataParsoid;
21use Wikimedia\Parsoid\NodeData\NodeData;
22use Wikimedia\Parsoid\NodeData\TempData;
23use Wikimedia\Parsoid\Tokens\CommentTk;
24use Wikimedia\Parsoid\Tokens\CompoundTk;
25use Wikimedia\Parsoid\Tokens\EndTagTk;
26use Wikimedia\Parsoid\Tokens\EOFTk;
27use Wikimedia\Parsoid\Tokens\NlTk;
28use Wikimedia\Parsoid\Tokens\SelfclosingTagTk;
29use Wikimedia\Parsoid\Tokens\TagTk;
30use Wikimedia\Parsoid\Tokens\Token;
31use Wikimedia\Parsoid\Utils\DOMDataUtils;
32use Wikimedia\Parsoid\Utils\DOMUtils;
33use Wikimedia\Parsoid\Utils\PHPUtils;
34use Wikimedia\Parsoid\Utils\TokenUtils;
35use Wikimedia\Parsoid\Utils\Utils;
36use Wikimedia\Parsoid\Utils\WTUtils;
37use Wikimedia\Parsoid\Wt2Html\PipelineStage;
38use Wikimedia\RemexHtml\TreeBuilder\Marker;
39
40class TreeBuilderStage extends PipelineStage {
41    /** @var int */
42    private $tagId;
43
44    /** @var bool */
45    private $inTransclusion;
46
47    /** @var int */
48    private $tableDepth;
49
50    /** @var RemexPipeline */
51    private $remexPipeline;
52
53    /** @var string|Token|null */
54    private $lastToken;
55
56    /** @var string */
57    private $textContentBuffer = '';
58
59    public function __construct( Env $env, array $options = [], string $stageId = "" ) {
60        parent::__construct( $env );
61
62        // Reset variable state and set up the parser
63        $this->resetState( [] );
64    }
65
66    /**
67     * @inheritDoc
68     */
69    public function resetState( array $options ): void {
70        parent::resetState( $options );
71
72        // Reset vars
73        $this->tagId = 1; // Assigned to start/self-closing tags
74        $this->inTransclusion = false;
75
76        /* --------------------------------------------------------------------
77         * Crude tracking of whether we are in a table
78         *
79         * The only requirement for correctness of detecting fostering content
80         * is that as long as there is an unclosed <table> tag, this value
81         * is positive.
82         *
83         * We can ensure that by making sure that independent of how many
84         * excess </table> tags we run into, this value is never negative.
85         *
86         * So, since this.tableDepth >= 0 always, whenever a <table> tag is seen,
87         * this.tableDepth >= 1 always, and our requirement is met.
88         * -------------------------------------------------------------------- */
89        $this->tableDepth = 0;
90
91        $this->remexPipeline = $this->env->fetchRemexPipeline( $this->toFragment );
92        $this->textContentBuffer = '';
93        $this->lastToken = null;
94    }
95
96    /**
97     * Process a chunk of tokens and feed it to the HTML5 tree builder.
98     * This doesn't return anything.
99     *
100     * @param array $tokens Array of tokens to process
101     */
102    public function processChunk( array $tokens ): void {
103        $s = null;
104        $profile = null;
105        if ( $this->env->profiling() ) {
106            $profile = $this->env->getCurrentProfile();
107            $s = hrtime( true );
108        }
109        $n = count( $tokens );
110        $i = 0;
111        while ( $i < $n ) {
112            $token = $tokens[$i];
113            // if there are exactly two newlines directly after the paragraph end, and if we have active
114            // formatting elements, we process one of the new lines inside the paragraph (before the EndTk)
115            // rather than after (T368720)
116            $nlIndex = $i + 1;
117            if ( $token instanceof EndTagTk && $token->getName() === 'p' && $this->hasAfe() ) {
118                while ( $nlIndex < $n && $tokens[$nlIndex] instanceof NlTk ) {
119                    $nlIndex++;
120                }
121            }
122            if ( $nlIndex === $i + 3 ) {
123                $this->processToken( $tokens[$i + 1] );
124                $this->processToken( $tokens[$i + 2] );
125                $this->processToken( $token );
126                $i += 3;
127            } else {
128                $this->processToken( $token );
129                $i += 1;
130            }
131        }
132
133        if ( $profile ) {
134            $profile->bumpTimeUse(
135                'HTML5 TreeBuilder', hrtime( true ) - $s, 'HTML5' );
136        }
137    }
138
139    /**
140     * @return DocumentFragment|Element
141     */
142    public function finalizeDOM(): Node {
143        Assert::invariant( $this->lastToken instanceof EOFTk, 'EOFTk was lost!' );
144
145        if ( $this->toFragment ) {
146            $node = $this->remexPipeline->documentFragment;
147        } else {
148            // Migrate remex children to top level document element
149            DOMUtils::migrateChildren(
150                $this->remexPipeline->documentFragment,
151                DOMCompat::getBody( $this->env->getTopLevelDoc() )
152            );
153            $node = DOMCompat::getBody( $this->env->getTopLevelDoc() );
154        }
155
156        return $node;
157    }
158
159    private function kvArrToAttr( array $kvArr ): array {
160        $attribs = [];
161        foreach ( $kvArr as $kv ) {
162            $attribs[$kv->k] = $kv->v;
163
164        }
165        return $attribs;
166    }
167
168    /**
169     * Keep this in sync with `DOMDataUtils.setNodeData()`
170     *
171     * @param array<string,string> $attribs
172     * @param DataParsoid $dataParsoid
173     * @param ?DataMw $dataMw
174     * @return array<string,string>
175     */
176    private function stashDataAttribs( array $attribs, DataParsoid $dataParsoid, ?DataMw $dataMw ): array {
177        $data = new NodeData;
178        $data->parsoid = $dataParsoid;
179        if ( $dataMw !== null ) {
180            $data->mw = $dataMw;
181        }
182        if ( isset( $dataParsoid->tmp->variantData ) ) {
183            $codec = DOMDataUtils::getCodec( $this->env->getTopLevelDoc() );
184            DOMDataUtils::setAttributeObjectNodeData(
185                $data, $codec, 'data-mw-variant', $dataParsoid->tmp->variantData
186            );
187            unset( $dataParsoid->tmp->variantData );
188        }
189        // Store in the top level doc since we'll be importing the nodes after treebuilding
190        $nodeId = DOMDataUtils::stashObjectInDoc( $this->env->getTopLevelDoc(), $data );
191        $attribs[DOMDataUtils::DATA_OBJECT_ATTR_NAME] = (string)$nodeId;
192        return $attribs;
193    }
194
195    /**
196     * Adapt the token format to internal HTML tree builder format, call the actual
197     * html tree builder by emitting the token.
198     *
199     * @param Token|string $token
200     */
201    public function processToken( $token ): void {
202        $this->env->trace( 'html', $this->pipelineId, $token );
203
204        if ( $this->pipelineId === 0 ) {
205            if ( $this->env->bumpWt2HtmlResourceUse( 'token' ) === false ) {
206                // `false` indicates that this bump pushed us over the threshold
207                // We don't want to log every token above that, which would be `null`
208                $this->env->log( 'warn', "wt2html: token limit exceeded" );
209            }
210        }
211
212        $dispatcher = $this->remexPipeline->dispatcher;
213        $attribs = !is_string( $token ) && $token->attribs !== null ? $this->kvArrToAttr( $token->attribs ) : [];
214        $dataParsoid = !is_string( $token ) ? $token->dataParsoid : new DataParsoid;
215        $dataMw = $token->dataMw ?? null;
216        $tmp = $dataParsoid->getTemp();
217
218        if ( $this->inTransclusion ) {
219            $tmp->setFlag( TempData::IN_TRANSCLUSION );
220        }
221
222        // Assign tagId to open/self-closing tags
223        if ( $token instanceof TagTk || $token instanceof SelfclosingTagTk ) {
224            $tmp->tagId = $this->tagId++;
225        }
226
227        // Store the last token
228        $this->lastToken = $token;
229
230        $isString = is_string( $token ) || $token instanceof NlTk;
231        if ( !$isString && $this->textContentBuffer !== '' ) {
232            // Finalize the combined string tokens
233            $dispatcher->characters( $this->textContentBuffer, 0, strlen( $this->textContentBuffer ), 0, 0 );
234
235            // If inside a table and a transclusion, add a meta tag after every
236            // text node so that we can detect fostered content that came from
237            // a transclusion.
238            if ( $this->inTransclusion && $this->tableDepth > 0 ) {
239                // The HTML spec says, "Space characters separated from non-space
240                // characters by non-character tokens are not affected by foster
241                // parenting"
242                if ( !preg_match( '/^\s*$/D', $this->textContentBuffer ) ) {
243                    $this->env->log(
244                        'debug/html', $this->pipelineId,
245                        'Inserting shadow transclusion meta'
246                    );
247                    $this->remexPipeline->insertExplicitStartTag(
248                        'meta', [ 'typeof' => 'mw:TransclusionShadow' ], true
249                    );
250                }
251            }
252
253            $this->textContentBuffer = '';
254        }
255
256        if ( $isString ) {
257            $data = $token instanceof NlTk ? "\n" : $token;
258            // Combine string tokens to be finalized later
259            $this->textContentBuffer .= $data;
260        } elseif ( $token instanceof CompoundTk ) {
261            $this->env->trace( 'html', $this->pipelineId, "---- START NESTED TOKENS ----" );
262            $this->processChunk( $token->getNestedTokens() );
263            $this->env->trace( 'html', $this->pipelineId, "---- END NESTED TOKENS ----" );
264        } elseif ( $token instanceof TagTk ) {
265            $tName = $token->getName();
266            if ( $tName === 'table' ) {
267                $this->tableDepth++;
268                // Don't add foster box in transclusion
269                // Avoids unnecessary insertions, the case where a table
270                // doesn't have tsr info, and the messy unbalanced table case,
271                // like the navbox
272                if ( !$this->inTransclusion ) {
273                    $this->env->log( 'debug/html', $this->pipelineId, 'Inserting foster box meta' );
274                    $this->remexPipeline->insertImplicitStartTag(
275                        'table',
276                        [ 'typeof' => 'mw:FosterBox' ]
277                    );
278                }
279            }
280
281            $node = $this->remexPipeline->insertExplicitStartTag(
282                $tName,
283                $this->stashDataAttribs( $attribs, $dataParsoid, $dataMw ),
284                false
285            );
286            if ( !$node ) {
287                $this->handleDeletedStartTag( $tName, $dataParsoid );
288            }
289        } elseif ( $token instanceof SelfclosingTagTk ) {
290            $tName = $token->getName();
291
292            $wasInserted = false;
293
294            // Transclusion metas are placeholders and are eliminated after template-wrapping.
295            // Fostering them unnecessarily expands template ranges. Same for mw:Param metas.
296            if ( $tName === 'meta' ) {
297                $shouldNotFoster = TokenUtils::matchTypeOf(
298                    $token,
299                    '#^mw:(Transclusion|Param)(/|$)#'
300                );
301                if ( $shouldNotFoster ) {
302                    // transclusions state
303                    $transType = TokenUtils::matchTypeOf( $token, '#^mw:Transclusion#' );
304                    if ( $transType ) {
305                        // typeof starts with mw:Transclusion
306                        $this->inTransclusion = ( $transType === 'mw:Transclusion' );
307                    }
308                    $this->remexPipeline->insertUnfosteredMeta(
309                        $this->stashDataAttribs( $attribs, $dataParsoid, $dataMw ) );
310                    $wasInserted = true;
311                }
312            }
313
314            if ( !$wasInserted ) {
315                $node = $this->remexPipeline->insertExplicitStartTag(
316                    $tName,
317                    $this->stashDataAttribs( $attribs, $dataParsoid, $dataMw ),
318                    false
319                );
320                if ( $node ) {
321                    if ( !Utils::isVoidElement( $tName ) ) {
322                        $this->remexPipeline->insertExplicitEndTag(
323                            $tName, ( $dataParsoid->stx ?? '' ) === 'html' );
324                    }
325                } else {
326                    $this->insertPlaceholderMeta( $tName, $dataParsoid, true );
327                }
328            }
329        } elseif ( $token instanceof EndTagTk ) {
330            $tName = $token->getName();
331            if ( $tName === 'table' && $this->tableDepth > 0 ) {
332                $this->tableDepth--;
333            }
334            $node = $this->remexPipeline->insertExplicitEndTag(
335                $tName,
336                ( $dataParsoid->stx ?? '' ) === 'html'
337            );
338            if ( $node ) {
339                // Copy data attribs from the end tag to the element
340                $nodeDP = DOMDataUtils::getDataParsoid( $node );
341                if ( !WTUtils::hasLiteralHTMLMarker( $nodeDP )
342                    && isset( $dataParsoid->endTagSrc )
343                ) {
344                    $nodeDP->endTagSrc = $dataParsoid->endTagSrc;
345                }
346                if ( !empty( $dataParsoid->stx ) ) {
347                    // FIXME: Not sure why we do this. For example,
348                    // with "{|\n|x\n</table>", why should the entire table
349                    // be marked HTML syntax? This is probably entirely
350                    // 2013-era historical stuff. Investigate & fix.
351                    //
352                    // Same behavior with '''foo</b>
353                    //
354                    // Transfer stx flag
355                    $nodeDP->stx = $dataParsoid->stx;
356                }
357                if ( isset( $dataParsoid->tsr ) ) {
358                    $nodeDP->getTemp()->endTSR = $dataParsoid->tsr;
359                }
360                if ( isset( $nodeDP->autoInsertedStartToken ) ) {
361                    $nodeDP->autoInsertedStart = true;
362                    unset( $nodeDP->autoInsertedStartToken );
363                }
364                if ( isset( $nodeDP->autoInsertedEndToken ) ) {
365                    $nodeDP->autoInsertedEnd = true;
366                    unset( $nodeDP->autoInsertedEndToken );
367                }
368            } else {
369                // The tag was stripped. Insert an mw:Placeholder for round-tripping
370                $this->insertPlaceholderMeta( $tName, $dataParsoid, false );
371            }
372        } elseif ( $token instanceof CommentTk ) {
373            $dp = $token->dataParsoid;
374            if ( isset( $dp->unclosedComment ) ) {
375                // Add a marker meta tag to aid accurate DSR computation
376                $attribs = [ 'typeof' => 'mw:Placeholder/UnclosedComment' ];
377                $this->remexPipeline->insertUnfosteredMeta(
378                    $this->stashDataAttribs( $attribs, $dp, $token->dataMw ) );
379            }
380            $dispatcher->comment( $token->value, 0, 0 );
381        } elseif ( $token instanceof EOFTk ) {
382            $dispatcher->endDocument( 0 );
383        } else {
384            $errors = [
385                '-------- Unhandled token ---------',
386                'TYPE: ' . $token->getType(),
387                'VAL : ' . PHPUtils::jsonEncode( $token )
388            ];
389            $this->env->log( 'error', implode( "\n", $errors ) );
390        }
391    }
392
393    /**
394     * Insert td/tr/th tag source or a placeholder meta
395     *
396     * @param string $name
397     * @param DataParsoid $dp
398     */
399    private function handleDeletedStartTag( string $name, DataParsoid $dp ): void {
400        if ( ( $dp->stx ?? null ) !== 'html' &&
401            ( $name === 'td' || $name === 'tr' || $name === 'th' )
402        ) {
403            // A stripped wikitext-syntax table tag outside of a table.
404            // Re-insert the original page source.
405            if ( !empty( $dp->tsr ) &&
406                $dp->tsr->start !== null && $dp->tsr->end !== null
407            ) {
408                $origTxt = $dp->tsr->substr( $this->frame->getSource() );
409            } else {
410                $origTxt = match ( $name ) {
411                    'td' => '|',
412                    'tr' => '|-',
413                    'th' => '!',
414                    default => ''
415                };
416            }
417            if ( $origTxt !== '' ) {
418                $this->remexPipeline->dispatcher->characters( $origTxt, 0, strlen( $origTxt ), 0,
419                    0 );
420            }
421        } else {
422            $this->insertPlaceholderMeta( $name, $dp, true );
423        }
424    }
425
426    /**
427     * Insert a placeholder meta for a deleted start or end tag
428     *
429     * @param string $name
430     * @param DataParsoid $dp
431     * @param bool $isStart
432     */
433    private function insertPlaceholderMeta(
434        string $name, DataParsoid $dp, bool $isStart
435    ): void {
436        // If node is in a position where the placeholder node will get fostered
437        // out, don't bother adding one since the browser and other compliant
438        // clients will move the placeholder out of the table.
439        if ( $this->remexPipeline->isFosterablePosition() ) {
440            return;
441        }
442
443        $src = $dp->src ?? null;
444
445        if ( !$src ) {
446            if ( !empty( $dp->tsr ) ) {
447                $src = $dp->tsr->substr( $this->frame->getSource() );
448            } elseif ( WTUtils::hasLiteralHTMLMarker( $dp ) ) {
449                if ( $isStart ) {
450                    $src = '<' . $name . '>';
451                } else {
452                    $src = '</' . $name . '>';
453                }
454            }
455        }
456
457        if ( $src ) {
458            $metaDP = new DataParsoid;
459            $metaDP->src = $src;
460            $metaDP->name = $name;
461            $this->remexPipeline->insertUnfosteredMeta(
462                $this->stashDataAttribs(
463                    [ 'typeof' => 'mw:Placeholder/StrippedTag' ],
464                    $metaDP, null
465                )
466            );
467        }
468    }
469
470    /**
471     * @inheritDoc
472     */
473    public function process(
474        string|array|DocumentFragment|Element $input,
475        array $options
476    ): array|Element|DocumentFragment {
477        Assert::invariant( is_array( $input ), "Input should be an array" );
478        '@phan-var array $input'; // @var array $input
479        $this->processChunk( $input );
480        return $this->finalizeDOM();
481    }
482
483    /**
484     * @inheritDoc
485     */
486    public function processChunkily(
487        string|array|DocumentFragment|Element $input,
488        array $options
489    ): Generator {
490        '@phan-var array $input'; // @var array $chunk
491        $this->processChunk( $input );
492        yield [];
493    }
494
495    /**
496     * @inheritDoc
497     */
498    public function finalize(): Generator {
499        yield $this->finalizeDOM();
500    }
501
502    private function hasAfe(): bool {
503        $afe = $this->remexPipeline->treeBuilder->afe->getTail();
504        while ( $afe !== null && $afe instanceof Marker ) {
505            $afe = $afe->prevAFE;
506        }
507        return $afe !== null;
508    }
509}