Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.05% covered (warning)
78.05%
64 / 82
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
MessageParser
78.05% covered (warning)
78.05%
64 / 82
50.00% covered (danger)
50.00%
5 / 10
30.09
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 transform
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 parse
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 parseWithoutPostprocessing
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getOptions
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 createOptions
66.67% covered (warning)
66.67%
12 / 18
0.00% covered (danger)
0.00%
0 / 1
4.59
 normalizeTargetLanguage
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getPlaceholderTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 acquireParser
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 releaseParser
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
2.86
1<?php
2
3namespace MediaWiki\Language;
4
5use LogicException;
6use MediaWiki\Context\RequestContext;
7use MediaWiki\MainConfigNames;
8use MediaWiki\MediaWikiServices;
9use MediaWiki\OutputTransform\OutputTransformPipeline;
10use MediaWiki\Page\PageReference;
11use MediaWiki\Page\PageReferenceValue;
12use MediaWiki\Parser\Parser;
13use MediaWiki\Parser\ParserFactory;
14use MediaWiki\Parser\ParserOptions;
15use MediaWiki\Parser\ParserOutput;
16use MediaWiki\StubObject\StubObject;
17use MediaWiki\StubObject\StubUserLang;
18use Psr\Log\LoggerInterface;
19
20/**
21 * Service for transformation of interface message text.
22 *
23 * @since 1.44
24 */
25class MessageParser {
26    private const DEPTH_EXCEEDED_MESSAGE =
27        '<span class="error">Message parse depth limit exceeded</span>';
28
29    private ParserFactory $parserFactory;
30    private OutputTransformPipeline $outputPipeline;
31    private LanguageFactory $langFactory;
32    private LoggerInterface $logger;
33
34    /** @var Parser[] Cached Parser objects */
35    private array $parsers = [];
36    /** @var ParserOptions[] Parser options associated with each Parser */
37    private array $parserOptions = [];
38    /** @var int Index into $this->parsers for the active Parser */
39    private int $curParser = -1;
40
41    /**
42     * Parsing some messages may require parsing another message first, due to special page
43     * transclusion and some hooks (T372891). This constant is the limit of nesting depth where
44     * we'll display an error instead of the other message.
45     */
46    private const MAX_PARSER_DEPTH = 5;
47
48    public function __construct(
49        ParserFactory $parserFactory,
50        OutputTransformPipeline $outputPipeline,
51        LanguageFactory $languageFactory,
52        LoggerInterface $logger
53    ) {
54        $this->parserFactory = $parserFactory;
55        $this->outputPipeline = $outputPipeline;
56        $this->langFactory = $languageFactory;
57        $this->logger = $logger;
58    }
59
60    /**
61     * Run message text through the preprocessor, expanding parser functions
62     *
63     * @param string $message
64     * @param bool $interface
65     * @param Language|string|null $language Language for {{PLURAL:}} etc., or
66     *   null for the user or content language depending on $interface
67     * @param PageReference|null $page
68     * @return string
69     */
70    public function transform(
71        $message,
72        $interface = false,
73        $language = null,
74        ?PageReference $page = null
75    ) {
76        // Avoid creating parser if nothing to transform
77        if ( !str_contains( $message, '{{' ) ) {
78            return $message;
79        }
80
81        $parser = $this->acquireParser();
82        if ( !$parser ) {
83            return self::DEPTH_EXCEEDED_MESSAGE;
84        }
85
86        $popts = $this->getOptions( $interface, $language );
87        $page ??= $this->getPlaceholderTitle();
88
89        try {
90            return $parser->transformMsg( $message, $popts, $page );
91        } finally {
92            $this->releaseParser( $parser );
93        }
94    }
95
96    /**
97     * @param string $text
98     * @param ?PageReference $contextPage The context page, or null to use a placeholder
99     * @param bool $lineStart Whether this should be parsed in start-of-line context
100     * @param bool $interface Whether this is an interface message
101     * @param Language|StubUserLang|string|null $language Language for {{PLURAL:}} etc., or
102     *   null for the user or content language depending on $interface
103     * @return ParserOutput
104     */
105    public function parse(
106        string $text,
107        ?PageReference $contextPage = null,
108        bool $lineStart = true,
109        bool $interface = false,
110        $language = null
111    ): ParserOutput {
112        $options = [
113            'allowTOC' => false,
114            // Wrapping messages in an extra <div> is probably not expected. If
115            // they're outside the content area they probably shouldn't be
116            // targeted by CSS that's targeting the parser output, and if
117            // they're inside they already are from the outer div.
118            'unwrap' => true,
119            'userLang' => $language,
120        ];
121        $parser = $this->acquireParser();
122        if ( !$parser ) {
123            return new ParserOutput( self::DEPTH_EXCEEDED_MESSAGE );
124        }
125        $popts = $this->getOptions( $interface, $language );
126        $contextPage ??= $this->getPlaceholderTitle();
127
128        try {
129            $po = $parser->parse( $text, $contextPage, $popts, $lineStart );
130            // Run the post-processing pipeline
131            return $this->outputPipeline->run( $po, $popts, $options );
132        } finally {
133            $this->releaseParser( $parser );
134        }
135    }
136
137    /**
138     * @param string $text
139     * @param ?PageReference $page The context title, or null to use a placeholder
140     * @param bool $lineStart Whether this is at the start of a line
141     * @param bool $interface Whether this is an interface message
142     * @param Language|StubUserLang|string|null $language Target language
143     * @return ParserOutput
144     */
145    public function parseWithoutPostprocessing(
146        $text,
147        ?PageReference $page = null,
148        $lineStart = true,
149        $interface = false,
150        $language = null
151    ): ParserOutput {
152        $parser = $this->acquireParser();
153        if ( !$parser ) {
154            return new ParserOutput( self::DEPTH_EXCEEDED_MESSAGE );
155        }
156        $popts = $this->getOptions( $interface, $language );
157        $page ??= $this->getPlaceholderTitle();
158
159        try {
160            return $parser->parse( $text, $page, $popts, $lineStart );
161        } finally {
162            $this->releaseParser( $parser );
163        }
164    }
165
166    /**
167     * Configure parser options
168     *
169     * @param bool $interface
170     * @param Language|StubUserLang|string|null $targetLanguage
171     * @return ParserOptions
172     */
173    private function getOptions( $interface, $targetLanguage ): ParserOptions {
174        if ( $this->curParser < 0 ) {
175            throw new LogicException( 'getOptions must be called after acquireParser' );
176        }
177        if ( isset( $this->parserOptions[$this->curParser] ) ) {
178            $popts = $this->parserOptions[$this->curParser];
179        } else {
180            $popts = $this->createOptions( $this->curParser );
181        }
182        $popts->setInterfaceMessage( $interface );
183        $popts->setTargetLanguage( $this->normalizeTargetLanguage( $targetLanguage ) );
184        $popts->setSuppressSectionEditLinks();
185        return $popts;
186    }
187
188    /**
189     * Create and maybe cache a ParserOptions object
190     *
191     * @param int $cacheIndex
192     * @return ParserOptions
193     */
194    private function createOptions( $cacheIndex ): ParserOptions {
195        $context = RequestContext::getMain();
196        $user = $context->getUser();
197        $useParsoidMessages = MediaWikiServices::getInstance()->getMainConfig()->get(
198            MainConfigNames::UseParsoidMessages
199        );
200        if ( !$user->isSafeToLoad() ) {
201            // It isn't safe to use the context user yet, so don't try to get a
202            // ParserOptions for it. And don't cache this ParserOptions
203            // either.
204            $po = ParserOptions::newFromAnon();
205            $po->setAllowUnsafeRawHtml( false );
206            if ( $useParsoidMessages !== null ) {
207                $po->setUseParsoid( $useParsoidMessages );
208            }
209            return $po;
210        }
211
212        $po = ParserOptions::newFromContext( $context );
213        $po->setIsMessage( true );
214        // Messages may take parameters that could come
215        // from malicious sources. As a precaution, disable
216        // the <html> parser tag when parsing messages.
217        $po->setAllowUnsafeRawHtml( false );
218        if ( $useParsoidMessages !== null ) {
219            $po->setUseParsoid( $useParsoidMessages );
220        }
221        $this->parserOptions[$cacheIndex] = $po;
222        return $po;
223    }
224
225    /**
226     * @param Language|string|null $lang
227     * @return Language|null
228     */
229    private function normalizeTargetLanguage( $lang ): ?Language {
230        if ( is_string( $lang ) ) {
231            return $this->langFactory->getLanguage( $lang );
232        } elseif ( $lang !== null ) {
233            StubObject::unstub( $lang );
234        }
235        return $lang;
236    }
237
238    private function getPlaceholderTitle(): PageReference {
239        return PageReferenceValue::localReference( NS_SPECIAL, 'Badtitle/MessageParser' );
240    }
241
242    /**
243     * Attempt to get a free parser from the cache. If none exists, create one,
244     * up to a limit of MAX_PARSER_DEPTH. If the limit is exceeded, return null.
245     *
246     * If a parser is returned, it must be released with releaseParser().
247     *
248     * @return Parser|null
249     */
250    private function acquireParser(): ?Parser {
251        $index = $this->curParser + 1;
252        if ( $index >= self::MAX_PARSER_DEPTH ) {
253            $this->logger->debug( __METHOD__ . ": Refusing to create a new parser with index {$index}" );
254            return null;
255        }
256        $parser = $this->parsers[ $index ] ?? null;
257        if ( !$parser ) {
258            $this->logger->debug( __METHOD__ . ": Creating a new parser with index {$index}" );
259            $parser = $this->parserFactory->create();
260        }
261        $this->parsers[ $index ] = $parser;
262        $this->curParser = $index;
263        return $parser;
264    }
265
266    /**
267     * Release a parser previously acquired by acquireParser().
268     *
269     * @param Parser $parser
270     */
271    private function releaseParser( Parser $parser ) {
272        if ( $this->parsers[$this->curParser] !== $parser ) {
273            throw new LogicException( 'releaseParser called with the wrong ' .
274                "parser instance: #{$this->curParser} = " .
275                gettype( $this->parsers[$this->curParser] ) );
276        }
277        $this->curParser--;
278    }
279
280}