Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
33.62% covered (danger)
33.62%
158 / 470
21.05% covered (danger)
21.05%
4 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
TemplateHandler
33.62% covered (danger)
33.62%
158 / 470
21.05% covered (danger)
21.05%
4 / 19
5466.36
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 parserFunctionsWrapper
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 processToString
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
650
 isSafeSubst
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 resolveTemplateTarget
41.18% covered (danger)
41.18%
35 / 85
0.00% covered (danger)
0.00%
0 / 1
101.42
 flattenAndAppendToks
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
132
 convertToString
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 enforceTemplateConstraints
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 expandTemplateNatively
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
90
 processTemplateSource
72.73% covered (warning)
72.73%
24 / 33
0.00% covered (danger)
0.00%
0 / 1
3.18
 encapTokens
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 processTemplateTokens
65.00% covered (warning)
65.00%
13 / 20
0.00% covered (danger)
0.00%
0 / 1
14.29
 fetchTemplateAndTitle
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 hasTemplateToken
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
7.46
 processSpecialMagicWord
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 expandTemplate
38.05% covered (danger)
38.05%
43 / 113
0.00% covered (danger)
0.00%
0 / 1
160.92
 onTemplate
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 onTemplateArg
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 onTag
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Wt2Html\TT;
5
6use Wikimedia\Assert\Assert;
7use Wikimedia\Assert\UnreachableException;
8use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
9use Wikimedia\Parsoid\Tokens\CommentTk;
10use Wikimedia\Parsoid\Tokens\EndTagTk;
11use Wikimedia\Parsoid\Tokens\KV;
12use Wikimedia\Parsoid\Tokens\NlTk;
13use Wikimedia\Parsoid\Tokens\SelfclosingTagTk;
14use Wikimedia\Parsoid\Tokens\SourceRange;
15use Wikimedia\Parsoid\Tokens\TagTk;
16use Wikimedia\Parsoid\Tokens\Token;
17use Wikimedia\Parsoid\Utils\PHPUtils;
18use Wikimedia\Parsoid\Utils\PipelineUtils;
19use Wikimedia\Parsoid\Utils\Title;
20use Wikimedia\Parsoid\Utils\TitleException;
21use Wikimedia\Parsoid\Utils\TokenUtils;
22use Wikimedia\Parsoid\Utils\WTUtils;
23use Wikimedia\Parsoid\Wikitext\Wikitext;
24use Wikimedia\Parsoid\Wt2Html\Params;
25use Wikimedia\Parsoid\Wt2Html\TokenTransformManager;
26
27/**
28 * Template and template argument handling.
29 */
30class TemplateHandler extends TokenHandler {
31    /**
32     * @var bool Should we wrap template tokens with template meta tags?
33     */
34    private $wrapTemplates;
35
36    /**
37     * @var AttributeExpander
38     * Local copy of the attribute expander to deal with template targets
39     * that are templated themselves
40     */
41    private $ae;
42
43    /**
44     * @var ParserFunctions
45     */
46    private $parserFunctions;
47
48    /**
49     * @var bool
50     */
51     private $atMaxArticleSize;
52
53     /** @var string|null */
54     private $safeSubstRegex;
55
56    /**
57     * @param TokenTransformManager $manager
58     * @param array $options
59     *  - ?bool inTemplate Is this being invoked while processing a template?
60     *  - ?bool expandTemplates Should we expand templates encountered here?
61     *  - ?string extTag The name of the extension tag, if any, which is being expanded.
62     */
63    public function __construct( TokenTransformManager $manager, array $options ) {
64        parent::__construct( $manager, $options );
65        $this->parserFunctions = new ParserFunctions( $this->env );
66        $this->ae = new AttributeExpander( $this->manager, [
67            'expandTemplates' => $this->options['expandTemplates'],
68            'inTemplate' => $this->options['inTemplate'],
69            'standalone' => true,
70        ] );
71        $this->wrapTemplates = !$options['inTemplate'];
72
73        // In the legacy parser, the call to replaceVariables from internalParse
74        // returns early if the text is already greater than the $wgMaxArticleSize
75        // We're going to compare and set a boolean here, then do the "early
76        // return" below.
77        $this->atMaxArticleSize = !$this->env->compareWt2HtmlLimit(
78            'wikitextSize',
79            strlen( $this->env->topFrame->getSrcText() )
80        );
81    }
82
83    /**
84     * Parser functions also need template wrapping.
85     *
86     * @param array $tokens
87     * @return array
88     */
89    private function parserFunctionsWrapper( array $tokens ): array {
90        $chunkToks = [];
91        if ( $tokens ) {
92            // This is only for the Parsoid native expansion pipeline used in
93            // parser tests. The "" token sometimes changes foster parenting
94            // behavior and trips up some tests.
95            $tokens = array_values( array_filter( $tokens, static function ( $t ) {
96                return $t !== '';
97            } ) );
98
99            // token chunk should be flattened
100            $flat = true;
101            foreach ( $tokens as $t ) {
102                if ( is_array( $t ) ) {
103                    $flat = false;
104                    break;
105                }
106            }
107            Assert::invariant( $flat, "Expected token chunk to be flattened" );
108
109            $chunkToks = $this->processTemplateTokens( $tokens );
110        }
111        return $chunkToks;
112    }
113
114    /**
115     * Take output of tokensToString and further postprocess it.
116     * - If it can be processed to a string which would be a valid template transclusion target,
117     *   the return value will be [ $the_string_value, null ]
118     * - If not, the return value will be [ $partial_string, $unprocessed_token_array ]
119     * The caller can then decide if this would be a valid parser function call
120     * where the unprocessed token array would be part of the first arg to the parser function.
121     * Ex: With "{{uc:foo [[foo]] {{1x|foo}} bar}}", we return
122     *     [ "uc:foo ", [ wikilink-token, " ", template-token, " bar" ] ]
123     *
124     * @param array $tokens
125     * @return array first element is always a string
126     */
127    private function processToString( array $tokens ): array {
128        $maybeTarget = TokenUtils::tokensToString( $tokens, true, [ 'retainNLs' => true ] );
129        if ( !is_array( $maybeTarget ) ) {
130            return [ $maybeTarget, null ];
131        }
132
133        $buf = $maybeTarget[0]; // Will always be a string
134        $tgtTokens = $maybeTarget[1];
135        $preNlContent = null;
136        $i = 0;
137        $n = count( $tgtTokens );
138        while ( $i < $n ) {
139            $ntt = $tgtTokens[$i];
140            if ( is_string( $ntt ) ) {
141                $buf .= $ntt;
142                if ( $preNlContent !== null && !preg_match( '/^\s*$/D', $buf ) ) {
143                    // intervening newline makes this an invalid template target
144                    return [ $preNlContent, array_merge( [ $buf ], array_slice( $tgtTokens, $i ) ) ];
145                }
146            } else {
147                switch ( get_class( $ntt ) ) {
148                    case SelfclosingTagTk::class:
149                        // Quotes are valid template targets
150                        if ( $ntt->getName() === 'mw-quote' ) {
151                            $buf .= $ntt->getAttributeV( 'value' );
152                        } elseif (
153                            !TokenUtils::isEmptyLineMetaToken( $ntt ) &&
154                            $ntt->getName() !== 'template' &&
155                            $ntt->getName() !== 'templatearg' &&
156                            // Ignore annotations in template targets
157                            // NOTE(T295834): There's a large discussion about who's responsible
158                            // for stripping these tags in I487baaafcf1ffd771cb6a9e7dd4fb76d6387e412
159                            !(
160                                $ntt->getName() === 'meta' &&
161                                TokenUtils::matchTypeOf( $ntt, WTUtils::ANNOTATION_META_TYPE_REGEXP )
162                            ) &&
163                            // Note that OnlyInclude only converts to metas during TT
164                            // in inTemplate context, but we shouldn't find ourselves
165                            // here in that case.
166                            !(
167                                $ntt->getName() === 'meta' &&
168                                TokenUtils::matchTypeOf( $ntt, '#^mw:Includes/#' )
169                            )
170                        ) {
171                            // We are okay with empty (comment-only) lines,
172                            // {{..}} and {{{..}}} in template targets.
173                            if ( $preNlContent !== null ) {
174                                return [ $preNlContent, array_merge( [ $buf ], array_slice( $tgtTokens, $i ) ) ];
175                            } else {
176                                return [ $buf, array_slice( $tgtTokens, $i ) ];
177                            }
178                        }
179                        break;
180
181                    case TagTk::class:
182                        if ( TokenUtils::isEntitySpanToken( $ntt ) ) {
183                            $buf .= $tgtTokens[$i + 1];
184                            $i += 2;
185                            break;
186                        }
187                        // Fall-through
188                    case EndTagTk::class:
189                        if ( $preNlContent !== null ) {
190                            return [ $preNlContent, array_merge( [ $buf ], array_slice( $tgtTokens, $i ) ) ];
191                        } else {
192                            return [ $buf, array_slice( $tgtTokens, $i ) ];
193                        }
194
195                    case CommentTk::class:
196                        // Ignore comments as well
197                        break;
198
199                    case NlTk::class:
200                        // Ignore only the leading or trailing newlines
201                        // (modulo whitespace and comments)
202                        //
203                        // If we only have whitespace in $buf thus far,
204                        // the newline can be ignored. But, if we have
205                        // non-ws content in $buf, everything that follows
206                        // can only be ws.
207                        if ( preg_match( '/^\s*$/D', $buf ) ) {
208                            $buf .= "\n";
209                            break;
210                        } elseif ( $preNlContent === null ) {
211                            // Buffer accumulated content
212                            $preNlContent = $buf;
213                            $buf = "\n";
214                            break;
215                        } else {
216                            return [ $preNlContent, array_merge( [ $buf ], array_slice( $tgtTokens, $i ) ) ];
217                        }
218
219                    default:
220                        throw new UnreachableException( 'Unexpected token type: ' . get_class( $ntt ) );
221                }
222            }
223            $i++;
224        }
225
226        // All good! No newline / only whitespace/comments post newline.
227        // (Well, annotation metas and template(arg) tokens too)
228        return [ $preNlContent . $buf, null ];
229    }
230
231    /**
232     * Is the prefix "safesubst"
233     * @param string $prefix
234     * @return bool
235     */
236    private function isSafeSubst( $prefix ): bool {
237        if ( $this->safeSubstRegex === null ) {
238            $this->safeSubstRegex = $this->env->getSiteConfig()->getMagicWordMatcher( 'safesubst' );
239        }
240        return (bool)preg_match( $this->safeSubstRegex, $prefix . ':' );
241    }
242
243    /**
244     * @param TemplateEncapsulator $state
245     * @param string|Token|array $targetToks
246     * @param SourceRange $srcOffsets
247     * @return array|null
248     */
249    private function resolveTemplateTarget(
250        TemplateEncapsulator $state, $targetToks, $srcOffsets
251    ): ?array {
252        $additionalToks = null;
253        if ( is_string( $targetToks ) ) {
254            $target = $targetToks;
255        } else {
256            $toks = !is_array( $targetToks ) ? [ $targetToks ] : $targetToks;
257            $toks = $this->processToString( $toks );
258            [ $target, $additionalToks ] = $toks;
259        }
260
261        $target = trim( $target );
262        $pieces = explode( ':', $target );
263        $untrimmedPrefix = $pieces[0];
264        $prefix = trim( $pieces[0] );
265
266        // Parser function names usually (not always) start with a hash
267        $hasHash = substr( $target, 0, 1 ) === '#';
268        // String found after the colon will be the parser function arg
269        $haveColon = count( $pieces ) > 1;
270
271        // safesubst found in content should be treated as if no modifier were
272        // present. See https://en.wikipedia.org/wiki/Help:Substitution#The_safesubst:_modifier
273        if ( $haveColon && $this->isSafeSubst( $prefix ) ) {
274            $target = substr( $target, strlen( $untrimmedPrefix ) + 1 );
275            array_shift( $pieces );
276            $untrimmedPrefix = $pieces[0];
277            $prefix = trim( $pieces[0] );
278            $haveColon = count( $pieces ) > 1;
279        }
280
281        $env = $this->env;
282        $siteConfig = $env->getSiteConfig();
283
284        // Additional tokens are only justifiable in parser functions scenario
285        if ( !$haveColon && $additionalToks ) {
286            return null;
287        }
288
289        $pfArg = '';
290        if ( $haveColon ) {
291            $pfArg = substr( $target, strlen( $untrimmedPrefix ) + 1 );
292            if ( $additionalToks ) {
293                $pfArg = [ $pfArg ];
294                PHPUtils::pushArray( $pfArg, $additionalToks );
295            }
296        }
297
298        // Check if we have a magic-word variable.
299        $magicWordVar = $siteConfig->getMagicWordForVariable( $prefix ) ??
300            $siteConfig->getMagicWordForVariable( mb_strtolower( $prefix ) );
301        if ( $magicWordVar ) {
302            $state->variableName = $magicWordVar;
303            return [
304                'isVariable' => true,
305                'magicWordType' => $magicWordVar === '!' ? '!' : null,
306                'name' => $magicWordVar,
307                // FIXME: Some made up synthetic title
308                'title' => $env->makeTitleFromURLDecodedStr( "Special:Variable/$magicWordVar" ),
309                'pfArg' => $pfArg,
310                'srcOffsets' => new SourceRange(
311                    $srcOffsets->start + strlen( $untrimmedPrefix ) + 1,
312                    $srcOffsets->end ),
313            ];
314        }
315
316        // FIXME: Checks for msgnw, msg, raw are missing at this point
317
318        $canonicalFunctionName = null;
319        if ( $haveColon ) {
320            $canonicalFunctionName = $siteConfig->getMagicWordForFunctionHook( $prefix );
321        }
322        if ( $canonicalFunctionName === null && $hasHash ) {
323            // If the target starts with a '#' it can't possibly be a template
324            // so this must be a "broken" parser function invocation
325            $canonicalFunctionName = substr( $prefix, 1 );
326            // @todo: Flag this as an author error somehow (T314524)
327        }
328        if ( $canonicalFunctionName !== null ) {
329            $state->parserFunctionName = $canonicalFunctionName;
330            // XXX this is made up.
331            $syntheticTitle = $env->makeTitleFromURLDecodedStr(
332                "Special:ParserFunction/$canonicalFunctionName",
333                $env->getSiteConfig()->canonicalNamespaceId( 'Special' ),
334                true // No exceptions
335            );
336            // Note that parserFunctionName/$canonicalFunctionName is not
337            // necessarily a valid title!  Parsing rules are pretty generous
338            // w/r/t valid parser function names.
339            if ( $syntheticTitle === null ) {
340                $syntheticTitle = $env->makeTitleFromText(
341                    'Special:ParserFunction/unknown'
342                );
343            }
344            return [
345                'isParserFunction' => true,
346                'magicWordType' => null,
347                'name' => $canonicalFunctionName,
348                'title' => $syntheticTitle, // FIXME: Some made up synthetic title
349                'pfArg' => $pfArg,
350                'srcOffsets' => new SourceRange(
351                    $srcOffsets->start + strlen( $untrimmedPrefix ) + 1,
352                    $srcOffsets->end ),
353            ];
354        }
355
356        // We've exhausted the parser-function scenarios, and we still have additional tokens.
357        if ( $additionalToks ) {
358            return null;
359        }
360
361        // `resolveTitle()` adds the namespace prefix when it resolves fragments
362        // and relative titles, and a leading colon should resolve to a template
363        // from the main namespace, hence we omit a default when making a title
364        $namespaceId = strspn( $target, ':#/.' ) ?
365            null : $siteConfig->canonicalNamespaceId( 'template' );
366
367        // Resolve a possibly relative link and
368        // normalize the target before template processing.
369        $title = null;
370        try {
371            $title = $env->resolveTitle( $target );
372        } catch ( TitleException $e ) {
373            // Invalid template target!
374            return null;
375        }
376
377        // Entities in transclusions aren't decoded in the PHP parser
378        // So, treat the title as a url-decoded string!
379        $title = $env->makeTitleFromURLDecodedStr( $title, $namespaceId, true );
380        if ( !$title ) {
381            // Invalid template target!
382            return null;
383        }
384
385        // data-mw.target.href should be a url
386        $state->resolvedTemplateTarget = $env->makeLink( $title );
387
388        return [
389            'magicWordType' => null,
390            'name' => $title->getPrefixedDBKey(),
391            'title' => $title,
392        ];
393    }
394
395    /**
396     * Flatten
397     * @param (Token|string)[] $tokens
398     * @param ?string $prefix
399     * @param Token|string|(Token|string)[] $t
400     * @return array
401     */
402    private function flattenAndAppendToks(
403        array $tokens, ?string $prefix, $t
404    ): array {
405        if ( is_array( $t ) ) {
406            $len = count( $t );
407            if ( $len > 0 ) {
408                if ( $prefix !== null && $prefix !== '' ) {
409                    $tokens[] = $prefix;
410                }
411                PHPUtils::pushArray( $tokens, $t );
412            }
413        } elseif ( is_string( $t ) ) {
414            $len = strlen( $t );
415            if ( $len > 0 ) {
416                if ( $prefix !== null && $prefix !== '' ) {
417                    $tokens[] = $prefix;
418                }
419                $tokens[] = $t;
420            }
421        } else {
422            if ( $prefix !== null && $prefix !== '' ) {
423                $tokens[] = $prefix;
424            }
425            $tokens[] = $t;
426        }
427
428        return $tokens;
429    }
430
431    /**
432     * By default, don't attempt to expand any templates in the wikitext that will be reprocessed.
433     *
434     * @param Token $token
435     * @param bool $expandTemplates
436     * @return TemplateExpansionResult
437     */
438    private function convertToString( Token $token, bool $expandTemplates = false ): TemplateExpansionResult {
439        $frame = $this->manager->getFrame();
440        $tsr = $token->dataParsoid->tsr;
441        $src = substr( $token->dataParsoid->src, 1, -1 );
442        $startOffset = $tsr->start + 1;
443        $srcOffsets = new SourceRange( $startOffset, $startOffset + strlen( $src ) );
444
445        $toks = PipelineUtils::processContentInPipeline(
446            $this->env, $frame, $src, [
447                'pipelineType' => 'wikitext-to-expanded-tokens',
448                'pipelineOpts' => [
449                    'inTemplate' => $this->options['inTemplate'],
450                    'expandTemplates' => $expandTemplates && $this->options['expandTemplates'],
451                ],
452                'sol' => false,
453                'srcOffsets' => $srcOffsets,
454            ]
455        );
456        TokenUtils::stripEOFTkfromTokens( $toks );
457        return new TemplateExpansionResult( array_merge( [ '{' ], $toks, [ '}' ] ), true );
458    }
459
460    /**
461     * Enforce template loops / loop depth limit constraints and emit
462     * error message if constraints are violated.
463     *
464     * @param mixed $target
465     * @param Title $title
466     * @param bool $ignoreLoop
467     * @return ?array
468     */
469    private function enforceTemplateConstraints( $target, Title $title, bool $ignoreLoop ): ?array {
470        $error = $this->manager->getFrame()->loopAndDepthCheck(
471            $title, $this->env->getSiteConfig()->getMaxTemplateDepth(),
472            $ignoreLoop
473        );
474
475        return $error ? [ // Loop detected or depth limit exceeded, abort!
476            new TagTk( 'span', [ new KV( 'class', 'error' ) ] ),
477            $error,
478            new SelfclosingTagTk( 'wikilink', [ new KV( 'href', $target, null, '', '' ) ] ),
479            new EndTagTk( 'span' ),
480        ] : null;
481    }
482
483    /**
484     * Fetch, tokenize and token-transform a template after all arguments and
485     * the target were expanded.
486     *
487     * @param TemplateEncapsulator $state
488     * @param array $resolvedTgt
489     * @param array $attribs
490     * @return TemplateExpansionResult
491     */
492    private function expandTemplateNatively(
493        TemplateEncapsulator $state, array $resolvedTgt, array $attribs
494    ): TemplateExpansionResult {
495        $env = $this->env;
496        $encap = $this->options['expandTemplates'] && $this->wrapTemplates;
497
498        // XXX: wrap attribs in object with .dict() and .named() methods,
499        // and each member (key/value) into object with .tokens(), .dom() and
500        // .wikitext() methods (subclass of Array)
501
502        $target = $resolvedTgt['name'];
503        if ( isset( $resolvedTgt['isParserFunction'] ) || isset( $resolvedTgt['isVariable'] ) ) {
504            // FIXME: HARDCODED to core parser function implementations!
505            // These should go through function hook registrations in the
506            // ParserTests mock setup ideally. But, it is complicated because the
507            // Parsoid core parser function versions have "token" versions
508            // which are incompatible with implementation in FunctionHookHandler
509            // and FunctionArgs. So, we continue down this hacky path for now.
510            if ( $target === '=' ) {
511                $target = 'equal';  // '=' is not a valid character in function names
512            }
513            $target = 'pf_' . $target;
514            // FIXME: Parsoid may not have implemented the parser function natively
515            // Emit an error message, but encapsulate it so it roundtrips back.
516            if ( !is_callable( [ $this->parserFunctions, $target ] ) ) {
517                // FIXME: Consolidate error response format with enforceTemplateConstraints
518                $err = 'Parser function implementation for ' . $target . ' missing in Parsoid.';
519                return new TemplateExpansionResult( [ $err ], false, $encap );
520            }
521
522            $pfAttribs = new Params( $attribs );
523            $pfAttribs->args[0] = new KV(
524                // FIXME: This is bogus, but preserves borked b/c
525                TokenUtils::tokensToString( $resolvedTgt['pfArg'] ), [],
526                $resolvedTgt['srcOffsets']->expandTsrK()
527            );
528            $env->log( 'debug', 'entering prefix', $target, $state->token );
529            $res = call_user_func( [ $this->parserFunctions, $target ],
530                $state->token, $this->manager->getFrame(), $pfAttribs );
531            if ( $this->wrapTemplates ) {
532                $res = $this->parserFunctionsWrapper( $res );
533            }
534            return new TemplateExpansionResult( $res, false, $encap );
535        }
536
537        // Loop detection needs to be enabled since we're doing our own template expansion
538        $error = $this->enforceTemplateConstraints( $target, $resolvedTgt['title'], false );
539        if ( $error ) {
540            // FIXME: Should we be encapsulating here?
541            // Inconsistent with the other place constrainsts are enforced.
542            return new TemplateExpansionResult( $error, false, $encap );
543        }
544
545        // XXX: notes from brion's mediawiki.parser.environment
546        // resolve template name
547        // load template w/ canonical name
548        // load template w/ variant names (language variants)
549
550        // Fetch template source and expand it
551        $src = $this->fetchTemplateAndTitle( $target, $attribs );
552        if ( $src !== null ) {
553            $toks = $this->processTemplateSource(
554                $state->token,
555                [
556                    'name' => $target,
557                    'title' => $resolvedTgt['title'],
558                    'attribs' => array_slice( $attribs, 1 ), // strip template target
559                ],
560                $src
561            );
562            return new TemplateExpansionResult( $toks, true, $encap );
563        } else {
564            // Convert to a wikilink (which will become a redlink after the redlinks pass).
565            $toks = [ new SelfclosingTagTk( 'wikilink' ) ];
566            $hrefSrc = $resolvedTgt['name'];
567            $toks[0]->attribs[] = new KV( 'href', $hrefSrc, null, null, $hrefSrc );
568            return new TemplateExpansionResult( $toks, false, $encap );
569        }
570    }
571
572    /**
573     * Process a fetched template source to a token stream.
574     *
575     * @param Token $token
576     * @param array $tplArgs
577     * @param string $src
578     * @return array
579     */
580    private function processTemplateSource( Token $token, array $tplArgs, string $src ): array {
581        $env = $this->env;
582        $frame = $this->manager->getFrame();
583        if ( $env->hasDumpFlag( 'tplsrc' ) ) {
584            $dump = str_repeat( '=', 28 ) . " template source " .
585                str_repeat( '=', 28 ) . "\n";
586            $dump .= 'TEMPLATE:' . $tplArgs['name'] . 'TRANSCLUSION:' .
587                PHPUtils::jsonEncode( $token->dataParsoid->src ) . "\n";
588            $dump .= str_repeat( '-', 80 ) . "\n";
589            $dump .= $src . "\n";
590            $dump .= str_repeat( '-', 80 ) . "\n";
591            $env->writeDump( $dump );
592        }
593
594        if ( $src === '' ) {
595            return [];
596        }
597
598        $env->log( 'debug', 'TemplateHandler.processTemplateSource',
599            $tplArgs['name'], $tplArgs['attribs'] );
600
601        // Get a nested transformation pipeline for the wikitext that takes
602        // us through stages 1-2, with the appropriate pipeline options set.
603        //
604        // Simply returning the tokenized source here (which may be correct
605        // when using the legacy preprocessor because we don't expect to
606        // tokenize any templates or include directives so skipping those
607        // handlers should be ok) won't work since the options for the pipeline
608        // we're in probably aren't what we want.
609        $toks = PipelineUtils::processContentInPipeline(
610            $env,
611            $frame,
612            $src,
613            [
614                'pipelineType' => 'wikitext-to-expanded-tokens',
615                'pipelineOpts' => [
616                    'inTemplate' => true,
617                    // FIXME: In reality, this is broken for parser tests where
618                    // we expand templates natively. We do want all nested templates
619                    // to be expanded. But, setting this to !usePHPPreProcessor seems
620                    // to break a number of tests. Not pursuing this line of enquiry
621                    // for now since this parserTests vs production distinction will
622                    // disappear with parser integration. We'll just bear the stench
623                    // till that time.
624                    //
625                    // NOTE: No expansion required for nested templates.
626                    'expandTemplates' => false,
627                    'extTag' => $this->options['extTag'] ?? null
628                ],
629                'srcText' => $src,
630                'srcOffsets' => new SourceRange( 0, strlen( $src ) ),
631                'tplArgs' => $tplArgs,
632                // HEADS UP: You might be wondering why we are forcing "sol" => true without
633                // using information about whether the transclusion is used in a SOL context.
634                //
635                // Ex: "foo {{1x|*bar}}"  Here, "*bar" is not in SOL context relative to the
636                // top-level page and so, should it be actually be parsed as a list item?
637                //
638                // So, there is a use-case where one could argue that the sol value here
639                // should be conditioned on the page-level context where "{{1x|*bar}}" showed
640                // up. So, in this example "foo {{1x|*bar}}, sol would be false and in this
641                // example "foo\n{{1x|*bar}}", sol would be true. That is effectively how
642                // the legacy parser behaves. (Ignore T2529 for the moment.)
643                //
644                // But, Parsoid is a different beast. Since the Parsoid/JS days, templates
645                // have been processed asynchronously. So, {{1x|*bar}} would be expanded and
646                // tokenized before even its preceding context might have been processed.
647                // From the start, Parsoid has aimed to decouple the processing of fragment
648                // generators (be it templates, extensions, or something else) from the
649                // processing of the page they are embedded in. This has been the
650                // starting point of many a wikitext 2.0 proposal on mediawiki.org;
651                // see also [[mw:Parsing/Notes/Wikitext_2.0#Implications_of_this_model]].
652                //
653                // The main performance implication is that you can process a transclusion
654                // concurrently *and* cache the output of {{1x|*bar}} since its output is
655                // the same no matter where on the page it appears. Without this decoupled
656                // model, if you got "{{mystery-template-that-takes-30-secs}}{{1x|*bar}}"
657                // you have to wait 30 secs before you get to expand {{1x|*bar}}
658                // because you have to wait and see whether the mystery template will
659                // leave you in SOL state or non-SOL state.
660                //
661                // In a stroke of good luck, wikitext editors seem to have agreed
662                // that it is better for all templates to be expanded in a
663                // consistent SOL state and not be dependent on their context;
664                // turn now to phab task T2529 which (via a fragile hack) tried
665                // to ensure that every template which started with
666                // start-of-line-sensitive markup was evaluated in a
667                // start-of-line context (by hackily inserting a newline).  Not
668                // everyone was satisfied with this hack (see T14974), but it's
669                // been the way things work for over a decade now (as evidenced
670                // by T14974 never having been "fixed").
671                //
672                // So, while we've established we would prefer *not* to use page
673                // context to set the initial SOL value for tokenizing the
674                // template, what *should* the initial SOL value be?
675                //
676                // * Treat every transclusion as a fresh document starting in SOL
677                //   state, ie set "sol" => true always.  This is supported by
678                //   most current wiki use, and is the intent behind the original
679                //   T2529 hack (although that hack left a number of edge cases,
680                //   described below).
681                //
682                // * Use `"sol" => false` for templates -- this was the solution
683                //   rejected by the original T2529 as being contrary to editor
684                //   expectations.
685                //
686                // * In the future, one might allow the template itself to
687                //   specify that its initial SOL state should be, using a
688                //   mechanism similar to what might be necessary for typed
689                //   templates.  This could also address T14974.  This is not
690                //   excluded by Parsoid at this point; but it would probably be
691                //   signaled by a template "return type" which is *not* DOM
692                //   therefore the template wouldn't get parsed "as wikitext"
693                //   (ie, T14974 wants an "attribute-value" return type which is
694                //   a plain string, and some of the wikitext 2.0 proposals
695                //   anticipate a "attribute name/value" dictionary as a possible
696                //   return type).
697                //
698                // In support of using sol=>true as the default initial state,
699                // let's examine the sol-sensitive wikitext constructs, and
700                // implicitly the corner cases left open by the T2529 hack.  (For
701                // non-sol-sensitive constructs, the initial SOL state is
702                // irrelevant.)
703                //
704                //   - SOL-sensitive contructs include lists, headings, indent-pre,
705                //     and table syntax.
706                //   - Of these, only lists, headings, and table syntax are actually handled in
707                //     the PEG tokenizer and are impacted by SOL state.
708                //   - Indent-Pre has its own handler that operates in a full page token context
709                //     and isn't impacted.
710                //   - T2529 effectively means for *#:; (lists) and {| (table start), newlines
711                //     are added which means no matter what value we set here, they will get
712                //     processed in sol state.
713                //   - This leaves us with headings (=), table heading (!), table row (|), and
714                //     table close (|}) syntax that would be impacted by what we set here.
715                //   - Given that table row/heading/close templates are very very common on wikis
716                //     and used for constructing complex tables, sol => true will let us handle
717                //     those without hacks. We aren't fully off the hook there -- see the code
718                //     in TokenStreamPatcher, AttributeExpander, TableFixups that all exist to
719                //     to work around the fact that decoupled processing isn't the wikitext
720                //     default. But, without sol => true, we'll likely be in deeper trouble.
721                //   - But, this can cause some occasional bad parses where "=|!" aren't meant
722                //     to be processed as a sol-wikitext construct.
723                //   - Note also that the workaround for T14974 (ie, the T2529 hack applying
724                //     where sol=false is actually desired) has traditionally been to add an
725                //     initial <nowiki/> which ensures that the "T2529 characters" are not
726                //     initial.  There are a number of alternative mechanisms to accomplish
727                //     this (ie, HTML-encode the first character).
728                //
729                // To honor the spirit of T2529 it seems plausible to try to lint
730                // away the remaining corner cases where T2529 does *not* result
731                // in start-of-line state for template expansion, and to use the
732                // various workarounds for compatibility in the meantime.
733                //
734                // We should also pick *one* of the workarounds for T14974
735                // (probably `<nowiki/>` at the first position in the template),
736                // support that (until a better mechanism exists), and (if
737                // possible) lint away any others.
738                'sol' => true
739            ]
740        );
741
742        return $this->processTemplateTokens( $toks );
743    }
744
745    /**
746     * Process the main template element, including the arguments.
747     *
748     * @param TemplateEncapsulator $state
749     * @param array $tokens
750     * @return array
751     */
752    private function encapTokens( TemplateEncapsulator $state, array $tokens ): array {
753        // Template encapsulation normally wouldn't happen in nested context,
754        // since they should have already been expanded, and indeed we set
755        // expandTemplates === false in processTemplateSource.  However,
756        // extension tags from templates can have content that requires wikitext
757        // parsing and, due to precedence, contain unexpanded templates.
758        //
759        // For example, {{1x|hi<ref>{{1x|ho}}</ref>}}
760        //
761        // Since extensions can require template expansion unconditionally, we can
762        // end up here inTemplate, in which case the substrings of env.page.src
763        // used in getArgInfo are no longer accurate, and so tplarginfo should be
764        // omitted.  Presumably, template wrapping in the dom post processor won't
765        // be happening anyways, so this is unnecessary work as it is.
766        Assert::invariant(
767            $this->wrapTemplates, 'Encapsulating tokens when not wrapping!'
768        );
769        return $state->encapTokens( $tokens );
770    }
771
772    /**
773     * Handle chunk emitted from the input pipeline after feeding it a template.
774     *
775     * @param array $chunk
776     * @return array
777     */
778    private function processTemplateTokens( array $chunk ): array {
779        TokenUtils::stripEOFTkfromTokens( $chunk );
780
781        foreach ( $chunk as $i => $t ) {
782            if ( !$t ) {
783                continue;
784            }
785
786            if ( isset( $t->dataParsoid->tsr ) ) {
787                unset( $t->dataParsoid->tsr );
788            }
789            Assert::invariant( !isset( $t->dataParsoid->tmp->endTSR ),
790                "Expected endTSR to not be set on templated content." );
791            if ( $t instanceof SelfclosingTagTk &&
792                strtolower( $t->getName() ) === 'meta' &&
793                TokenUtils::hasTypeOf( $t, 'mw:Placeholder' )
794            ) {
795                // replace with empty string to avoid metas being foster-parented out
796                $chunk[$i] = '';
797            }
798        }
799
800        // FIXME: What is this stuff here? Why do we care about stripping out comments
801        // so much that we create a new token array for every expanded template?
802        // Unlikely to help perf very much.
803        if ( !$this->options['expandTemplates'] ) {
804            // Ignore comments in template transclusion mode
805            $newChunk = [];
806            for ( $i = 0, $n = count( $chunk ); $i < $n;  $i++ ) {
807                if ( !( $chunk[$i] instanceof CommentTk ) ) {
808                    $newChunk[] = $chunk[$i];
809                }
810            }
811            $chunk = $newChunk;
812        }
813
814        $this->env->log( 'debug', 'TemplateHandler.processTemplateTokens', $chunk );
815        return $chunk;
816    }
817
818    /**
819     * Fetch a template.
820     *
821     * @param string $templateName
822     * @param array $attribs
823     * @return ?string
824     */
825    private function fetchTemplateAndTitle( string $templateName, array $attribs ): ?string {
826        $env = $this->env;
827        if ( isset( $env->pageCache[$templateName] ) ) {
828            return $env->pageCache[$templateName];
829        }
830
831        $start = microtime( true );
832        $pageContent = $env->getDataAccess()->fetchTemplateSource(
833            $env->getPageConfig(),
834            Title::newFromText( $templateName, $env->getSiteConfig() )
835        );
836        if ( $env->profiling() ) {
837            $profile = $env->getCurrentProfile();
838            $profile->bumpMWTime( "TemplateFetch", 1000 * ( microtime( true ) - $start ), "api" );
839            $profile->bumpCount( "TemplateFetch" );
840        }
841
842        // FIXME:
843        // 1. Hard-coded 'main' role
844        return $pageContent ? $pageContent->getContent( 'main' ) : null;
845    }
846
847    /**
848     * @param mixed $tokens
849     * @return bool
850     */
851    private static function hasTemplateToken( $tokens ): bool {
852        if ( is_array( $tokens ) ) {
853            foreach ( $tokens as $t ) {
854                if ( TokenUtils::isTemplateToken( $t ) ) {
855                    return true;
856                }
857            }
858        }
859        return false;
860    }
861
862    /**
863     * Process the special magic word as specified by $resolvedTgt['magicWordType'].
864     * ```
865     * magicWordType === '!' => {{!}} is the magic word
866     * ```
867     * @param bool $atTopLevel
868     * @param TemplateEncapsulator $state
869     * @param array $resolvedTgt
870     * @return TemplateExpansionResult
871     */
872    private function processSpecialMagicWord(
873        bool $atTopLevel, TemplateEncapsulator $state, array $resolvedTgt
874    ): TemplateExpansionResult {
875        $env = $this->env;
876        $tplToken = $state->token;
877
878        // Special case for {{!}} magic word.
879        //
880        // If we tokenized as a magic word, we meant for it to expand to a
881        // string.  The tokenizer has handling for this syntax in table
882        // positions.  However, proceeding to go through template expansion
883        // will reparse it as a table cell token.  Hence this special case
884        // handling to avoid that path.
885        if ( $resolvedTgt['magicWordType'] === '!' ) {
886            // If we're not at the top level, return a table cell. This will always
887            // be the case. Either {{!}} was tokenized as a td, or it was tokenized
888            // as template but the recursive call to fetch its content returns a
889            // single | in an ambiguous context which will again be tokenized as td.
890            // In any case, this should only be relevant for parserTests.
891            if ( empty( $atTopLevel ) ) {
892                $toks = [ new TagTk( 'td' ) ];
893            } else {
894                $toks = [ '|' ];
895            }
896            return new TemplateExpansionResult( $toks, false, (bool)$this->wrapTemplates );
897        }
898
899        throw new UnreachableException(
900            'Unsupported magic word type: ' . ( $resolvedTgt['magicWordType'] ?? 'null' )
901        );
902    }
903
904    private function expandTemplate( TemplateEncapsulator $state ): TemplateExpansionResult {
905        $env = $this->env;
906        $token = $state->token;
907        $expandTemplates = $this->options['expandTemplates'];
908
909        // Since AttributeExpander runs later in the pipeline than TemplateHandler,
910        // if the template name is templated, use our copy of AttributeExpander
911        // to process the first attribute to tokens, and force reprocessing of this
912        // template token since we will then know the actual template target.
913        if ( $expandTemplates && self::hasTemplateToken( $token->attribs[0]->k ) ) {
914            $ret = $this->ae->expandFirstAttribute( $token );
915            $toks = $ret->tokens ?? null;
916            Assert::invariant( $toks && count( $toks ) === 1 && $toks[0] === $token,
917                "Expected only the input token as the return value." );
918        }
919
920        if ( $this->atMaxArticleSize ) {
921            // As described above, if we were already greater than $wgMaxArticleSize
922            // we're going to return the tokens without expanding them.
923            // (This case is where the original article as fetched from the DB
924            // or passed to the API exceeded max article size.)
925            return $this->convertToString( $token );
926        }
927
928        // There's no point in proceeding if we've already hit the maximum inclusion size
929        // XXX should this be combined with the previous test?
930        if ( !$env->bumpWt2HtmlResourceUse( 'wikitextSize', 0 ) ) {
931            // FIXME: The legacy parser would try to make this a link and
932            // elsewhere we'd return the $e->getMessage()
933            // (This case is where the template post-expansion accumulation is
934            // over the maximum wikitext size.)
935            // XXX: It could be combined with the previous test, but we might
936            // want to use different error messages in the future.
937            return $this->convertToString( $token );
938        }
939
940        $toks = null;
941        $text = $token->dataParsoid->src ?? '';
942
943        $tgt = $this->resolveTemplateTarget(
944            $state, $token->attribs[0]->k, $token->attribs[0]->srcOffsets->key
945        );
946
947        if ( $expandTemplates && $tgt === null ) {
948            // Target contains tags, convert template braces and pipes back into text
949            // Re-join attribute tokens with '=' and '|'
950            return $this->convertToString( $token, true );
951        }
952
953        if ( isset( $tgt['magicWordType'] ) ) {
954            return $this->processSpecialMagicWord( $this->atTopLevel, $state, $tgt );
955        }
956
957        $frame = $this->manager->getFrame();
958
959        if ( $env->nativeTemplateExpansionEnabled() ) {
960            // Expand argument keys
961            $atm = new AttributeTransformManager( $frame,
962                [ 'expandTemplates' => false, 'inTemplate' => true ]
963            );
964            $newAttribs = $atm->process( $token->attribs );
965            $target = $newAttribs[0]->k;
966            if ( !$target ) {
967                $env->log( 'debug', 'No template target! ', $newAttribs );
968            }
969            // Resolve the template target again now that the template token's
970            // attributes have been expanded by the AttributeTransformManager
971            $resolvedTgt = $this->resolveTemplateTarget( $state, $target, $newAttribs[0]->srcOffsets->key );
972            if ( $resolvedTgt === null ) {
973                // Target contains tags, convert template braces and pipes back into text
974                // Re-join attribute tokens with '=' and '|'
975                return $this->convertToString( $token, true );
976            } else {
977                return $this->expandTemplateNatively( $state, $resolvedTgt, $newAttribs );
978            }
979        } elseif ( $expandTemplates ) {
980            // Use MediaWiki's preprocessor
981            //
982            // The tokenizer needs to use `text` as the cache key for caching
983            // expanded tokens from the expanded transclusion text that we get
984            // from the preprocessor, since parameter substitution will already
985            // have taken place.
986            //
987            // It's sufficient to pass `[]` in place of attribs since they
988            // won't be used.  In `usePHPPreProcessor`, there is no parameter
989            // substitution coming from the frame.
990
991            /* If $tgt is not null, target will be present. */
992            $templateName = $tgt['name'];
993            $templateTitle = $tgt['title'];
994            // FIXME: This is a source of a lot of issues since templateargs
995            // get looked up from the Frame and yield these tokens which then enter
996            // the token stream. See T301948 and others from wmf.22
997            // $attribs = array_slice( $token->attribs, 1 ); // Strip template name
998            $attribs = [];
999
1000            // We still need to check for limit violations because of the
1001            // higher precedence of extension tags, which can result in nested
1002            // templates even while using the php preprocessor for expansion.
1003            $error = $this->enforceTemplateConstraints( $templateName, $templateTitle, true );
1004            if ( $error ) {
1005                // FIXME: Should we be encapsulating here?
1006                // Inconsistent with the other place constrainsts are enforced.
1007                return new TemplateExpansionResult( $error );
1008            }
1009
1010            // Check if we have an expansion for this template in the cache already
1011            $cachedTransclusion = $env->transclusionCache[$text] ?? null;
1012            if ( $cachedTransclusion ) {
1013                // cache hit: reuse the expansion DOM
1014                // FIXME(SSS): How does this work again for
1015                // templates like {{start table}} and {[end table}}??
1016                return new TemplateExpansionResult(
1017                    PipelineUtils::encapsulateExpansionHTML(
1018                        $env, $token, $cachedTransclusion, [ 'fromCache' => true ]
1019                    )
1020                );
1021            } elseif ( str_starts_with( $text, PipelineUtils::PARSOID_FRAGMENT_PREFIX ) ) {
1022                // See PipelineUtils::pFragmentToParsoidFragmentMarkers()
1023                $pFragment = $env->getPFragment( $text );
1024                $domFragment = $pFragment->asDom(
1025                    new ParsoidExtensionAPI(
1026                        $env, [
1027                            'wt2html' => [
1028                                'frame' => $this->manager->getFrame(),
1029                                'parseOpts' => [
1030                                    // This fragment comes from a template and it is important to set
1031                                    // the 'inTemplate' parse option for it.
1032                                    'inTemplate' => true,
1033                                    // There might be translcusions within this fragment and we want
1034                                    // to expand them. Ex: {{1x|<ref>{{my-tpl}}foo</ref>}}
1035                                    'expandTemplates' => true
1036                                ] + $this->options
1037                            ]
1038                        ]
1039                    )
1040                );
1041                $toks = PipelineUtils::tunnelDOMThroughTokens( $env, $token, $domFragment, [] );
1042                $toks = $this->processTemplateTokens( $toks );
1043                // This is an internal strip marker, it should be wrapped at a
1044                // higher level and we don't need to wrap it again.
1045                $wrapTemplates = false;
1046                return new TemplateExpansionResult( $toks, true, $wrapTemplates );
1047            } else {
1048                if (
1049                    !isset( $tgt['isParserFunction'] ) &&
1050                    !isset( $tgt['isVariable'] ) &&
1051                    !$templateTitle->isExternal() &&
1052                    $templateTitle->isSpecialPage()
1053                ) {
1054                    $domFragment = PipelineUtils::fetchHTML( $env, $text );
1055                    $toks = $domFragment
1056                        ? PipelineUtils::tunnelDOMThroughTokens( $env, $token, $domFragment, [] )
1057                        : [];
1058                    $toks = $this->processTemplateTokens( $toks );
1059                    return new TemplateExpansionResult( $toks, true, $this->wrapTemplates );
1060                }
1061
1062                // Fetch and process the template expansion
1063                $expansion = Wikitext::preprocess( $env, $text );
1064                if ( $expansion['error'] ) {
1065                    return new TemplateExpansionResult(
1066                        [ $expansion['src'] ], false, $this->wrapTemplates
1067                    );
1068                } elseif ( isset( $expansion['fragment'] ) ) {
1069                    $domFragment = $expansion['fragment']->asDom(
1070                        new ParsoidExtensionAPI(
1071                            $env, [
1072                                'wt2html' => [
1073                                    'frame' => $this->manager->getFrame(),
1074                                    'parseOpts' => [
1075                                        // This fragment comes from a template and it is important to set
1076                                        // the 'inTemplate' parse option for it.
1077                                        'inTemplate' => true,
1078                                        // There might be translcusions within this fragment and we want
1079                                        // to expand them. Ex: {{1x|<ref>{{my-tpl}}foo</ref>}}
1080                                        'expandTemplates' => true
1081                                    ] + $this->options
1082                                ]
1083                            ]
1084                        )
1085                    );
1086                    $toks = PipelineUtils::tunnelDOMThroughTokens( $env, $token, $domFragment, [] );
1087                    $toks = $this->processTemplateTokens( $toks );
1088                    return new TemplateExpansionResult( $toks, true, $this->wrapTemplates );
1089                } else {
1090                    $tplToks = $this->processTemplateSource(
1091                        $token,
1092                        [
1093                            'name' => $templateName,
1094                            'title' => $templateTitle,
1095                            'attribs' => $attribs
1096                        ],
1097                        $expansion['src']
1098                    );
1099                    return new TemplateExpansionResult(
1100                        $tplToks, true, $this->wrapTemplates
1101                    );
1102                }
1103            }
1104        } else {
1105            // We don't perform recursive template expansion- something
1106            // template-like that the PHP parser did not expand. This is
1107            // encapsulated already, so just return the plain text.
1108            Assert::invariant( TokenUtils::isTemplateToken( $token ), "Expected template token." );
1109            return $this->convertToString( $token );
1110        }
1111    }
1112
1113    /**
1114     * Main template token handler.
1115     *
1116     * Expands target and arguments (both keys and values) and either directly
1117     * calls or sets up the callback to expandTemplate, which then fetches and
1118     * processes the template.
1119     *
1120     * @param Token $token
1121     * @return TokenHandlerResult
1122     */
1123    private function onTemplate( Token $token ): TokenHandlerResult {
1124        $state = new TemplateEncapsulator(
1125            $this->env, $this->manager->getFrame(), $token, 'mw:Transclusion'
1126        );
1127        $res = $this->expandTemplate( $state );
1128        $toks = $res->tokens;
1129        if ( $res->encap ) {
1130            $toks = $this->encapTokens( $state, $toks );
1131        }
1132        if ( $res->shuttle ) {
1133            // Shuttle tokens to the end of the stage since they've gone through the
1134            // rest of the handlers in the current pipeline in the pipeline above.
1135            $toks = $this->manager->shuttleTokensToEndOfStage( $toks );
1136        }
1137        return new TokenHandlerResult( $toks );
1138    }
1139
1140    /**
1141     * Expand template arguments with tokens from the containing frame.
1142     * @param Token $token
1143     * @return TokenHandlerResult
1144     */
1145    private function onTemplateArg( Token $token ): TokenHandlerResult {
1146        $toks = $this->manager->getFrame()->expandTemplateArg( $token );
1147
1148        if ( $this->wrapTemplates && $this->options['expandTemplates'] ) {
1149            // This is a bare use of template arg syntax at the top level
1150            // outside any template use context.  Wrap this use with RDF attrs.
1151            // so that this chunk can be RT-ed en-masse.
1152            $state = new TemplateEncapsulator(
1153                $this->env, $this->manager->getFrame(), $token, 'mw:Param'
1154            );
1155            $toks = $this->encapTokens( $state, $toks );
1156        }
1157
1158        // Shuttle tokens to the end of the stage since they've gone through the
1159        // rest of the handlers in the current pipeline in the pipeline above.
1160        $toks = $this->manager->shuttleTokensToEndOfStage( $toks );
1161
1162        return new TokenHandlerResult( $toks );
1163    }
1164
1165    public function onTag( Token $token ): ?TokenHandlerResult {
1166        switch ( $token->getName() ) {
1167            case "template":
1168                return $this->onTemplate( $token );
1169            case "templatearg":
1170                return $this->onTemplateArg( $token );
1171            default:
1172                return null;
1173        }
1174    }
1175}