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