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