Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 194
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
ParagraphWrapper
0.00% covered (danger)
0.00%
0 / 194
0.00% covered (danger)
0.00%
0 / 14
8010
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 onNewline
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 onEnd
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 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 resetBuffers
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 resetCurrLine
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 processBuffers
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 flushBuffers
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 processOneNlTk
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 openPTag
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
132
 closeOpenPTag
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
132
 onNewlineOrEOF
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
72
 processPendingNLs
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
110
 onAny
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
1260
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Wt2Html\TT;
5
6use Wikimedia\Assert\Assert;
7use Wikimedia\Assert\UnreachableException;
8use Wikimedia\Parsoid\Tokens\CommentTk;
9use Wikimedia\Parsoid\Tokens\EndTagTk;
10use Wikimedia\Parsoid\Tokens\EOFTk;
11use Wikimedia\Parsoid\Tokens\NlTk;
12use Wikimedia\Parsoid\Tokens\SelfclosingTagTk;
13use Wikimedia\Parsoid\Tokens\TagTk;
14use Wikimedia\Parsoid\Tokens\Token;
15use Wikimedia\Parsoid\Utils\PHPUtils;
16use Wikimedia\Parsoid\Utils\TokenUtils;
17use Wikimedia\Parsoid\Wikitext\Consts;
18use Wikimedia\Parsoid\Wt2Html\TokenHandlerPipeline;
19
20/**
21 * Insert paragraph tags where needed -- smartly and carefully
22 * -- there is much fun to be had mimicking "wikitext visual newlines"
23 * behavior as implemented by the PHP parser.
24 */
25class ParagraphWrapper extends TokenHandler {
26
27    private bool $inPre = false;
28    private bool $hasOpenPTag = false;
29    private bool $inBlockElem = false;
30    private bool $inBlockquote = false;
31
32    /**
33     * The state machine in the PreHandler is line based and only suppresses
34     * indent-pres when encountering blocks on a line.  However, the legacy
35     * parser's `doBlockLevels` has a concept of being "$inBlockElem", which
36     * is mimicked here.  Rather than replicate that awareness in both passes,
37     * we piggyback on it here to undo indent-pres when they're found to be
38     * undesirable.
39     */
40    private bool $undoIndentPre = false;
41    private array $tokenBuffer = [];
42    private array $nlWsTokens = [];
43    private int $newLineCount = 0;
44
45    /** @var array */
46    private $currLineTokens = [];
47    /** @var bool */
48    private $currLineHasWrappableTokens = false;
49    /** @var bool */
50    private $currLineBlockTagSeen = false;
51    /** @var bool */
52    private $currLineBlockTagOpen = false;
53
54    /**
55     * Constructor for paragraph wrapper.
56     * @param TokenHandlerPipeline $manager manager enviroment
57     * @param array $options various configuration options
58     */
59    public function __construct( TokenHandlerPipeline $manager, array $options ) {
60        parent::__construct( $manager, $options );
61        // Disable p-wrapper
62        $this->disabled = !empty( $this->options['inlineContext'] );
63        $this->reset();
64    }
65
66    /**
67     * @inheritDoc
68     */
69    public function onNewline( NlTk $token ): ?array {
70        return $this->inPre ? null : $this->onNewlineOrEOF( $token );
71    }
72
73    /**
74     * @inheritDoc
75     */
76    public function onEnd( EOFTk $token ): ?array {
77        return $this->onNewlineOrEOF( $token );
78    }
79
80    /**
81     * Reset the token buffer and related info
82     * This is the ordering of buffered tokens and how they should get emitted:
83     *
84     * token-buffer         (from previous lines if newLineCount > 0)
85     * newline-ws-tokens    (buffered nl+sol-transparent tokens since last non-nl-token)
86     * current-line-tokens  (all tokens after newline-ws-tokens)
87     *
88     * newline-token-count is > 0 only when we encounter multiple "empty lines".
89     *
90     * Periodically, when it is clear where an open/close p-tag is required, the buffers
91     * are collapsed and emitted. Wherever tokens are buffered/emitted, verify that this
92     *  order is preserved.
93     */
94    private function reset(): void {
95        $this->resetBuffers();
96        $this->resetCurrLine();
97        $this->hasOpenPTag = false;
98        $this->inPre = false;
99        $this->undoIndentPre = false;
100        // NOTE: This flag is the local equivalent of what we're mimicking with
101        // the 'inlineContext' pipeline option.
102        $this->inBlockElem = false;
103        $this->inBlockquote = false;
104    }
105
106    /**
107     * Reset the token buffer and new line info
108     *
109     */
110    private function resetBuffers(): void {
111        $this->tokenBuffer = [];
112        $this->nlWsTokens = [];
113        $this->newLineCount = 0;
114    }
115
116    /**
117     * Reset the current line info
118     *
119     */
120    private function resetCurrLine(): void {
121        if ( $this->currLineBlockTagSeen ) {
122            $this->inBlockElem = $this->currLineBlockTagOpen;
123        }
124        $this->currLineTokens = [];
125        $this->currLineHasWrappableTokens = false;
126        $this->currLineBlockTagSeen = false;
127        $this->currLineBlockTagOpen = false;
128    }
129
130    /**
131     * Process the current buffer contents and token provided
132     *
133     * @param Token|string $token token
134     * @param bool $flushCurrentLine option to flush current line or preserve it
135     * @return array<string|Token>
136     */
137    private function processBuffers( $token, bool $flushCurrentLine ): array {
138        $res = $this->processPendingNLs();
139        $this->currLineTokens[] = $token;
140        if ( $flushCurrentLine ) {
141            PHPUtils::pushArray( $res, $this->currLineTokens );
142            $this->resetCurrLine();
143        }
144        $this->env->trace( 'p-wrap', $this->pipelineId, '---->   ', $res );
145        return $res;
146    }
147
148    /**
149     * Process and flush existing buffer contents
150     *
151     * @param Token|string $token token
152     * @return array<string|Token>
153     */
154    private function flushBuffers( $token ): array {
155        Assert::invariant( $this->newLineCount === 0, "PWrap: Trying to flush buffers with pending newlines" );
156
157        $this->currLineTokens[] = $token;
158        // Juggle the array reference count to allow us to append to it without
159        // copying the array
160        $resToks = $this->tokenBuffer;
161        $nlWsTokens = $this->nlWsTokens;
162        $this->resetBuffers();
163        PHPUtils::pushArray( $resToks, $nlWsTokens );
164        $this->env->trace( 'p-wrap', $this->pipelineId, '---->   ', $resToks );
165        return $resToks;
166    }
167
168    /**
169     * Append tokens from the newline/whitespace buffer to the output array
170     * until a newline is encountered. Increment the offset reference. Return
171     * the newline token.
172     *
173     * @param array &$out array to append to
174     * @param int &$offset The offset reference to update
175     * @return NlTk
176     */
177    public function processOneNlTk( array &$out, &$offset ) {
178        $n = count( $this->nlWsTokens );
179        while ( $offset < $n ) {
180            $t = $this->nlWsTokens[$offset++];
181            if ( $t instanceof NlTk ) {
182                return $t;
183            } else {
184                $out[] = $t;
185            }
186        }
187        throw new UnreachableException( 'nlWsTokens was expected to contain an NlTk.' );
188    }
189
190    /**
191     * Search for the opening paragraph tag
192     *
193     * @param array &$out array to process and update
194     */
195    private function openPTag( array &$out ): void {
196        if ( !$this->hasOpenPTag ) {
197            $tplStartIndex = -1;
198            // Be careful not to expand template ranges unnecessarily.
199            // Look for open markers before starting a p-tag.
200            $countOut = count( $out );
201            for ( $i = 0; $i < $countOut; $i++ ) {
202                $t = $out[$i];
203                if ( !is_string( $t ) && $t->getName() === 'meta' ) {
204                    if ( TokenUtils::hasTypeOf( $t, 'mw:Transclusion' ) ) {
205                        // We hit a start tag and everything before it is sol-transparent.
206                        $tplStartIndex = $i;
207                        continue;
208                    } elseif ( TokenUtils::matchTypeOf( $t, '#^mw:Transclusion/#' ) ) {
209                        // End tag. All tokens before this are sol-transparent.
210                        // Let us leave them all out of the p-wrapping.
211                        $tplStartIndex = -1;
212                        continue;
213                    } elseif ( TokenUtils::isAnnotationStartToken( $t ) ) {
214                        break;
215                    }
216                }
217                // Not a transclusion meta; Check for nl/sol-transparent tokens
218                // and leave them out of the p-wrapping.
219                if ( !TokenUtils::isSolTransparent( $this->env, $t ) && !( $t instanceof NlTk ) ) {
220                    break;
221                }
222            }
223            if ( $tplStartIndex > -1 ) {
224                $i = $tplStartIndex;
225            }
226            array_splice( $out, $i, 0, [ new TagTk( 'p' ) ] );
227            $this->hasOpenPTag = true;
228        }
229    }
230
231    /**
232     * Search for the closing paragraph tag
233     *
234     * @param array &$out array to process and update
235     */
236    private function closeOpenPTag( array &$out ): void {
237        if ( $this->hasOpenPTag ) {
238            $tplEndIndex = -1;
239            // Be careful not to expand template ranges unnecessarily.
240            // Look for open markers before closing.
241            for ( $i = count( $out ) - 1; $i > -1; $i-- ) {
242                $t = $out[$i];
243                if ( !is_string( $t ) && $t->getName() === 'meta' ) {
244                    if ( TokenUtils::hasTypeOf( $t, 'mw:Transclusion' ) ) {
245                        // We hit a start tag and everything after it is sol-transparent.
246                        // Don't include the sol-transparent tags OR the start tag.
247                        $tplEndIndex = -1;
248                        continue;
249                    } elseif ( TokenUtils::matchTypeOf( $t, '#^mw:Transclusion/#' ) ) {
250                        // End tag. The rest of the tags past this are sol-transparent.
251                        // Let us leave them all out of the p-wrapping.
252                        $tplEndIndex = $i;
253                        continue;
254                    } elseif ( TokenUtils::isAnnotationEndToken( $t ) ) {
255                        break;
256                    }
257                }
258                // Not a transclusion meta; Check for nl/sol-transparent tokens
259                // and leave them out of the p-wrapping.
260                if ( !TokenUtils::isSolTransparent( $this->env, $t ) && !( $t instanceof NlTk ) ) {
261                    break;
262                }
263            }
264            if ( $tplEndIndex > -1 ) {
265                $i = $tplEndIndex;
266            }
267            array_splice( $out, $i + 1, 0, [ new EndTagTk( 'p' ) ] );
268            $this->hasOpenPTag = false;
269        }
270    }
271
272    /**
273     * Handle newline tokens
274     * @return array<string|Token>
275     */
276    private function onNewlineOrEOF( Token $token ): array {
277        $this->env->trace( 'p-wrap', $this->pipelineId, 'NL    | ', $token );
278        if ( $this->currLineBlockTagSeen ) {
279            $this->closeOpenPTag( $this->currLineTokens );
280        } elseif ( !$this->inBlockElem && !$this->hasOpenPTag && $this->currLineHasWrappableTokens ) {
281            $this->openPTag( $this->currLineTokens );
282        }
283
284        // Assertion to catch bugs in p-wrapping; both cannot be true.
285        if ( $this->newLineCount > 0 && count( $this->currLineTokens ) > 0 ) {
286            $this->env->log( 'error/p-wrap', 'Failed assertion in onNewlineOrEOF: newline-count:',
287                $this->newLineCount, '; current line tokens: ', $this->currLineTokens );
288        }
289
290        PHPUtils::pushArray( $this->tokenBuffer, $this->currLineTokens );
291
292        if ( $token instanceof EOFTk ) {
293            $this->nlWsTokens[] = $token;
294            $this->closeOpenPTag( $this->tokenBuffer );
295            $res = $this->processPendingNLs();
296            $this->reset();
297            $this->env->trace( 'p-wrap', $this->pipelineId, '---->   ', $res );
298            return $res;
299        } else {
300            $this->resetCurrLine();
301            $this->newLineCount++;
302            $this->nlWsTokens[] = $token;
303            return [];
304        }
305    }
306
307    /**
308     * Process pending newlines
309     *
310     * @return array<string|Token>
311     */
312    private function processPendingNLs(): array {
313        $resToks = $this->tokenBuffer;
314        $newLineCount = $this->newLineCount;
315        $nlTk = null;
316        $nlOffset = 0;
317
318        $this->env->trace( 'p-wrap', $this->pipelineId, '        NL-count: ', $newLineCount );
319
320        if ( $newLineCount >= 2 && !$this->inBlockElem ) {
321            $this->closeOpenPTag( $resToks );
322
323            // First is emitted as a literal newline
324            $resToks[] = $this->processOneNlTk( $resToks, $nlOffset );
325            $newLineCount -= 1;
326
327            $remainder = $newLineCount % 2;
328
329            while ( $newLineCount > 0 ) {
330                $nlTk = $this->processOneNlTk( $resToks, $nlOffset );
331                if ( $newLineCount % 2 === $remainder ) {
332                    if ( $this->hasOpenPTag ) {
333                        $resToks[] = new EndTagTk( 'p' );
334                        $this->hasOpenPTag = false;
335                    }
336                    if ( $newLineCount > 1 ) {
337                        $resToks[] = new TagTk( 'p' );
338                        $this->hasOpenPTag = true;
339                    }
340                } else {
341                    $resToks[] = new SelfclosingTagTk( 'br' );
342                }
343                $resToks[] = $nlTk;
344                $newLineCount -= 1;
345            }
346        }
347
348        if ( $this->currLineBlockTagSeen ) {
349            $this->closeOpenPTag( $resToks );
350            if ( $newLineCount === 1 ) {
351                $resToks[] = $this->processOneNlTk( $resToks, $nlOffset );
352            }
353        }
354
355        // Gather remaining ws and nl tokens
356        for ( $i = $nlOffset; $i < count( $this->nlWsTokens ); $i++ ) {
357            $resToks[] = $this->nlWsTokens[$i];
358        }
359
360        // reset buffers
361        $this->resetBuffers();
362
363        return $resToks;
364    }
365
366    /**
367     * @inheritDoc
368     */
369    public function onAny( $token ): ?array {
370        $this->env->trace( 'p-wrap', $this->pipelineId, 'ANY   | ', $token );
371        $res = null;
372        if ( $token instanceof TagTk && $token->getName() === 'pre'
373             && !TokenUtils::isHTMLTag( $token )
374        ) {
375            if ( $this->inBlockElem || $this->inBlockquote ) {
376                $this->undoIndentPre = true;
377                if ( $this->newLineCount === 0 ) {
378                    return $this->flushBuffers( '' );
379                } else {
380                    return [];
381                }
382            } else {
383                $this->inPre = true;
384                // This will put us `inBlockElem`, so we need the extra `!inPre`
385                // condition below.  Presumably, we couldn't have entered
386                // `inBlockElem` while being `inPre`.  Alternatively, we could say
387                // that indent-pre is "never suppressing" and set the `blockTagOpen`
388                // flag to false. The point of all this is that we want to close
389                // any open p-tags.
390                $this->currLineBlockTagSeen = true;
391                $this->currLineBlockTagOpen = true;
392                // skip ensures this doesn't hit the AnyHandler
393                return $this->processBuffers( $token, true );
394            }
395        } elseif ( $token instanceof EndTagTk && $token->getName() === 'pre' &&
396            !TokenUtils::isHTMLTag( $token )
397        ) {
398            if ( ( $this->inBlockElem && !$this->inPre ) || $this->inBlockquote ) {
399                $this->undoIndentPre = false;
400                // No pre-tokens inside block tags -- swallow it.
401                return [];
402            } else {
403                $this->inPre = false;
404                $this->currLineBlockTagSeen = true;
405                $this->currLineBlockTagOpen = false;
406                $this->env->trace( 'p-wrap', $this->pipelineId, '---->   ', $token );
407                return null;
408            }
409        } elseif ( $token instanceof EOFTk || $this->inPre ) {
410            $this->env->trace( 'p-wrap', $this->pipelineId, '---->   ', $token );
411            // null is a signal of an unmodified token
412            return null;
413        } elseif (
414            $token instanceof CommentTk
415            || ( is_string( $token ) && preg_match( '/^[\t ]*$/D', $token ) )
416            || TokenUtils::isEmptyLineMetaToken( $token )
417        ) {
418            if ( $this->newLineCount === 0 ) {
419                // Since we have no pending newlines to trip us up,
420                // no need to buffer -- just flush everything
421                return $this->flushBuffers( $token );
422            } else {
423                // We are in buffering mode waiting till we are ready to
424                // process pending newlines.
425                $this->nlWsTokens[] = $token;
426                return [];
427            }
428        } elseif (
429            // T186965: <style> behaves similarly to sol transparent tokens in
430            // that it doesn't open/close paragraphs, but also doesn't induce
431            // a new paragraph by itself.
432            TokenUtils::isSolTransparent( $this->env, $token ) ||
433            ( !is_string( $token ) && $token->getName() === 'style' )
434        ) {
435            if ( $this->undoIndentPre && PreHandler::isIndentPreWS( $token ) ) {
436                $this->nlWsTokens[] = ' ';
437                return [];
438            } elseif ( $this->newLineCount === 0 ) {
439                // Since we have no pending newlines to trip us up,
440                // no need to buffer -- just flush everything
441                return $this->flushBuffers( $token );
442            } elseif ( $this->newLineCount === 1 ) {
443                // Swallow newline, whitespace, comments, and the current line
444                PHPUtils::pushArray( $this->tokenBuffer, $this->nlWsTokens );
445                PHPUtils::pushArray( $this->tokenBuffer, $this->currLineTokens );
446                $this->newLineCount = 0;
447                $this->nlWsTokens = [];
448                $this->resetCurrLine();
449
450                // But, don't process the new token yet.
451                $this->currLineTokens[] = $token;
452                return [];
453            } else {
454                return $this->processBuffers( $token, false );
455            }
456        } else {
457            if ( !is_string( $token ) ) {
458                $name = $token->getName();
459                if ( isset( Consts::$wikitextBlockElems[$name] ) ) {
460                    $this->currLineBlockTagSeen = true;
461                    $this->currLineBlockTagOpen = true;
462                    if (
463                        ( isset( Consts::$blockElems[$name] ) && $token instanceof EndTagTk ) ||
464                        ( isset( Consts::$antiBlockElems[$name] ) && !$token instanceof EndTagTk ) ||
465                        isset( Consts::$neverBlockElems[$name] )
466                    ) {
467                        $this->currLineBlockTagOpen = false;
468                    }
469                }
470                if ( $name === 'blockquote' ) {
471                    $this->inBlockquote = !( $token instanceof EndTagTk );
472                }
473            }
474            $this->currLineHasWrappableTokens = true;
475            return $this->processBuffers( $token, false );
476        }
477    }
478}