Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
38.36% covered (danger)
38.36%
89 / 232
10.53% covered (danger)
10.53%
2 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
SyntaxHighlight
38.36% covered (danger)
38.36%
89 / 232
10.53% covered (danger)
10.53%
2 / 19
1502.73
0.00% covered (danger)
0.00%
0 / 1
 getMaxLines
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getMaxBytes
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getLexer
46.15% covered (danger)
46.15%
6 / 13
0.00% covered (danger)
0.00%
0 / 1
8.90
 onParserFirstCallInit
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 parserHookSource
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getModuleStyles
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 processContent
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 parserHook
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 sourceToDom
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 unwrap
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 plainCodeWrap
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 highlightInner
64.06% covered (warning)
64.06%
41 / 64
0.00% covered (danger)
0.00%
0 / 1
44.46
 highlight
56.25% covered (warning)
56.25%
27 / 48
0.00% covered (danger)
0.00%
0 / 1
18.37
 makeCacheKeyHash
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 parseHighlightLines
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
 validHighlightRange
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 onContentGetParserOutput
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
42
 onApiFormatHighlight
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 onSoftwareInfo
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 */
18
19namespace MediaWiki\SyntaxHighlight;
20
21use Content;
22use ExtensionRegistry;
23use FormatJson;
24use IContextSource;
25use MediaWiki\Api\Hook\ApiFormatHighlightHook;
26use MediaWiki\Content\Hook\ContentGetParserOutputHook;
27use MediaWiki\Hook\ParserFirstCallInitHook;
28use MediaWiki\Hook\SoftwareInfoHook;
29use MediaWiki\Html\Html;
30use MediaWiki\MediaWikiServices;
31use MediaWiki\Parser\ParserOutput;
32use MediaWiki\Parser\Sanitizer;
33use MediaWiki\Status\Status;
34use MediaWiki\Title\Title;
35use Parser;
36use ParserOptions;
37use RuntimeException;
38use TextContent;
39use WANObjectCache;
40use Wikimedia\Parsoid\DOM\DocumentFragment;
41use Wikimedia\Parsoid\Ext\ExtensionTagHandler;
42use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
43
44class SyntaxHighlight extends ExtensionTagHandler implements
45    ParserFirstCallInitHook,
46    ContentGetParserOutputHook,
47    ApiFormatHighlightHook,
48    SoftwareInfoHook
49{
50
51    /** @var string CSS class for syntax-highlighted code. Public as used by the updateCSS maintenance script. */
52    public const HIGHLIGHT_CSS_CLASS = 'mw-highlight';
53
54    /** @var int Cache version. Increment whenever the HTML changes. */
55    private const CACHE_VERSION = 2;
56
57    /** @var array<string,string> Mapping of MIME-types to lexer names. */
58    private static $mimeLexers = [
59        'text/javascript'  => 'javascript',
60        'application/json' => 'javascript',
61        'text/xml'         => 'xml',
62    ];
63
64    /**
65     * Returns the maximum number of lines that may be selected for highlighting
66     *
67     * @return int
68     */
69    private static function getMaxLines(): int {
70        $config = MediaWikiServices::getInstance()->getMainConfig();
71        return $config->get( 'SyntaxHighlightMaxLines' );
72    }
73
74    /**
75     * Returns the maximum input size for the highlighter
76     *
77     * @return int
78     */
79    private static function getMaxBytes(): int {
80        $config = MediaWikiServices::getInstance()->getMainConfig();
81        return $config->get( 'SyntaxHighlightMaxBytes' );
82    }
83
84    /**
85     * Get the Pygments lexer name for a particular language.
86     *
87     * @param string|null $lang Language name.
88     * @return string|null Lexer name, or null if no matching lexer.
89     */
90    private static function getLexer( $lang ) {
91        static $lexers = null;
92
93        if ( $lang === null ) {
94            return null;
95        }
96
97        $lexers ??= Pygmentize::getLexers();
98
99        $lexer = strtolower( $lang );
100
101        if ( isset( $lexers[$lexer] ) ) {
102            return $lexer;
103        }
104
105        $geshi2pygments = SyntaxHighlightGeSHiCompat::getGeSHiToPygmentsMap();
106
107        // Check if this is a GeSHi lexer name for which there exists
108        // a compatible Pygments lexer with a different name.
109        if ( isset( $geshi2pygments[$lexer] ) ) {
110            $lexer = $geshi2pygments[$lexer];
111            if ( isset( $lexers[$lexer] ) ) {
112                return $lexer;
113            }
114        }
115
116        return null;
117    }
118
119    /**
120     * Register parser hook
121     *
122     * @param Parser $parser
123     */
124    public function onParserFirstCallInit( $parser ) {
125        $parser->setHook( 'source', [ self::class, 'parserHookSource' ] );
126        $parser->setHook( 'syntaxhighlight', [ self::class, 'parserHook' ] );
127    }
128
129    /**
130     * Parser hook for <source> to add deprecated tracking category
131     *
132     * @param ?string $text
133     * @param array $args
134     * @param Parser $parser
135     * @return string
136     */
137    public static function parserHookSource( $text, $args, $parser ) {
138        $parser->addTrackingCategory( 'syntaxhighlight-source-category' );
139        return self::parserHook( $text, $args, $parser );
140    }
141
142    /**
143     * @return string[]
144     */
145    private static function getModuleStyles(): array {
146        return [ 'ext.pygments' ];
147    }
148
149    /**
150     *
151     * @param string $text
152     * @param array $args
153     * @param ?Parser $parser
154     * @return array
155     */
156    private static function processContent( string $text, array $args, ?Parser $parser = null ): array {
157        // Don't trim leading spaces away, just the linefeeds
158        $out = rtrim( trim( $text, "\n" ) );
159        $trackingCats = [];
160
161        // Convert deprecated attributes
162        if ( isset( $args['enclose'] ) ) {
163            if ( $args['enclose'] === 'none' ) {
164                $args['inline'] = true;
165            }
166            unset( $args['enclose'] );
167            $trackingCats[] = 'syntaxhighlight-enclose-category';
168        }
169
170        $lexer = $args['lang'] ?? '';
171
172        $result = self::highlight( $out, $lexer, $args, $parser );
173        if ( !$result->isGood() ) {
174            $trackingCats[] = 'syntaxhighlight-error-category';
175        }
176
177        return [ 'html' => $result->getValue(), 'cats' => $trackingCats ];
178    }
179
180    /**
181     * Parser hook for both <source> and <syntaxhighlight> logic
182     *
183     * @param ?string $text
184     * @param array $args
185     * @param Parser $parser
186     * @return string
187     */
188    public static function parserHook( $text, $args, $parser ) {
189        // Replace strip markers (For e.g. {{#tag:syntaxhighlight|<nowiki>...}})
190        $out = $parser->getStripState()->unstripNoWiki( $text ?? '' );
191
192        $result = self::processContent( $out, $args, $parser );
193        foreach ( $result['cats'] as $cat ) {
194            $parser->addTrackingCategory( $cat );
195        }
196
197        // Register CSS
198        $parser->getOutput()->addModuleStyles( self::getModuleStyles() );
199        if ( !empty( $args['linelinks'] ) ) {
200            $parser->getOutput()->addModules( [ 'ext.pygments.linenumbers' ] );
201        }
202
203        return $result['html'];
204    }
205
206    /** @inheritDoc */
207    public function sourceToDom(
208        ParsoidExtensionAPI $extApi, string $text, array $extArgs
209    ): ?DocumentFragment {
210        $result = self::processContent( $text, $extApi->extArgsToArray( $extArgs ) );
211
212        // FIXME: There is no API method in Parsoid to add tracking categories
213        // So, $result['cats'] is being ignored
214
215        // Register CSS
216        $extApi->addModuleStyles( self::getModuleStyles() );
217
218        return $extApi->htmlToDom( $result['html'] );
219    }
220
221    /**
222     * Unwrap the <div> wrapper of the Pygments output
223     *
224     * @param string $out Output
225     * @return string Unwrapped output
226     */
227    private static function unwrap( string $out ): string {
228        if ( $out !== '' ) {
229            $m = [];
230            if ( preg_match( '/^<div class="?mw-highlight"?>(.*)<\/div>$/s', trim( $out ), $m ) ) {
231                $out = trim( $m[1] );
232            } else {
233                throw new RuntimeException( 'Unexpected output from Pygments encountered' );
234            }
235        }
236        return $out;
237    }
238
239    /**
240     * @param string $code
241     * @param bool $isInline
242     * @return string HTML
243     */
244    private static function plainCodeWrap( $code, $isInline ) {
245        if ( $isInline ) {
246            return htmlspecialchars( $code, ENT_NOQUOTES );
247        }
248
249        return Html::rawElement(
250            'div',
251            [ 'class' => self::HIGHLIGHT_CSS_CLASS ],
252            Html::element( 'pre', [], $code )
253        );
254    }
255
256    /**
257     * @param string $code
258     * @param string|null $lang
259     * @param array $args
260     * @param Parser|null $parser Parser, if generating content to be parsed.
261     * @return Status
262     */
263    private static function highlightInner( $code, $lang = null, $args = [], ?Parser $parser = null ) {
264        $status = new Status;
265
266        $lexer = self::getLexer( $lang );
267        if ( $lexer === null && $lang !== null ) {
268            $status->warning( 'syntaxhighlight-error-unknown-language', $lang );
269        }
270
271        // For empty tag, output nothing instead of empty <pre>.
272        if ( $code === '' ) {
273            $status->value = '';
274            return $status;
275        }
276
277        $length = strlen( $code );
278        if ( strlen( $code ) > self::getMaxBytes() ) {
279            // Disable syntax highlighting
280            $lexer = null;
281            $status->warning(
282                'syntaxhighlight-error-exceeds-size-limit',
283                $length,
284                self::getMaxBytes()
285            );
286        }
287
288        $isInline = isset( $args['inline'] );
289        $showLines = isset( $args['line'] );
290
291        if ( $isInline ) {
292            $code = trim( $code );
293        }
294
295        if ( $lexer === null ) {
296            // When syntax highlighting is disabled..
297            $status->value = self::plainCodeWrap( $code, $isInline );
298            return $status;
299        }
300
301        if ( $parser && !$parser->incrementExpensiveFunctionCount() ) {
302            // Highlighting is expensive, return unstyled
303            $status->value = self::plainCodeWrap( $code, $isInline );
304            return $status;
305        }
306
307        $options = [
308            'cssclass' => self::HIGHLIGHT_CSS_CLASS,
309            'encoding' => 'utf-8',
310        ];
311
312        // Line numbers
313        if ( $showLines ) {
314            $options['linenos'] = 'inline';
315        }
316
317        if ( $lexer === 'php' && strpos( $code, '<?php' ) === false ) {
318            $options['startinline'] = 1;
319        }
320
321        // Highlight specific lines
322        if ( isset( $args['highlight'] ) ) {
323            $lines = self::parseHighlightLines( $args['highlight'] );
324            if ( count( $lines ) ) {
325                $options['hl_lines'] = implode( ' ', $lines );
326            }
327        }
328
329        // Starting line number
330        if ( isset( $args['start'] ) && ctype_digit( $args['start'] ) ) {
331            $options['linenostart'] = (int)$args['start'];
332        }
333
334        if ( !empty( $args['linelinks'] ) ) {
335            $options['linespans'] = $args['linelinks'];
336        }
337
338        if ( $isInline ) {
339            $options['nowrap'] = 1;
340        }
341
342        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
343        $error = null;
344        $output = $cache->getWithSetCallback(
345            $cache->makeGlobalKey( 'highlight', self::makeCacheKeyHash( $code, $lexer, $options ) ),
346            $cache::TTL_MONTH,
347            static function ( $oldValue, &$ttl ) use ( $code, $lexer, $options, &$error ) {
348                try {
349                    return Pygmentize::highlight( $lexer, $code, $options );
350                } catch ( PygmentsException $e ) {
351                    $ttl = WANObjectCache::TTL_UNCACHEABLE;
352                    $error = $e->getMessage();
353                    return null;
354                }
355            }
356        );
357
358        if ( $error !== null || $output === null ) {
359            $status->warning( 'syntaxhighlight-error-pygments-invocation-failure' );
360            if ( $error !== null ) {
361                wfWarn( 'Failed to invoke Pygments: ' . $error );
362            } else {
363                wfWarn( 'Invoking Pygments returned blank output with no error response' );
364            }
365
366            // Fall back to preformatted code without syntax highlighting
367            $output = self::plainCodeWrap( $code, $isInline );
368        }
369
370        $status->value = $output;
371
372        return $status;
373    }
374
375    /**
376     * Highlight a code-block using a particular lexer.
377     *
378     * This produces raw HTML (wrapped by Status), the caller is responsible
379     * for making sure the "ext.pygments" module is loaded in the output.
380     *
381     * @param string $code Code to highlight.
382     * @param string|null $lang Language name, or null to use plain markup.
383     * @param array $args Associative array of additional arguments.
384     *  If it contains a 'line' key, the output will include line numbers.
385     *  If it includes a 'highlight' key, the value will be parsed as a
386     *   comma-separated list of lines and line-ranges to highlight.
387     *  If it contains a 'start' key, the value will be used as the line at which to
388     *   start highlighting.
389     *  If it contains a 'inline' key, the output will not be wrapped in `<div><pre/></div>`.
390     *  If it contains a 'linelinks' key, lines will have links and anchors with a prefix
391     *   of the value. Similar to the lineanchors+linespans features in Pygments.
392     * @param Parser|null $parser Parser, if generating content to be parsed.
393     * @return Status Status object, with HTML representing the highlighted
394     *  code as its value.
395     */
396    public static function highlight( $code, $lang = null, $args = [], ?Parser $parser = null ) {
397        $status = self::highlightInner( $code, $lang, $args, $parser );
398        $output = $status->getValue();
399
400        $isInline = isset( $args['inline'] );
401        $showLines = isset( $args['line'] );
402        $lexer = self::getLexer( $lang );
403
404        // Post-Pygment HTML transformations.
405
406        if ( $showLines ) {
407            $lineReplace = Html::element( 'span', [ 'class' => 'linenos', 'data-line' => '$1' ] );
408            if ( !empty( $args['linelinks'] ) ) {
409                $lineReplace = Html::rawElement(
410                    'a',
411                    [ 'href' => '#' . $args['linelinks'] . '-$1' ],
412                    $lineReplace
413                );
414            }
415            // Convert line numbers to data attributes so they
416            // can be displayed as CSS generated content and be
417            // unselectable in all browsers.
418            $output = preg_replace(
419                '`<span class="linenos">\s*([^<]*)\s*</span>`',
420                $lineReplace,
421                $output
422            );
423        }
424
425        // Allow certain HTML attributes
426        $htmlAttribs = Sanitizer::validateAttributes(
427            $args, array_flip( [ 'style', 'class', 'id' ] )
428        );
429
430        $dir = ( isset( $args['dir'] ) && $args['dir'] === 'rtl' ) ? 'rtl' : 'ltr';
431
432        // Build class list
433        $classList = [];
434        if ( isset( $htmlAttribs['class'] ) ) {
435            $classList[] = $htmlAttribs['class'];
436        }
437        $classList[] = self::HIGHLIGHT_CSS_CLASS;
438        if ( $lexer !== null ) {
439            $classList[] = self::HIGHLIGHT_CSS_CLASS . '-lang-' . $lexer;
440        }
441        $classList[] = 'mw-content-' . $dir;
442        if ( $showLines ) {
443            $classList[] = self::HIGHLIGHT_CSS_CLASS . '-lines';
444        }
445        $htmlAttribs['class'] = implode( ' ', $classList );
446        $htmlAttribs['dir'] = $dir;
447        '@phan-var array{class:string,dir:string} $htmlAttribs';
448
449        if ( $isInline ) {
450            // We've already trimmed the input $code before highlighting,
451            // but pygment's standard out adds a line break afterwards,
452            // which would then be preserved in the paragraph that wraps this,
453            // and become visible as a space. Avoid that.
454            $output = trim( $output );
455
456            // Enforce inlineness. Stray newlines may result in unexpected list and paragraph processing
457            // (also known as doBlockLevels()).
458            $output = str_replace( "\n", ' ', $output );
459            $output = Html::rawElement( 'code', $htmlAttribs, $output );
460        } else {
461            $output = self::unwrap( $output );
462
463            if ( $parser ) {
464                // Use 'nowiki' strip marker to prevent list processing (also known as doBlockLevels()).
465                // However, leave the wrapping <div/> outside to prevent <p/>-wrapping.
466                $marker = $parser::MARKER_PREFIX . '-syntaxhighlightinner-' .
467                    sprintf( '%08X', $parser->mMarkerIndex++ ) . $parser::MARKER_SUFFIX;
468                $parser->getStripState()->addNoWiki( $marker, $output );
469                $output = $marker;
470            }
471
472            $output = Html::openElement( 'div', $htmlAttribs ) .
473                $output .
474                Html::closeElement( 'div' );
475        }
476
477        $status->value = $output;
478        return $status;
479    }
480
481    /**
482     * Construct a cache key for the results of a Pygments invocation.
483     *
484     * @param string $code Code to be highlighted.
485     * @param string $lexer Lexer name.
486     * @param array $options Options array.
487     * @return string Cache key.
488     */
489    private static function makeCacheKeyHash( $code, $lexer, $options ) {
490        $optionString = FormatJson::encode( $options, false, FormatJson::ALL_OK );
491        return md5( "{$code}|{$lexer}|{$optionString}|" . self::CACHE_VERSION );
492    }
493
494    /**
495     * Take an input specifying a list of lines to highlight, returning
496     * a raw list of matching line numbers.
497     *
498     * Input is comma-separated list of lines or line ranges.
499     *
500     * @param string $lineSpec
501     * @return int[] Line numbers.
502     */
503    protected static function parseHighlightLines( $lineSpec ) {
504        $lines = [];
505        $values = array_map( 'trim', explode( ',', $lineSpec ) );
506        foreach ( $values as $value ) {
507            if ( ctype_digit( $value ) ) {
508                $lines[] = (int)$value;
509            } elseif ( strpos( $value, '-' ) !== false ) {
510                [ $start, $end ] = array_map( 'intval', explode( '-', $value ) );
511                if ( self::validHighlightRange( $start, $end ) ) {
512                    for ( $i = $start; $i <= $end; $i++ ) {
513                        $lines[] = $i;
514                    }
515                }
516            }
517            if ( count( $lines ) > self::getMaxLines() ) {
518                $lines = array_slice( $lines, 0, self::getMaxLines() );
519                break;
520            }
521        }
522        return $lines;
523    }
524
525    /**
526     * Validate a provided input range
527     * @param int $start
528     * @param int $end
529     * @return bool
530     */
531    protected static function validHighlightRange( $start, $end ) {
532        // Since we're taking this tiny range and producing a an
533        // array of every integer between them, it would be trivial
534        // to DoS the system by asking for a huge range.
535        // Impose an arbitrary limit on the number of lines in a
536        // given range to reduce the impact.
537        return $start > 0 &&
538            $start < $end &&
539            $end - $start < self::getMaxLines();
540    }
541
542    /**
543     * Hook into Content::getParserOutput to provide syntax highlighting for
544     * script content.
545     *
546     * @param Content $content
547     * @param Title $title
548     * @param int $revId
549     * @param ParserOptions $options
550     * @param bool $generateHtml
551     * @param ParserOutput &$parserOutput
552     * @return bool
553     * @since MW 1.21
554     */
555    public function onContentGetParserOutput( $content, $title,
556        $revId, $options, $generateHtml, &$parserOutput
557    ) {
558        global $wgTextModelsToParse;
559
560        // Hope that the "SyntaxHighlightModels" attribute does not contain silly types.
561        if ( !( $content instanceof TextContent ) ) {
562            // Oops! Non-text content? Let MediaWiki handle this.
563            return true;
564        }
565
566        if ( !$generateHtml ) {
567            // Nothing special for us to do, let MediaWiki handle this.
568            return true;
569        }
570
571        // Determine the SyntaxHighlight language from the page's
572        // content model. Extensions can extend the default CSS/JS
573        // mapping by setting the SyntaxHighlightModels attribute.
574        $extension = ExtensionRegistry::getInstance();
575        $models = $extension->getAttribute( 'SyntaxHighlightModels' ) + [
576            CONTENT_MODEL_CSS => 'css',
577            CONTENT_MODEL_JAVASCRIPT => 'javascript',
578        ];
579        $model = $content->getModel();
580        if ( !isset( $models[$model] ) ) {
581            // We don't care about this model, carry on.
582            return true;
583        }
584        $lexer = $models[$model];
585        $text = $content->getText();
586
587        // Parse using the standard parser to get links etc. into the database, HTML is replaced below.
588        // We could do this using $content->fillParserOutput(), but alas it is 'protected'.
589        if ( in_array( $model, $wgTextModelsToParse, true ) ) {
590            $parserOutput = MediaWikiServices::getInstance()->getParser()
591                ->parse( $text, $title, $options, true, true, $revId );
592        }
593
594        $status = self::highlight( $text, $lexer, [ 'line' => true, 'linelinks' => 'L' ] );
595        if ( !$status->isOK() ) {
596            return true;
597        }
598        $out = $status->getValue();
599
600        $parserOutput->addModuleStyles( self::getModuleStyles() );
601        $parserOutput->addModules( [ 'ext.pygments.linenumbers' ] );
602        $parserOutput->setText( $out );
603
604        // Inform MediaWiki that we have parsed this page and it shouldn't mess with it.
605        return false;
606    }
607
608    /**
609     * Hook to provide syntax highlighting for API pretty-printed output
610     *
611     * @param IContextSource $context
612     * @param string $text
613     * @param string $mime
614     * @param string $format
615     * @since MW 1.24
616     * @return bool
617     */
618    public function onApiFormatHighlight( $context, $text, $mime, $format ) {
619        if ( !isset( self::$mimeLexers[$mime] ) ) {
620            return true;
621        }
622
623        $lexer = self::$mimeLexers[$mime];
624        $status = self::highlight( $text, $lexer );
625        if ( !$status->isOK() ) {
626            return true;
627        }
628
629        $out = $status->getValue();
630        if ( preg_match( '/^<pre([^>]*)>/i', $out, $m ) ) {
631            $attrs = Sanitizer::decodeTagAttributes( $m[1] );
632            $attrs['class'] .= ' api-pretty-content';
633            $encodedAttrs = Sanitizer::safeEncodeTagAttributes( $attrs );
634            $out = '<pre' . $encodedAttrs . '>' . substr( $out, strlen( $m[0] ) );
635        }
636        $output = $context->getOutput();
637        $output->addModuleStyles( self::getModuleStyles() );
638        $output->addHTML( '<div dir="ltr">' . $out . '</div>' );
639
640        // Inform MediaWiki that we have parsed this page and it shouldn't mess with it.
641        return false;
642    }
643
644    /**
645     * Hook to add Pygments version to Special:Version
646     *
647     * @see https://www.mediawiki.org/wiki/Manual:Hooks/SoftwareInfo
648     * @param array &$software
649     */
650    public function onSoftwareInfo( &$software ) {
651        try {
652            $software['[https://pygments.org/ Pygments]'] = Pygmentize::getVersion();
653        } catch ( PygmentsException $e ) {
654            // pass
655        }
656    }
657}