Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 231
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 / 231
0.00% covered (danger)
0.00%
0 / 14
5256
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 / 56
0.00% covered (danger)
0.00%
0 / 1
600
 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 / 16
0.00% covered (danger)
0.00%
0 / 1
42
 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\TokenTransformManager;
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    /** @var ?ListFrame */
24    private $currListFrame;
25    /** @var int */
26    private $nestedTableCount;
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 TokenTransformManager $manager manager environment
56     * @param array $options options
57     */
58    public function __construct( TokenTransformManager $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 ( !$this->currListFrame ) {
97            // this.currListFrame will be null only when we are in a table
98            // that in turn was seen in a list context.
99            //
100            // Since we are not in a list within the table, nothing to do.
101            // Just send the token back unchanged.
102            if ( $token instanceof EndTagTk && $token->getName() === 'table' ) {
103                if ( $this->nestedTableCount === 0 ) {
104                    $this->currListFrame = array_pop( $this->listFrames );
105                } else {
106                    $this->nestedTableCount--;
107                }
108            } elseif ( $token instanceof TagTk && $token->getName() === 'table' ) {
109                $this->nestedTableCount++;
110            }
111
112            $this->env->log( 'trace/list', $this->pipelineId, 'RET: ', $token );
113            return null;
114        }
115
116        // Keep track of open tags per list frame in order to prevent colons
117        // starting lists illegally. Php's findColonNoLinks.
118        if ( $token instanceof TagTk
119            // Table tokens will push the frame and remain balanced.
120            // They're safe to ignore in the bookkeeping.
121            && $token->getName() !== 'table' ) {
122            $this->currListFrame->numOpenTags += 1;
123        } elseif ( $token instanceof EndTagTk && $this->currListFrame->numOpenTags > 0 ) {
124            $this->currListFrame->numOpenTags -= 1;
125        }
126
127        if ( $token instanceof EndTagTk ) {
128            if ( $token->getName() === 'table' ) {
129                // close all open lists and pop a frame
130                $ret = $this->closeLists( $token );
131                $this->currListFrame = array_pop( $this->listFrames );
132                return new TokenHandlerResult( $ret );
133            } elseif ( self::generateImpliedEndTags( $token->getName() ) ) {
134                if ( $this->currListFrame->numOpenBlockTags === 0 ) {
135                    // Unbalanced closing block tag in a list context ==> close all previous lists
136                    return new TokenHandlerResult( $this->closeLists( $token ) );
137                } else {
138                    $this->currListFrame->numOpenBlockTags--;
139                    if ( $this->currListFrame->atEOL ) {
140                        // Non-list item in newline context ==> close all previous lists
141                        return new TokenHandlerResult( $this->closeLists( $token ) );
142                    } else {
143                        $this->env->log( 'trace/list', $this->pipelineId, 'RET: ', $token );
144                        return null;
145                    }
146                }
147            }
148
149            /* Non-block tag -- fall-through to other tests below */
150        }
151
152        if ( $this->currListFrame->atEOL ) {
153            if ( !$token instanceof NlTk && TokenUtils::isSolTransparent( $this->env, $token ) ) {
154                // Hold on to see where the token stream goes from here
155                // - another list item, or
156                // - end of list
157                if ( $this->currListFrame->nlTk ) {
158                    $this->currListFrame->solTokens[] = $this->currListFrame->nlTk;
159                    $this->currListFrame->nlTk = null;
160                }
161                $this->currListFrame->solTokens[] = $token;
162                return new TokenHandlerResult( [] );
163            } else {
164                // Non-list item in newline context ==> close all previous lists
165                return new TokenHandlerResult( $this->closeLists( $token ) );
166            }
167        }
168
169        if ( $token instanceof NlTk ) {
170            $this->currListFrame->atEOL = true;
171            $this->currListFrame->nlTk = $token;
172            $this->currListFrame->haveDD = false;
173            // php's findColonNoLinks is run in doBlockLevels, which examines
174            // the text line-by-line. At nltk, any open tags will cease having
175            // an effect.
176            $this->currListFrame->numOpenTags = 0;
177            return new TokenHandlerResult( [] );
178        }
179
180        if ( $token instanceof TagTk ) {
181            if ( $token->getName() === 'table' ) {
182                $this->listFrames[] = $this->currListFrame;
183                $this->resetCurrListFrame();
184            } elseif ( self::generateImpliedEndTags( $token->getName() ) ) {
185                $this->currListFrame->numOpenBlockTags++;
186            }
187            $this->env->log( 'trace/list', $this->pipelineId, 'RET: ', $token );
188            return null;
189        }
190
191        // Nothing else left to do
192        $this->env->log( 'trace/list', $this->pipelineId, 'RET: ', $token );
193        return null;
194    }
195
196    /**
197     * @inheritDoc
198     */
199    public function onEnd( EOFTk $token ): ?TokenHandlerResult {
200        $this->env->log( 'trace/list', $this->pipelineId,
201            'END:', static function () use ( $token ) { return PHPUtils::jsonEncode( $token );
202            } );
203
204        $this->listFrames = [];
205        if ( !$this->currListFrame ) {
206            // init here so we dont have to have a check in closeLists
207            // That way, if we get a null frame there, we know we have a bug.
208            $this->currListFrame = new ListFrame;
209        }
210        $toks = $this->closeLists( $token );
211        $this->reset();
212        return new TokenHandlerResult( $toks );
213    }
214
215    /**
216     * Handle close list processing
217     *
218     * @param Token|string $token
219     * @return array
220     */
221    private function closeLists( $token ): array {
222        // pop all open list item tokens
223        $tokens = $this->popTags( count( $this->currListFrame->bstack ) );
224
225        // purge all stashed sol-tokens
226        PHPUtils::pushArray( $tokens, $this->currListFrame->solTokens );
227        if ( $this->currListFrame->nlTk ) {
228            $tokens[] = $this->currListFrame->nlTk;
229        }
230        $tokens[] = $token;
231
232        // remove any transform if we dont have any stashed list frames
233        if ( count( $this->listFrames ) === 0 ) {
234            $this->onAnyEnabled = false;
235        }
236
237        $this->resetCurrListFrame();
238
239        $this->env->log( 'trace/list', $this->pipelineId, '----closing all lists----' );
240        $this->env->log( 'trace/list', $this->pipelineId, 'RET: ', $tokens );
241
242        return $tokens;
243    }
244
245    /**
246     * Handle a list item
247     *
248     * @param Token $token
249     * @return TokenHandlerResult|null
250     */
251    private function onListItem( Token $token ): ?TokenHandlerResult {
252        if ( $token instanceof TagTk ) {
253            $this->onAnyEnabled = true;
254            $bullets = $token->getAttributeV( 'bullets' );
255            if ( $this->currListFrame ) {
256                // Ignoring colons inside tags to prevent illegal overlapping.
257                // Attempts to mimic findColonNoLinks in the php parser.
258                if ( PHPUtils::lastItem( $bullets ) === ':'
259                    && ( $this->currListFrame->haveDD || $this->currListFrame->numOpenTags > 0 )
260                ) {
261                    $this->env->log( 'trace/list', $this->pipelineId,
262                        'ANY:', static function () use ( $token ) { return PHPUtils::jsonEncode( $token );
263                        } );
264                    $this->env->log( 'trace/list', $this->pipelineId, 'RET: ', ':' );
265                    return new TokenHandlerResult( [ ':' ] );
266                }
267            } else {
268                $this->currListFrame = new ListFrame;
269            }
270            // convert listItem to list and list item tokens
271            $res = $this->doListItem( $this->currListFrame->bstack, $bullets, $token );
272            return new TokenHandlerResult( $res );
273        }
274
275        $this->env->log( 'trace/list', $this->pipelineId, 'RET: ', $token );
276        return null;
277    }
278
279    /**
280     * Determine the minimum common prefix length
281     *
282     * @param array $x
283     * @param array $y
284     * @return int
285     */
286    private function commonPrefixLength( array $x, array $y ): int {
287        $minLength = min( count( $x ), count( $y ) );
288        $i = 0;
289        for ( ; $i < $minLength; $i++ ) {
290            if ( $x[$i] !== $y[$i] ) {
291                break;
292            }
293        }
294        return $i;
295    }
296
297    /**
298     * Push a list
299     *
300     * @param array $container
301     * @param DataParsoid $dp1
302     * @param DataParsoid $dp2
303     * @return array
304     */
305    private function pushList( array $container, DataParsoid $dp1, DataParsoid $dp2 ): array {
306        $this->currListFrame->endtags[] = new EndTagTk( $container['list'] );
307        $this->currListFrame->endtags[] = new EndTagTk( $container['item'] );
308
309        if ( $container['item'] === 'dd' ) {
310            $this->currListFrame->haveDD = true;
311        } elseif ( $container['item'] === 'dt' ) {
312            $this->currListFrame->haveDD = false; // reset
313        }
314
315        return [
316            new TagTk( $container['list'], [], $dp1 ),
317            new TagTk( $container['item'], [], $dp2 )
318        ];
319    }
320
321    /**
322     * Handle popping tags after processing
323     *
324     * @param int $n
325     * @return array
326     */
327    private function popTags( int $n ): array {
328        $tokens = [];
329
330        while ( $n > 0 ) {
331            // push list item..
332            $temp = array_pop( $this->currListFrame->endtags );
333            if ( !empty( $temp ) ) {
334                $tokens[] = $temp;
335            }
336            // and the list end tag
337            $temp = array_pop( $this->currListFrame->endtags );
338            if ( !empty( $temp ) ) {
339                $tokens[] = $temp;
340            }
341            $n--;
342        }
343        return $tokens;
344    }
345
346    /**
347     * Check for Dt Dd sequence
348     *
349     * @param string $a
350     * @param string $b
351     * @return bool
352     */
353    private function isDtDd( string $a, string $b ): bool {
354        $ab = [ $a, $b ];
355        sort( $ab );
356        return ( $ab[0] === ':' && $ab[1] === ';' );
357    }
358
359    /**
360     * Handle do list item processing
361     *
362     * @param array $bs
363     * @param array $bn
364     * @param Token $token
365     * @return array
366     */
367    private function doListItem( array $bs, array $bn, Token $token ): array {
368        $this->env->log(
369            'trace/list', $this->pipelineId, 'BEGIN:',
370            static function () use ( $token ) { return PHPUtils::jsonEncode( $token );
371            }
372        );
373
374        $prefixLen = $this->commonPrefixLength( $bs, $bn );
375        $prefix = array_slice( $bn, 0, $prefixLen/*CHECK THIS*/ );
376        $dp = $token->dataParsoid;
377
378        $makeDP = static function ( $k, $j ) use ( $dp ) {
379            $newDP = clone $dp;
380            $tsr = $dp->tsr ?? null;
381            if ( $tsr ) {
382                $newDP->tsr = new SourceRange( $tsr->start + $k, $tsr->start + $j );
383            }
384            return $newDP;
385        };
386
387        $this->currListFrame->bstack = $bn;
388
389        $res = null;
390        $itemToken = null;
391
392        // emit close tag tokens for closed lists
393        $this->env->log(
394            'trace/list', $this->pipelineId,
395            static function () use ( $bs, $bn ) {
396                return '    bs: ' . PHPUtils::jsonEncode( $bs ) . '; bn: ' . PHPUtils::jsonEncode( $bn );
397            }
398        );
399
400        if ( count( $prefix ) === count( $bs ) && count( $bn ) === count( $bs ) ) {
401            $this->env->log( 'trace/list', $this->pipelineId, '    -> no nesting change' );
402
403            // same list item types and same nesting level
404            $itemToken = array_pop( $this->currListFrame->endtags );
405            $this->currListFrame->endtags[] = new EndTagTk( $itemToken->getName() );
406            $res = array_merge( [ $itemToken ],
407                $this->currListFrame->solTokens,
408                [
409                    // this list item gets all the bullets since this is
410                    // a list item at the same level
411                    //
412                    // **a
413                    // **b
414                    $this->currListFrame->nlTk ?: '',
415                    new TagTk( $itemToken->getName(), [], $makeDP( 0, count( $bn ) ) )
416                ]
417            );
418        } else {
419            $prefixCorrection = 0;
420            $tokens = [];
421            if ( count( $bs ) > $prefixLen
422                && count( $bn ) > $prefixLen
423                && $this->isDtDd( $bs[$prefixLen], $bn[$prefixLen] ) ) {
424                /* ------------------------------------------------
425                 * Handle dd/dt transitions
426                 *
427                 * Example:
428                 *
429                 * **;:: foo
430                 * **::: bar
431                 *
432                 * the 3rd bullet is the dt-dd transition
433                 * ------------------------------------------------ */
434
435                $tokens = $this->popTags( count( $bs ) - $prefixLen - 1 );
436                $tokens = array_merge( $this->currListFrame->solTokens, $tokens );
437                $newName = self::$bullet_chars_map[$bn[$prefixLen]]['item'];
438                $endTag = array_pop( $this->currListFrame->endtags );
439                if ( $newName === 'dd' ) {
440                    $this->currListFrame->haveDD = true;
441                } elseif ( $newName === 'dt' ) {
442                    $this->currListFrame->haveDD = false; // reset
443                }
444                $this->currListFrame->endtags[] = new EndTagTk( $newName );
445
446                $newTag = null;
447                if ( isset( $dp->stx ) && $dp->stx === 'row' ) {
448                    // stx='row' is only set for single-line dt-dd lists (see tokenizer)
449                    // In this scenario, the dd token we are building a token for has no prefix
450                    // Ex: ;a:b, *;a:b, #**;a:b, etc. Compare with *;a\n*:b, #**;a\n#**:b
451                    $this->env->log( 'trace/list', $this->pipelineId,
452                        '    -> single-line dt->dd transition' );
453                    $newTag = new TagTk( $newName, [], $makeDP( 0, 1 ) );
454                } else {
455                    $this->env->log( 'trace/list', $this->pipelineId, '    -> other dt/dd transition' );
456                    $newTag = new TagTk( $newName, [], $makeDP( 0, $prefixLen + 1 ) );
457                }
458
459                $tokens[] = $endTag;
460                $tokens[] = $this->currListFrame->nlTk ?: '';
461                $tokens[] = $newTag;
462
463                $prefixCorrection = 1;
464            } else {
465                $this->env->log( 'trace/list', $this->pipelineId, '    -> reduced nesting' );
466                $tokens = array_merge(
467                    $this->currListFrame->solTokens,
468                    $tokens,
469                    $this->popTags( count( $bs ) - $prefixLen )
470                );
471                if ( $this->currListFrame->nlTk ) {
472                    $tokens[] = $this->currListFrame->nlTk;
473                }
474                if ( $prefixLen > 0 && count( $bn ) === $prefixLen ) {
475                    $itemToken = array_pop( $this->currListFrame->endtags );
476                    $tokens[] = $itemToken;
477                    // this list item gets all bullets upto the shared prefix
478                    $tokens[] = new TagTk( $itemToken->getName(), [], $makeDP( 0, count( $bn ) ) );
479                    $this->currListFrame->endtags[] = new EndTagTk( $itemToken->getName() );
480                }
481            }
482
483            for ( $i = $prefixLen + $prefixCorrection; $i < count( $bn ); $i++ ) {
484                if ( !self::$bullet_chars_map[$bn[$i]] ) {
485                    throw new \InvalidArgumentException( 'Unknown node prefix ' . $prefix[$i] );
486                }
487
488                // Each list item in the chain gets one bullet.
489                // However, the first item also includes the shared prefix.
490                //
491                // Example:
492                //
493                // **a
494                // ****b
495                //
496                // Yields:
497                //
498                // <ul><li-*>
499                // <ul><li-*>a
500                // <ul><li-FIRST-ONE-gets-***>
501                // <ul><li-*>b</li></ul>
502                // </li></ul>
503                // </li></ul>
504                // </li></ul>
505                //
506                // Unless prefixCorrection is > 0, in which case we've
507                // already accounted for the initial bullets.
508                //
509                // prefixCorrection is for handling dl-dts like this
510                //
511                // ;a:b
512                // ;;c:d
513                //
514                // ";c:d" is embedded within a dt that is 1 char wide(;)
515
516                $listDP = null;
517                $listItemDP = null;
518                if ( $i === $prefixLen ) {
519                    $this->env->log( 'trace/list', $this->pipelineId,
520                        '    -> increased nesting: first'
521                    );
522                    $listDP = $makeDP( 0, 0 );
523                    $listItemDP = $makeDP( 0, $i + 1 );
524                } else {
525                    $this->env->log( 'trace/list', $this->pipelineId,
526                        '    -> increased nesting: 2nd and higher'
527                    );
528                    $listDP = $makeDP( $i, $i );
529                    $listItemDP = $makeDP( $i, $i + 1 );
530                }
531
532                PHPUtils::pushArray( $tokens, $this->pushList(
533                    self::$bullet_chars_map[$bn[$i]], $listDP, $listItemDP
534                ) );
535            }
536            $res = $tokens;
537        }
538
539        // clear out sol-tokens
540        $this->currListFrame->solTokens = [];
541        $this->currListFrame->nlTk = null;
542        $this->currListFrame->atEOL = false;
543
544        $this->env->log(
545            'trace/list', $this->pipelineId, 'RET:',
546            static function () use ( $res ) { return PHPUtils::jsonEncode( $res );
547            }
548        );
549        return $res;
550    }
551}