Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 229
0.00% covered (danger)
0.00%
0 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
ListHandler
0.00% covered (danger)
0.00%
0 / 229
0.00% covered (danger)
0.00%
0 / 16
6162
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getListFrame
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 generateImpliedEndTags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 reset
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 onTag
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 onCompoundTk
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 onNewline
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 onAny
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
650
 popListFrame
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 onEnd
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 closeLists
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 onListItem
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 commonPrefixLength
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 isDtDd
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 makeDP
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 doListItem
0.00% covered (danger)
0.00%
0 / 84
0.00% covered (danger)
0.00%
0 / 1
342
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Wt2Html\TT;
5
6use Wikimedia\Assert\UnreachableException;
7use Wikimedia\Parsoid\Core\SourceRange;
8use Wikimedia\Parsoid\NodeData\DataParsoid;
9use Wikimedia\Parsoid\Tokens\CompoundTk;
10use Wikimedia\Parsoid\Tokens\EmptyLineTk;
11use Wikimedia\Parsoid\Tokens\EndTagTk;
12use Wikimedia\Parsoid\Tokens\EOFTk;
13use Wikimedia\Parsoid\Tokens\IndentPreTk;
14use Wikimedia\Parsoid\Tokens\TagTk;
15use Wikimedia\Parsoid\Tokens\Token;
16use Wikimedia\Parsoid\Tokens\XMLTagTk;
17use Wikimedia\Parsoid\Utils\PHPUtils;
18use Wikimedia\Parsoid\Utils\TokenUtils;
19use Wikimedia\Parsoid\Wt2Html\TokenHandlerPipeline;
20
21/**
22 * Create list tag around list items and map wiki bullet levels to html.
23 */
24class ListHandler extends LineBasedHandler {
25    /**
26     * Debug string output of bullet character mappings.
27     * @var array<string,array<string,string>>
28     */
29    private static $bullet_chars_map = [
30        '*' => [ 'list' => 'ul', 'item' => 'li' ],
31        '#' => [ 'list' => 'ol', 'item' => 'li' ],
32        ';' => [ 'list' => 'dl', 'item' => 'dt' ],
33        ':' => [ 'list' => 'dl', 'item' => 'dd' ]
34    ];
35
36    /** @var array<ListFrame> */
37    private array $listFrameStack;
38    private int $nestedTableCount;
39    private bool $inT2529Mode = false;
40
41    public function __construct( TokenHandlerPipeline $manager, array $options ) {
42        parent::__construct( $manager, $options );
43        $this->reset();
44    }
45
46    private bool $haveActiveListFrame = false;
47
48    private function getListFrame(): ListFrame {
49        return $this->listFrameStack[count( $this->listFrameStack ) - 1];
50    }
51
52    /**
53     * The HTML5 parsing spec says that when encountering a closing tag for a
54     * certain set of open tags we should generate implied ends to list items,
55     * https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inbody:generate-implied-end-tags-5
56     *
57     * So, in order to roundtrip accurately, we should follow suit.  However,
58     * we choose an ostensible superset of those tags, our wikitext blocks, to
59     * have this behaviour.  Hopefully the differences aren't relevant.
60     *
61     * @param string $tagName
62     * @return bool
63     */
64    private function generateImpliedEndTags( string $tagName ): bool {
65        return TokenUtils::isWikitextBlockTag( $tagName );
66    }
67
68    /**
69     * Resets the list handler
70     */
71    private function reset(): void {
72        $this->listFrameStack = [];
73        $this->onAnyEnabled = false;
74        $this->nestedTableCount = 0;
75        $this->haveActiveListFrame = false;
76    }
77
78    /**
79     * @inheritDoc
80     */
81    public function onTag( XMLTagTk $token ): ?array {
82        if ( $token->getName() === 'listItem' ) {
83            '@phan-var TagTk $token'; // @var TagTk $token
84            return $this->onListItem( $token );
85        } else {
86            return null;
87        }
88    }
89
90    /**
91     * @inheritDoc
92     */
93    public function onCompoundTk( CompoundTk $ctk, TokenHandler $tokensHandler ): ?array {
94        if ( $ctk instanceof EmptyLineTk || $ctk instanceof IndentPreTk ) {
95            // Nothing to do!
96            // IndentPre content / empty lines are of no interest to us
97            return null;
98        } else {
99            throw new UnreachableException(
100                "ListHandler: Unsupported compound token."
101            );
102        }
103    }
104
105    /**
106     * @inheritDoc
107     */
108    public function onNewline( $token ): ?array {
109        $this->env->trace( 'list', $this->pipelineId, 'NEWLINE:', $token );
110
111        if ( !$this->onAnyEnabled ) {
112            return null;
113        }
114
115        // onAny handler is only active when there's at least one list frame
116        // on the stack, even if it is not active
117        $listFrame = $this->getListFrame();
118
119        if ( !$this->haveActiveListFrame ) {
120            $listFrame->listTk->addToken( $token );
121            $this->env->trace( 'list', $this->pipelineId, 'RET[LIST]:', $token );
122            return [];
123        }
124
125        if ( $listFrame->atEOL ) {
126            // Non-list item in newline context
127            // ==> close all previous lists and reset frame to null
128            return $this->closeLists( $listFrame, $token );
129        } else {
130            $listFrame->atEOL = true;
131            $listFrame->nlTk = $token;
132            $listFrame->haveDD = false;
133            // php's findColonNoLinks is run in doBlockLevels, which examines
134            // the text line-by-line. At nltk, any open tags will cease having
135            // an effect.
136            $listFrame->numOpenTags = 0;
137            return [];
138        }
139    }
140
141    /**
142     * @inheritDoc
143     */
144    public function onAny( $token ): ?array {
145        $this->env->trace( 'list', $this->pipelineId, 'ANY:', $token );
146
147        if ( $token instanceof Token && TokenUtils::matchTypeOf( $token, '#^mw:Transclusion$#' ) ) {
148            // We are now in T2529 scenario where legacy would have added a newline
149            // if it had encountered a list-start character. So, if we encounter
150            // a listItem token next, we should first execute the same actions
151            // as if we had run onAny on a NlTk (see below).
152            $this->inT2529Mode = true;
153        } elseif ( !TokenUtils::isSolTransparent( $this->env, $token ) ) {
154            $this->inT2529Mode = false;
155        }
156
157        // onAny handler is only active when there's at least one list frame
158        // on the stack, even if it is not active
159        $listFrame = $this->getListFrame();
160
161        if ( !$this->haveActiveListFrame ) {
162            // haveActiveListFrame will be false in the onAny handler only when
163            // we are in a table that in turn was seen in a list context.
164            //
165            // Since we are not in a list within the table, nothing to do.
166            // Just stuff it in the list token.
167            if ( $token instanceof EndTagTk && $token->getName() === 'table' ) {
168                if ( $this->nestedTableCount === 0 ) {
169                    $this->haveActiveListFrame = true;
170                } else {
171                    $this->nestedTableCount--;
172                }
173            } elseif ( $token instanceof TagTk && $token->getName() === 'table' ) {
174                $this->nestedTableCount++;
175            }
176
177            $listFrame->listTk->addToken( $token );
178            $this->env->trace( 'list', $this->pipelineId, 'RET[LIST]:', $token );
179            return [];
180        }
181
182        // Keep track of open tags per list frame in order to prevent colons
183        // starting lists illegally. Php's findColonNoLinks.
184        if ( $token instanceof TagTk
185            // Table tokens will push the frame and remain balanced.
186            // They're safe to ignore in the bookkeeping.
187            && $token->getName() !== 'table'
188        ) {
189            $listFrame->numOpenTags += 1;
190        } elseif ( $token instanceof EndTagTk ) {
191            if ( $listFrame->numOpenTags > 0 ) {
192                $listFrame->numOpenTags -= 1;
193            }
194
195            if ( $token->getName() === 'table' ) {
196                // close all open lists and pop a frame
197                $ret = $this->closeLists( $listFrame, $token );
198                if ( count( $this->listFrameStack ) > 0 ) {
199                    $this->haveActiveListFrame = true;
200                }
201                return $ret;
202            } elseif ( $this->generateImpliedEndTags( $token->getName() ) ) {
203                if ( $listFrame->numOpenBlockTags === 0 ) {
204                    // Unbalanced closing block tag in a list context
205                    // ==> close all previous lists and reset frame to null
206                    return $this->closeLists( $listFrame, $token );
207                } else {
208                    $listFrame->numOpenBlockTags--;
209                    if ( $listFrame->atEOL ) {
210                        // Non-list item in newline context
211                        // ==> close all previous lists and reset frame to null
212                        return $this->closeLists( $listFrame, $token );
213                    } else {
214                        $listFrame->listTk->addToken( $token );
215                        $this->env->trace( 'list', $this->pipelineId, 'RET[LIST]:', $token );
216                        return [];
217                    }
218                }
219            }
220
221        /* Non-block tag -- fall-through to other tests below */
222        }
223
224        if ( $listFrame->atEOL ) {
225            if ( TokenUtils::isSolTransparent( $this->env, $token ) ) {
226                // Hold on to see where the token stream goes from here
227                // - another list item, or
228                // - end of list
229                if ( $listFrame->nlTk ) {
230                    $listFrame->solTokens[] = $listFrame->nlTk;
231                    $listFrame->nlTk = null;
232                }
233                $listFrame->solTokens[] = $token;
234                return [];
235            } else {
236                // Non-list item in newline context
237                // ==> close all previous lists and reset frame to null
238                return $this->closeLists( $listFrame, $token );
239            }
240        }
241
242        if ( $token instanceof TagTk ) {
243            if ( $token->getName() === 'table' ) {
244                $this->haveActiveListFrame = false;
245            } elseif ( $this->generateImpliedEndTags( $token->getName() ) ) {
246                $listFrame->numOpenBlockTags++;
247            }
248        }
249
250        // Nothing else left to do
251        $listFrame->listTk->addToken( $token );
252        $this->env->trace( 'list', $this->pipelineId, 'RET[LIST]:', $token );
253        return [];
254    }
255
256    /**
257     * @param array<string|Token> $ret
258     * @return array<string|Token>
259     */
260    private function popListFrame( array $ret ): array {
261        array_pop( $this->listFrameStack );
262        $this->haveActiveListFrame = false;
263
264        if ( count( $this->listFrameStack ) > 0 ) {
265            $this->getListFrame()->listTk->addTokens( $ret );
266            $this->env->trace( 'list', $this->pipelineId, 'RET[LIST]:', $ret );
267            $ret = [];
268        } else {
269            // Remove onAny transform if we dont have any stashed list frames
270            $this->onAnyEnabled = false;
271            $this->env->trace( 'list', $this->pipelineId, 'RET:', $ret );
272        }
273
274        return $ret;
275    }
276
277    /**
278     * @inheritDoc
279     */
280    public function onEnd( EOFTk $token ): ?array {
281        $this->env->trace( 'list', $this->pipelineId, 'END:', $token );
282
283        if ( $this->haveActiveListFrame ) {
284            // EOF goes at the end outside all compound tokens
285            // So, don't pass in the token into closeLists
286            $toks = $this->closeLists( $this->getListFrame() );
287        } else {
288            // all lists have been closed and nothing to do on that front
289            $toks = [];
290        }
291
292        while ( count( $this->listFrameStack ) > 0 ) {
293            // $toks should be [] here
294            $toks = $this->popListFrame( [ $this->getListFrame()->listTk ] );
295        }
296        $this->reset();
297
298        $toks[] = $token;
299        $this->env->trace( 'list', $this->pipelineId, 'RET: ', $toks );
300        return $toks;
301    }
302
303    /**
304     * Handle close list processing
305     *
306     * @param ListFrame $listFrame
307     * @param Token|string|null $token
308     * @return array<string|Token>
309     */
310    private function closeLists( ListFrame $listFrame, $token = null ): array {
311        $this->env->trace( 'list', $this->pipelineId, '----closing all lists----' );
312
313        // pop all open list item tokens onto $listFrame->listTk
314        $ret = $listFrame->popTags( count( $listFrame->bstack ) );
315        $listFrame->listTk->addTokens( $ret );
316        $this->env->trace( 'list', $this->pipelineId, 'RET[LIST]: ', $ret );
317
318        $ret = [ $listFrame->listTk ];
319        PHPUtils::pushArray( $ret, $listFrame->solTokens );
320        if ( $listFrame->nlTk ) {
321            $ret[] = $listFrame->nlTk;
322        }
323        if ( $token ) {
324            $ret[] = $token;
325        }
326
327        return $this->popListFrame( $ret );
328    }
329
330    /**
331     * Handle a list item
332     * @return ?array<string|Token>
333     */
334    private function onListItem( TagTk $token ): ?array {
335        if ( $this->inT2529Mode ) {
336            // See comment in onAny where this property is set to true
337            // The only relevant change is to 'haveDD'.
338            if ( $this->haveActiveListFrame ) {
339                $this->getListFrame()->haveDD = false;
340            }
341            $this->inT2529Mode = false;
342
343            // 'atEOL' and NlTk changes don't apply.
344            //
345            // This might be a divergence, but, I don't think we should
346            // close open tags here as in the NlTk case. So, this means that
347            // this wikitext ";term: def=foo<b>{{1x|:bar}}</b>"
348            // will generate different output in Parsoid & legacy..
349            // I believe Parsoid's output is better, but we can comply
350            // if we see a real regression for this.
351        }
352
353        $this->onAnyEnabled = true;
354        $bullets = $token->getAttributeV( 'bullets' );
355        if ( $this->haveActiveListFrame ) {
356            $listFrame = $this->getListFrame();
357            // Ignoring colons inside tags to prevent illegal overlapping.
358            // Attempts to mimic findColonNoLinks in the php parser.
359            if (
360                PHPUtils::lastItem( $bullets ) === ':' &&
361                ( $listFrame->haveDD || $listFrame->numOpenTags > 0 )
362            ) {
363                $this->env->trace( 'list', $this->pipelineId, 'ANY:', $token );
364                $this->env->trace( 'list', $this->pipelineId, 'RET[LIST]: ', ':' );
365                $listFrame->listTk->addToken( ':' );
366                return [];
367            }
368        } else {
369            $listFrame = new ListFrame;
370            $this->listFrameStack[] = $listFrame;
371            $this->haveActiveListFrame = true;
372        }
373        // convert listItem to list and list item tokens
374        return $this->doListItem( $listFrame, $bullets, $token );
375    }
376
377    /**
378     * Determine the minimum common prefix length
379     */
380    private function commonPrefixLength( array $x, array $y ): int {
381        $minLength = min( count( $x ), count( $y ) );
382        $i = 0;
383        for ( ; $i < $minLength; $i++ ) {
384            if ( $x[$i] !== $y[$i] ) {
385                break;
386            }
387        }
388        return $i;
389    }
390
391    /** Check for Dt Dd sequence */
392    private static function isDtDd( string $a, string $b ): bool {
393        $ab = [ $a, $b ];
394        sort( $ab );
395        return ( $ab[0] === ':' && $ab[1] === ';' );
396    }
397
398    /** Make a new DP that is a slice of a source DP */
399    private function makeDP( DataParsoid $sourceDP, int $startOffset, int $endOffset ): DataParsoid {
400        $newDP = clone $sourceDP;
401        $tsr = $sourceDP->tsr ?? null;
402        if ( $tsr ) {
403            $newDP->tsr = new SourceRange( $tsr->start + $startOffset, $tsr->start + $endOffset, $tsr->source );
404        }
405        return $newDP;
406    }
407
408    /**
409     * Handle list item processing
410     *
411     * @param ListFrame $listFrame
412     * @param array $bn
413     * @param Token $token
414     * @return array<string|Token>
415     */
416    private function doListItem( ListFrame $listFrame, array $bn, Token $token ): array {
417        $this->env->trace( 'list', $this->pipelineId, 'BEGIN:', $token );
418
419        $bs = $listFrame->bstack;
420        $prefixLen = $this->commonPrefixLength( $bs, $bn );
421        $prefix = array_slice( $bn, 0, $prefixLen/*CHECK THIS*/ );
422        $tokenDP = $token->dataParsoid;
423        $listFrame->bstack = $bn;
424
425        // emit close tag tokens for closed lists
426        $this->env->trace(
427            'list', $this->pipelineId,
428            static function () use ( $bs, $bn ) {
429                return '    bs: ' . PHPUtils::jsonEncode( $bs ) . '; bn: ' . PHPUtils::jsonEncode( $bn );
430            }
431        );
432
433        if ( count( $prefix ) === count( $bs ) && count( $bn ) === count( $bs ) ) {
434            $this->env->trace( 'list', $this->pipelineId, '    -> no nesting change' );
435
436            // same list item types and same nesting level
437            $itemToken = array_pop( $listFrame->endtags );
438            $listFrame->endtags[] = new EndTagTk( $itemToken->getName() );
439            $res = array_merge( [ $itemToken ],
440                $listFrame->solTokens,
441                [
442                    // this list item gets all the bullets since this is
443                    // a list item at the same level
444                    //
445                    // **a
446                    // **b
447                    $listFrame->nlTk ?: '',
448                    new TagTk( $itemToken->getName(), [], $this->makeDP( $tokenDP, 0, count( $bn ) ) )
449                ]
450            );
451        } else {
452            $prefixCorrection = 0;
453            $tokens = [];
454            if ( count( $bs ) > $prefixLen
455                && count( $bn ) > $prefixLen
456                && self::isDtDd( $bs[$prefixLen], $bn[$prefixLen] ) ) {
457                /* ------------------------------------------------
458                 * Handle dd/dt transitions
459                 *
460                 * Example:
461                 *
462                 * **;:: foo
463                 * **::: bar
464                 *
465                 * the 3rd bullet is the dt-dd transition
466                 * ------------------------------------------------ */
467
468                $tokens = $listFrame->popTags( count( $bs ) - $prefixLen - 1 );
469                $tokens = array_merge( $listFrame->solTokens, $tokens );
470                $newName = self::$bullet_chars_map[$bn[$prefixLen]]['item'];
471                $endTag = array_pop( $listFrame->endtags );
472                if ( $newName === 'dd' ) {
473                    $listFrame->haveDD = true;
474                } elseif ( $newName === 'dt' ) {
475                    $listFrame->haveDD = false; // reset
476                }
477                $listFrame->endtags[] = new EndTagTk( $newName );
478
479                if ( isset( $tokenDP->stx ) && $tokenDP->stx === 'row' ) {
480                    // stx='row' is only set for single-line dt-dd lists (see tokenizer)
481                    // In this scenario, the dd token we are building a token for has no prefix
482                    // Ex: ;a:b, *;a:b, #**;a:b, etc. Compare with *;a\n*:b, #**;a\n#**:b
483                    $this->env->trace( 'list', $this->pipelineId,
484                        '    -> single-line dt->dd transition' );
485                    $newTag = new TagTk( $newName, [], $this->makeDP( $tokenDP, 0, 1 ) );
486                } else {
487                    $this->env->trace( 'list', $this->pipelineId, '    -> other dt/dd transition' );
488                    $newTag = new TagTk( $newName, [], $this->makeDP( $tokenDP, 0, $prefixLen + 1 ) );
489                }
490
491                $tokens[] = $endTag;
492                $tokens[] = $listFrame->nlTk ?: '';
493                $tokens[] = $newTag;
494
495                $prefixCorrection = 1;
496            } else {
497                $this->env->trace( 'list', $this->pipelineId, '    -> reduced nesting' );
498                $tokens = array_merge(
499                    $listFrame->solTokens,
500                    $tokens,
501                    $listFrame->popTags( count( $bs ) - $prefixLen )
502                );
503                if ( $listFrame->nlTk ) {
504                    $tokens[] = $listFrame->nlTk;
505                }
506                if ( $prefixLen > 0 && count( $bn ) === $prefixLen ) {
507                    $itemToken = array_pop( $listFrame->endtags );
508                    $tokens[] = $itemToken;
509                    // this list item gets all bullets upto the shared prefix
510                    $tokens[] = new TagTk( $itemToken->getName(), [], $this->makeDP( $tokenDP, 0, count( $bn ) ) );
511                    $listFrame->endtags[] = new EndTagTk( $itemToken->getName() );
512                }
513            }
514
515            for ( $i = $prefixLen + $prefixCorrection; $i < count( $bn ); $i++ ) {
516                if ( !self::$bullet_chars_map[$bn[$i]] ) {
517                    throw new \InvalidArgumentException( 'Unknown node prefix ' . $prefix[$i] );
518                }
519
520                // Each list item in the chain gets one bullet.
521                // However, the first item also includes the shared prefix.
522                //
523                // Example:
524                //
525                // **a
526                // ****b
527                //
528                // Yields:
529                //
530                // <ul><li-*>
531                // <ul><li-*>a
532                // <ul><li-FIRST-ONE-gets-***>
533                // <ul><li-*>b</li></ul>
534                // </li></ul>
535                // </li></ul>
536                // </li></ul>
537                //
538                // Unless prefixCorrection is > 0, in which case we've
539                // already accounted for the initial bullets.
540                //
541                // prefixCorrection is for handling dl-dts like this
542                //
543                // ;a:b
544                // ;;c:d
545                //
546                // ";c:d" is embedded within a dt that is 1 char wide(;)
547
548                if ( $i === $prefixLen ) {
549                    $this->env->trace( 'list', $this->pipelineId,
550                        '    -> increased nesting: first'
551                    );
552                    $listDP = $this->makeDP( $tokenDP, 0, 0 );
553                    $listItemDP = $this->makeDP( $tokenDP, 0, $i + 1 );
554                } else {
555                    $this->env->trace( 'list', $this->pipelineId,
556                        '    -> increased nesting: 2nd and higher'
557                    );
558                    $listDP = $this->makeDP( $tokenDP, $i, $i );
559                    $listItemDP = $this->makeDP( $tokenDP, $i, $i + 1 );
560                }
561
562                PHPUtils::pushArray( $tokens, $listFrame->pushList(
563                    self::$bullet_chars_map[$bn[$i]], $listDP, $listItemDP
564                ) );
565            }
566            $res = $tokens;
567        }
568
569        // clear out sol-tokens
570        $listFrame->solTokens = [];
571        $listFrame->nlTk = null;
572        $listFrame->atEOL = false;
573        $listFrame->listTk->addTokens( $res );
574
575        $this->env->trace( 'list', $this->pipelineId, 'RET[LIST]:', $res );
576        return [];
577    }
578}