Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
10.14% covered (danger)
10.14%
14 / 138
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
PegTokenizer
10.14% covered (danger)
10.14%
14 / 138
0.00% covered (danger)
0.00%
0 / 15
1581.12
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 initGrammar
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 getOptions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFrame
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 process
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 processChunkily
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 onlyIncludeOffsets
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 finalize
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 tokenizeSync
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
240
 tokenizeAs
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
2.02
 tokenizeURL
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 tokenizeTemplate
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 tokenizeTemplate3
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 tokenizeTableCellAttributes
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 resetState
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Wt2Html;
5
6use Generator;
7use Wikimedia\Assert\Assert;
8use Wikimedia\Parsoid\Config\Env;
9use Wikimedia\Parsoid\Core\Source;
10use Wikimedia\Parsoid\Core\SourceRange;
11use Wikimedia\Parsoid\Core\SourceString;
12use Wikimedia\Parsoid\DOM\DocumentFragment;
13use Wikimedia\Parsoid\DOM\Element;
14use Wikimedia\Parsoid\Tokens\EOFTk;
15use Wikimedia\Parsoid\Tokens\SelfclosingTagTk;
16use Wikimedia\Parsoid\Tokens\Token;
17use Wikimedia\Parsoid\Utils\PHPUtils;
18use Wikimedia\Parsoid\Utils\TokenUtils;
19use Wikimedia\WikiPEG\SyntaxError;
20
21/**
22 * Tokenizer for wikitext, using WikiPEG and a
23 * separate PEG grammar file (Grammar.pegphp)
24 */
25class PegTokenizer extends PipelineStage {
26    private array $options;
27    /** @var Grammar|TracingGrammar|null */
28    private $grammar = null;
29    private bool $tracing;
30    /**
31     * No need to retokenize identical strings
32     * Cache <src,startRule> --> token array.
33     * Expected benefits:
34     * - same expanded template source used multiple times on a page
35     * - convertToString calls
36     * - calls from TableFixups and elsewhere to tokenize* methods
37     */
38    private PipelineContentCache $cache;
39
40    public function __construct( Env $env, array $options = [], string $stageId = "" ) {
41        parent::__construct( $env );
42        $this->env = $env;
43        $this->options = $options;
44        $this->tracing = $env->hasTraceFlag( 'grammar' ) ||
45            // Allow substitution of a custom tracer for unit testing
46            isset( $options['tracer'] );
47        // Cache only on seeing the same source the second time.
48        // This minimizes cache bloat & token cloning penalties.
49        $this->cache = $this->env->getCache(
50            "PegTokenizer",
51            [ "repeatThreshold" => 1, "cloneValue" => true ]
52        );
53    }
54
55    private function initGrammar(): void {
56        if ( !$this->grammar ) {
57            $this->grammar = $this->tracing ? new TracingGrammar : new Grammar;
58        }
59    }
60
61    /**
62     * Get the constructor options.
63     *
64     * @internal
65     * @return array
66     */
67    public function getOptions(): array {
68        return $this->options;
69    }
70
71    public function getFrame(): Frame {
72        return $this->frame ?? $this->env->topFrame;
73    }
74
75    /**
76     * See PipelineStage::process docs as well. This doc block refines
77     * the generic arg types to be specific to this pipeline stage.
78     *
79     * @param string|array|DocumentFragment|Element $input
80     *   Wikitext to tokenize. In practice this should be a string.
81     * @param array{sol:bool} $options
82     * - atTopLevel: (bool) Whether we are processing the top-level document
83     * - sol: (bool) Whether input should be processed in start-of-line context
84     *
85     * @return array The token array
86     * @throws SyntaxError
87     */
88    public function process(
89        string|array|DocumentFragment|Element $input,
90        array $options
91    ): array|Element|DocumentFragment {
92        Assert::invariant( is_string( $input ), "Input should be a string" );
93        $result = $this->tokenizeSync( $input, $options, $exception );
94        if ( $result === false ) {
95            // Should never happen.
96            throw $exception;
97        }
98
99        // Downstream stages expect EOFTk at the end of the token stream.
100        $result[] = new EOFTk();
101        return $result;
102    }
103
104    /**
105     * The text is tokenized in chunks (one per top-level block).
106     *
107     * @param string|array|DocumentFragment|Element $input
108     *   Wikitext to tokenize. In practice this should be a string.
109     * @param array{atTopLevel:bool,sol:bool} $options
110     *   - atTopLevel: (bool) Whether we are processing the top-level document
111     *   - sol (bool) Whether text should be processed in start-of-line context.
112     * @return Generator<list<Token|string>>
113     */
114    public function processChunkily(
115        string|array|DocumentFragment|Element $input,
116        array $options
117    ): Generator {
118        if ( !$this->grammar ) {
119            $this->initGrammar();
120        }
121
122        Assert::invariant( is_string( $input ), "Input should be a string" );
123        Assert::invariant( isset( $options['sol'] ), "Sol should be set" );
124
125        // Kick it off!
126        $args = [
127            'env' => $this->env,
128            'pipelineId' => $this->getPipelineId(),
129            'pegTokenizer' => $this,
130            'pipelineOffset' => $this->srcOffsets->start ?? 0,
131            'source' => $this->srcOffsets->source,
132            'sol' => $options['sol'],
133            'stream' => true,
134            'startRule' => 'start_async',
135        ];
136
137        if ( $this->tracing ) {
138            $args['tracer'] = $this->options['tracer'] ?? new Tracer( $input );
139        }
140
141        foreach ( $this->onlyIncludeOffsets( $input, $args ) as [ $start, $end ] ) {
142            $piece = substr( $input, $start, $end - $start );
143            // Wrap wikipeg's generator with our own generator
144            // to track time usage.
145            // @phan-suppress-next-line PhanTypeInvalidYieldFrom
146            yield from $this->grammar->parse( $piece, [
147                'pipelineOffset' => $args['pipelineOffset'] + $start,
148            ] + $args );
149        }
150    }
151
152    /**
153     * Provide the offsets into $input needed for <onlyinclude> processing
154     * if `inTemplate` mode.  Otherwise just return the start and end
155     * of the string.
156     */
157    private function onlyIncludeOffsets( string $input, array $args ): array {
158        // Handle <onlyinclude>
159        if (
160            ( $this->options['inTemplate'] ?? false ) &&
161            str_contains( $input, '<onlyinclude>' ) &&
162            str_contains( $input, '</onlyinclude>' )
163        ) {
164            try {
165                return $this->grammar->parse( $input, [
166                    'stream' => false,
167                    'startRule' => 'preproc_find_only_include',
168                    'pipelineOffset' => 0,
169                ] + $args );
170            } catch ( SyntaxError ) {
171                /* ignore, fall through to process the whole input */
172                $this->env->log( 'warn', "Couldn't extract <onlyinclude>" );
173            }
174        }
175        return [ [ 0, strlen( $input ) ] ];
176    }
177
178    /**
179     * @inheritDoc
180     */
181    public function finalize(): Generator {
182        yield [ new EOFTk() ];
183    }
184
185    /**
186     * Tokenize via a rule passed in as an arg.
187     * The text is tokenized synchronously in one shot.
188     *
189     * @param string $text
190     * @param array{sol:bool} $args
191     * - sol: (bool) Whether input should be processed in start-of-line context.
192     * - startRule: (string) which tokenizer rule to tokenize with
193     * @param SyntaxError|null &$exception a syntax error, if thrown.
194     * @return array|false The token array, or false for a syntax error
195     */
196    public function tokenizeSync( string $text, array $args, &$exception = null ) {
197        if ( !$this->grammar ) {
198            $this->initGrammar();
199        }
200        Assert::invariant( isset( $args['sol'] ), "Sol should be set" );
201        $args += [
202            'pegTokenizer' => $this,
203            'pipelineId' => $this->getPipelineId(),
204            'pipelineOffset' => $this->srcOffsets->start ?? 0,
205            'source' => $this->srcOffsets->source ?? null,
206            'startRule' => 'start',
207            'env' => $this->env
208        ];
209        Assert::invariant( $args['startRule'] !== null, 'null start rule' );
210        Assert::invariant( !( $args['stream'] ?? false ), 'synchronous parse' );
211
212        // crc32 is much faster than md5 and since we are verifying a
213        // $text match when reusing cache contents, hash collisions are okay.
214        //
215        // NOTE about inclusion of pipelineOffset in the cache key:
216        // The PEG tokenizer returns tokens with offsets shifted by
217        // $args['pipelineOffset'], so we cannot reuse tokens across
218        // differing values of this option. If required, we could refactor
219        // to move that and the logging code into this file.
220        $cacheKey = crc32( $text ) .
221            "|" . (int)$args['sol'] .
222            "|" . $args['startRule'] .
223            "|" . $args['pipelineOffset'] .
224            "|" . ( $args['source'] ? spl_object_id( $args['source'] ) : "" ) .
225            "|" . ( $this->options['enableOnlyInclude'] ?? false );
226        $res = $this->cache->lookup( $cacheKey, $text );
227        if ( $res !== null ) {
228            $tokens = $res['value'];
229            // NOTE: Since $args['source'] is part of the cache key,
230            // we don't need to reset source in SourceRange properties in $tokens.
231            return $tokens;
232        }
233
234        if ( $this->tracing ) {
235            $args['tracer'] = $this->options['tracer'] ?? new Tracer( $text );
236        }
237
238        $start = null;
239        $profile = null;
240        if ( $this->env->profiling() ) {
241            $profile = $this->env->getCurrentProfile();
242            $start = hrtime( true );
243        }
244
245        try {
246            if ( $this->options['enableOnlyInclude'] ?? false ) {
247                $toks = [];
248                foreach ( $this->onlyIncludeOffsets( $text, $args ) as [ $start, $end ] ) {
249                    $piece = substr( $text, $start, $end - $start );
250                    $result = $this->grammar->parse( $piece, [
251                        'pipelineOffset' => $args['pipelineOffset'] + $start,
252                    ] + $args );
253                    if ( !is_array( $result ) ) {
254                        $result = [ $result ];
255                    }
256                    // The 'start' and 'start_async' rules manually call
257                    // ::shiftTokenTSR before returning tokens.  For all
258                    // others, we still need to perform the shift by the
259                    // requested $args['pipelineOffset'].
260                    if ( $args['startRule'] !== 'start' ) {
261                        TokenUtils::shiftTokenTSR(
262                            $result, $args['pipelineOffset'] + $start
263                        );
264                    }
265                    PHPUtils::pushArray( $toks, $result );
266                }
267            } else {
268                $toks = $this->grammar->parse( $text, $args );
269                // The 'start' and 'start_async' rules manually call
270                // ::shiftTokenTSR before returning tokens.  For all
271                // others, we still need to perform the shift by the
272                // requested $args['pipelineOffset'].
273                if ( $args['startRule'] !== 'start' ) {
274                    TokenUtils::shiftTokenTSR(
275                        is_array( $toks ) ? $toks : [ $toks ], $args['pipelineOffset']
276                    );
277                }
278            }
279        } catch ( SyntaxError $e ) {
280            $exception = $e;
281            return false;
282        }
283
284        if ( $profile ) {
285            $profile->bumpTimeUse( 'PEG', hrtime( true ) - $start, 'PEG' );
286        }
287
288        if ( is_array( $toks ) ) {
289            $this->cache->cache( $cacheKey, $toks, $this->frame?->getSource(), $text );
290        }
291
292        return $toks;
293    }
294
295    /**
296     * Tokenizes a string as a rule
297     *
298     * @param string|Source $text The input text
299     * @param string $rule The rule name
300     * @param bool $sol Start of line flag
301     * @param ?SourceRange $tsr The optional position of $text in the Source
302     * @return array|false Array of tokens/strings or false on error
303     */
304    public function tokenizeAs( string|Source $text, string $rule, bool $sol, ?SourceRange $tsr = null ) {
305        if ( $text instanceof Source ) {
306            $source = $text;
307            $text = $source->getSrcText();
308        } else {
309            $source = new SourceString( $text );
310        }
311        $args = [
312            'startRule' => $rule,
313            'sol' => $sol,
314            'pipelineOffset' => $tsr->start ?? 0,
315            'source' => $tsr->source ?? $source,
316        ];
317        return $this->tokenizeSync( $text, $args );
318    }
319
320    /**
321     * Tokenize a URL.
322     * @param string $text
323     * @return array|false Array of tokens/strings or false on error
324     */
325    public function tokenizeURL( string $text ) {
326        // XXX T405759 This returns tokens with a unique (not top-level)
327        // source in the TSR; if this is retokenizing part of the top-level
328        // source this should pass srcOffsets.
329        return $this->tokenizeAs( $text, 'url', /* sol */true );
330    }
331
332    /**
333     * (Re)tokenize a template / parser function.
334     * @param string $text
335     * @param SourceRange $tsr The location of $text in the Source
336     * @return SelfclosingTagTk|false A template token or false on error
337     */
338    public function tokenizeTemplate( string $text, SourceRange $tsr ) {
339        $toks = $this->tokenizeAs( $text, 'template', false, $tsr );
340        if ( !is_array( $toks ) ) {
341            $toks = [ $toks ];
342        }
343        if ( count( $toks ) === 1 ) {
344            return $toks[0];
345        }
346        return false;
347    }
348
349    /**
350     * (Re)tokenize a v3 template / parser function.
351     * @param string $text
352     * @param SourceRange $tsr The location of $text in the Source
353     * @return SelfclosingTagTk|false A template3 token or false on error
354     */
355    public function tokenizeTemplate3( string $text, SourceRange $tsr ) {
356        $toks = $this->tokenizeAs( $text, 'template3', false, $tsr );
357        if ( !is_array( $toks ) ) {
358            $toks = [ $toks ];
359        }
360        if ( count( $toks ) === 1 ) {
361            return $toks[0];
362        }
363        return false;
364    }
365
366    /**
367     * Tokenize table cell attributes.
368     * @param string $text
369     * @param bool $sol
370     * @return array|false Array of tokens/strings or false on error
371     */
372    public function tokenizeTableCellAttributes( string $text, bool $sol ) {
373        // XXX T405759 This returns tokens with a unique (not top-level)
374        // source in the TSR; if this is retokenizing part of the top-level
375        // source this should pass srcOffsets.
376        return $this->tokenizeAs( $text, 'row_syntax_table_args', $sol );
377    }
378
379    /**
380     * @inheritDoc
381     */
382    public function resetState( array $options ): void {
383        TokenizerUtils::resetAnnotationIncludeRegex();
384        if ( $this->grammar ) {
385            $this->grammar->resetState();
386        }
387        parent::resetState( $options );
388    }
389}