wikimedia/mediawiki-core

View on GitHub
includes/parser/ParserOptions.php

Summary

Maintainability
F
4 days
Test Coverage
<?php
/**
 * Options for the PHP parser
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
 * http://www.gnu.org/copyleft/gpl.html
 *
 * @file
 * @ingroup Parser
 */

use MediaWiki\Context\IContextSource;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Parser\Parser;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\StubObject\StubObject;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentity;
use MediaWiki\Utils\MWTimestamp;
use Wikimedia\ScopedCallback;

/**
 * @brief Set options of the Parser
 *
 * How to add an option in core:
 *  1. Add it to one of the arrays in ParserOptions::setDefaults()
 *  2. If necessary, add an entry to ParserOptions::$inCacheKey
 *  3. Add a getter and setter in the section for that.
 *
 * How to add an option in an extension:
 *  1. Use the 'ParserOptionsRegister' hook to register it.
 *  2. Where necessary, use $popt->getOption() and $popt->setOption()
 *     to access it.
 *
 * @ingroup Parser
 */
class ParserOptions {

    /**
     * Default values for all options that are relevant for caching.
     * @see self::getDefaults()
     * @var array|null
     */
    private static $defaults = null;

    /**
     * Lazy-loaded options
     * @var callable[]|null
     */
    private static $lazyOptions = null;

    /**
     * Initial lazy-loaded options (before hook)
     * @var callable[]
     */
    private static $initialLazyOptions = [
        'dateformat' => [ __CLASS__, 'initDateFormat' ],
        'speculativeRevId' => [ __CLASS__, 'initSpeculativeRevId' ],
        'speculativePageId' => [ __CLASS__, 'initSpeculativePageId' ],
    ];

    /**
     * Specify options that are included in the cache key
     * @var array|null
     */
    private static $cacheVaryingOptionsHash = null;

    /**
     * Initial inCacheKey options (before hook)
     * @var array
     */
    private static $initialCacheVaryingOptionsHash = [
        'dateformat' => true,
        'thumbsize' => true,
        'printable' => true,
        'userlang' => true,
        'useParsoid' => true,
    ];

    /**
     * Specify pseudo-options that are actually callbacks.
     * These must be ignored when checking for cacheability.
     * @var array
     */
    private static $callbacks = [
        'currentRevisionRecordCallback' => true,
        'templateCallback' => true,
        'speculativeRevIdCallback' => true,
        'speculativePageIdCallback' => true,
    ];

    /**
     * Current values for all options that are relevant for caching.
     * @var array
     */
    private $options;

    /**
     * Timestamp used for {{CURRENTDAY}} etc.
     * @var string|null
     * @note Caching based on parse time is handled externally
     */
    private $mTimestamp;

    /**
     * Stored user object
     * @var UserIdentity
     * @todo Track this for caching somehow without fragmenting the cache
     */
    private $mUser;

    /**
     * Function to be called when an option is accessed.
     * @var callable|null
     * @note Used for collecting used options, does not affect caching
     */
    private $onAccessCallback = null;

    /**
     * If the page being parsed is a redirect, this should hold the redirect
     * target.
     * @var Title|null
     * @todo Track this for caching somehow
     */
    private $redirectTarget = null;

    /**
     * Appended to the options hash
     */
    private $mExtraKey = '';

    /**
     * The reason for rendering the content.
     * @var string
     */
    private $renderReason = 'unknown';

    /**
     * Fetch an option and track that is was accessed
     * @since 1.30
     * @param string $name Option name
     * @return mixed
     */
    public function getOption( $name ) {
        if ( !array_key_exists( $name, $this->options ) ) {
            throw new InvalidArgumentException( "Unknown parser option $name" );
        }

        $this->lazyLoadOption( $name );
        $this->optionUsed( $name );
        return $this->options[$name];
    }

    /**
     * @param string $name Lazy load option without tracking usage
     */
    private function lazyLoadOption( $name ) {
        $lazyOptions = self::getLazyOptions();
        if ( isset( $lazyOptions[$name] ) && $this->options[$name] === null ) {
            $this->options[$name] = call_user_func( $lazyOptions[$name], $this, $name );
        }
    }

    /**
     * Resets lazy loaded options to null in the provided $options array
     * @param array $options
     * @return array
     */
    private function nullifyLazyOption( array $options ): array {
        return array_fill_keys( array_keys( self::getLazyOptions() ), null ) + $options;
    }

    /**
     * Get lazy-loaded options.
     *
     * This array should be initialised by the constructor. The return type
     * hint is used as an assertion to ensure this has happened and to coerce
     * the type for static analysis.
     *
     * @internal Public for testing only
     *
     * @return array
     */
    public static function getLazyOptions(): array {
        // Trigger a call to the 'ParserOptionsRegister' hook if it hasn't
        // already been called.
        if ( self::$lazyOptions === null ) {
            self::getDefaults();
        }
        return self::$lazyOptions;
    }

    /**
     * Get cache varying options, with the name of the option in the key, and a
     * boolean in the value which indicates whether the cache is indeed varied.
     *
     * @see self::allCacheVaryingOptions()
     *
     * @return array
     */
    private static function getCacheVaryingOptionsHash(): array {
        // Trigger a call to the 'ParserOptionsRegister' hook if it hasn't
        // already been called.
        if ( self::$cacheVaryingOptionsHash === null ) {
            self::getDefaults();
        }
        return self::$cacheVaryingOptionsHash;
    }

    /**
     * Set an option, generically
     * @since 1.30
     * @param string $name Option name
     * @param mixed $value New value. Passing null will set null, unlike many
     *  of the existing accessors which ignore null for historical reasons.
     * @return mixed Old value
     */
    public function setOption( $name, $value ) {
        if ( !array_key_exists( $name, $this->options ) ) {
            throw new InvalidArgumentException( "Unknown parser option $name" );
        }
        $old = $this->options[$name];
        $this->options[$name] = $value;
        return $old;
    }

    /**
     * Legacy implementation
     * @since 1.30 For implementing legacy setters only. Don't use this in new code.
     * @deprecated since 1.30
     * @param string $name Option name
     * @param mixed $value New value. Passing null does not set the value.
     * @return mixed Old value
     */
    protected function setOptionLegacy( $name, $value ) {
        if ( !array_key_exists( $name, $this->options ) ) {
            throw new InvalidArgumentException( "Unknown parser option $name" );
        }
        return wfSetVar( $this->options[$name], $value );
    }

    /**
     * Whether to extract interlanguage links
     *
     * When true, interlanguage links will be returned by
     * ParserOutput::getLanguageLinks() instead of generating link HTML.
     *
     * @return bool
     */
    public function getInterwikiMagic() {
        return $this->getOption( 'interwikiMagic' );
    }

    /**
     * Specify whether to extract interlanguage links
     * @param bool|null $x New value (null is no change)
     * @return bool Old value
     */
    public function setInterwikiMagic( $x ) {
        return $this->setOptionLegacy( 'interwikiMagic', $x );
    }

    /**
     * Allow all external images inline?
     * @return bool
     */
    public function getAllowExternalImages() {
        return $this->getOption( 'allowExternalImages' );
    }

    /**
     * Allow all external images inline?
     * @param bool|null $x New value (null is no change)
     * @return bool Old value
     * @deprecated since 1.35; per-parser configuration of image handling via
     * parser options is deprecated. Use site configuration.
     */
    public function setAllowExternalImages( $x ) {
        wfDeprecated( __METHOD__, '1.35' );
        return $this->setOptionLegacy( 'allowExternalImages', $x );
    }

    /**
     * External images to allow
     *
     * When self::getAllowExternalImages() is false
     *
     * @return string|string[] URLs to allow
     */
    public function getAllowExternalImagesFrom() {
        return $this->getOption( 'allowExternalImagesFrom' );
    }

    /**
     * External images to allow
     *
     * When self::getAllowExternalImages() is false
     *
     * @param string|string[]|null $x New value (null is no change)
     * @return string|string[] Old value
     * @deprecated since 1.35; per-parser configuration of image handling via
     * parser options is deprecated. Use site configuration.
     */
    public function setAllowExternalImagesFrom( $x ) {
        wfDeprecated( __METHOD__, '1.35' );
        return $this->setOptionLegacy( 'allowExternalImagesFrom', $x );
    }

    /**
     * Use the on-wiki external image whitelist?
     * @return bool
     */
    public function getEnableImageWhitelist() {
        return $this->getOption( 'enableImageWhitelist' );
    }

    /**
     * Use the on-wiki external image whitelist?
     * @param bool|null $x New value (null is no change)
     * @return bool Old value
     * @deprecated since 1.35; per-parser configuration of image handling via
     * parser options is deprecated. Use site configuration.
     */
    public function setEnableImageWhitelist( $x ) {
        wfDeprecated( __METHOD__, '1.35' );
        return $this->setOptionLegacy( 'enableImageWhitelist', $x );
    }

    /**
     * Allow inclusion of special pages?
     * @return bool
     */
    public function getAllowSpecialInclusion() {
        return $this->getOption( 'allowSpecialInclusion' );
    }

    /**
     * Allow inclusion of special pages?
     * @param bool|null $x New value (null is no change)
     * @return bool Old value
     */
    public function setAllowSpecialInclusion( $x ) {
        return $this->setOptionLegacy( 'allowSpecialInclusion', $x );
    }

    /**
     * Parsing an interface message?
     * @return bool
     */
    public function getInterfaceMessage() {
        return $this->getOption( 'interfaceMessage' );
    }

    /**
     * Parsing an interface message?
     * @param bool|null $x New value (null is no change)
     * @return bool Old value
     */
    public function setInterfaceMessage( $x ) {
        return $this->setOptionLegacy( 'interfaceMessage', $x );
    }

    /**
     * Target language for the parse
     * @return Language|null
     */
    public function getTargetLanguage() {
        return $this->getOption( 'targetLanguage' );
    }

    /**
     * Target language for the parse
     * @param Language|null $x New value
     * @return Language|null Old value
     */
    public function setTargetLanguage( $x ) {
        return $this->setOption( 'targetLanguage', $x );
    }

    /**
     * Maximum size of template expansions, in bytes
     * @return int
     */
    public function getMaxIncludeSize() {
        return $this->getOption( 'maxIncludeSize' );
    }

    /**
     * Maximum size of template expansions, in bytes
     * @param int|null $x New value (null is no change)
     * @return int Old value
     */
    public function setMaxIncludeSize( $x ) {
        return $this->setOptionLegacy( 'maxIncludeSize', $x );
    }

    /**
     * Maximum number of nodes touched by PPFrame::expand()
     * @return int
     */
    public function getMaxPPNodeCount() {
        return $this->getOption( 'maxPPNodeCount' );
    }

    /**
     * Maximum number of nodes touched by PPFrame::expand()
     * @param int|null $x New value (null is no change)
     * @return int Old value
     */
    public function setMaxPPNodeCount( $x ) {
        return $this->setOptionLegacy( 'maxPPNodeCount', $x );
    }

    /**
     * Maximum recursion depth in PPFrame::expand()
     * @return int
     */
    public function getMaxPPExpandDepth() {
        return $this->getOption( 'maxPPExpandDepth' );
    }

    /**
     * Maximum recursion depth for templates within templates
     * @return int
     * @internal Only used by Parser (T318826)
     */
    public function getMaxTemplateDepth() {
        return $this->getOption( 'maxTemplateDepth' );
    }

    /**
     * Maximum recursion depth for templates within templates
     * @param int|null $x New value (null is no change)
     * @return int Old value
     * @internal Only used by ParserTestRunner (T318826)
     */
    public function setMaxTemplateDepth( $x ) {
        return $this->setOptionLegacy( 'maxTemplateDepth', $x );
    }

    /**
     * Maximum number of calls per parse to expensive parser functions
     * @since 1.20
     * @return int
     */
    public function getExpensiveParserFunctionLimit() {
        return $this->getOption( 'expensiveParserFunctionLimit' );
    }

    /**
     * Maximum number of calls per parse to expensive parser functions
     * @since 1.20
     * @param int|null $x New value (null is no change)
     * @return int Old value
     */
    public function setExpensiveParserFunctionLimit( $x ) {
        return $this->setOptionLegacy( 'expensiveParserFunctionLimit', $x );
    }

    /**
     * Remove HTML comments
     * @warning Only applies to preprocess operations
     * @return bool
     */
    public function getRemoveComments() {
        return $this->getOption( 'removeComments' );
    }

    /**
     * Remove HTML comments
     * @warning Only applies to preprocess operations
     * @param bool|null $x New value (null is no change)
     * @return bool Old value
     */
    public function setRemoveComments( $x ) {
        return $this->setOptionLegacy( 'removeComments', $x );
    }

    /**
     * @deprecated since 1.38. This does nothing now, to control limit reporting
     * please provide 'includeDebugInfo' option to ParserOutput::getText.
     *
     * Enable limit report in an HTML comment on output
     * @return bool
     */
    public function getEnableLimitReport() {
        return false;
    }

    /**
     * @deprecated since 1.38. This does nothing now, to control limit reporting
     * please provide 'includeDebugInfo' option to ParserOutput::getText.
     *
     * Enable limit report in an HTML comment on output
     * @param bool|null $x New value (null is no change)
     * @return bool Old value
     */
    public function enableLimitReport( $x = true ) {
        return false;
    }

    /**
     * Clean up signature texts?
     * @see Parser::cleanSig
     * @return bool
     */
    public function getCleanSignatures() {
        return $this->getOption( 'cleanSignatures' );
    }

    /**
     * Clean up signature texts?
     * @see Parser::cleanSig
     * @param bool|null $x New value (null is no change)
     * @return bool Old value
     */
    public function setCleanSignatures( $x ) {
        return $this->setOptionLegacy( 'cleanSignatures', $x );
    }

    /**
     * Target attribute for external links
     * @return string|false
     * @internal Only set by installer (T317647)
     */
    public function getExternalLinkTarget() {
        return $this->getOption( 'externalLinkTarget' );
    }

    /**
     * Target attribute for external links
     * @param string|false|null $x New value (null is no change)
     * @return string Old value
     * @internal Only used by installer (T317647)
     */
    public function setExternalLinkTarget( $x ) {
        return $this->setOptionLegacy( 'externalLinkTarget', $x );
    }

    /**
     * Whether content conversion should be disabled
     * @return bool
     */
    public function getDisableContentConversion() {
        return $this->getOption( 'disableContentConversion' );
    }

    /**
     * Whether content conversion should be disabled
     * @param bool|null $x New value (null is no change)
     * @return bool Old value
     */
    public function disableContentConversion( $x = true ) {
        return $this->setOptionLegacy( 'disableContentConversion', $x );
    }

    /**
     * Whether title conversion should be disabled
     * @return bool
     */
    public function getDisableTitleConversion() {
        return $this->getOption( 'disableTitleConversion' );
    }

    /**
     * Whether title conversion should be disabled
     * @param bool|null $x New value (null is no change)
     * @return bool Old value
     */
    public function disableTitleConversion( $x = true ) {
        return $this->setOptionLegacy( 'disableTitleConversion', $x );
    }

    /**
     * Thumb size preferred by the user.
     * @return int
     */
    public function getThumbSize() {
        return $this->getOption( 'thumbsize' );
    }

    /**
     * Thumb size preferred by the user.
     * @param int|null $x New value (null is no change)
     * @return int Old value
     */
    public function setThumbSize( $x ) {
        return $this->setOptionLegacy( 'thumbsize', $x );
    }

    /**
     * Parsing the page for a "preview" operation?
     * @return bool
     */
    public function getIsPreview() {
        return $this->getOption( 'isPreview' );
    }

    /**
     * Parsing the page for a "preview" operation?
     * @param bool|null $x New value (null is no change)
     * @return bool Old value
     */
    public function setIsPreview( $x ) {
        return $this->setOptionLegacy( 'isPreview', $x );
    }

    /**
     * Parsing the page for a "preview" operation on a single section?
     * @return bool
     */
    public function getIsSectionPreview() {
        return $this->getOption( 'isSectionPreview' );
    }

    /**
     * Parsing the page for a "preview" operation on a single section?
     * @param bool|null $x New value (null is no change)
     * @return bool Old value
     */
    public function setIsSectionPreview( $x ) {
        return $this->setOptionLegacy( 'isSectionPreview', $x );
    }

    /**
     * Parsing the printable version of the page?
     * @return bool
     */
    public function getIsPrintable() {
        return $this->getOption( 'printable' );
    }

    /**
     * Parsing the printable version of the page?
     * @param bool|null $x New value (null is no change)
     * @return bool Old value
     */
    public function setIsPrintable( $x ) {
        return $this->setOptionLegacy( 'printable', $x );
    }

    /**
     * Transform wiki markup when saving the page?
     * @return bool
     */
    public function getPreSaveTransform() {
        return $this->getOption( 'preSaveTransform' );
    }

    /**
     * Transform wiki markup when saving the page?
     * @param bool|null $x New value (null is no change)
     * @return bool Old value
     */
    public function setPreSaveTransform( $x ) {
        return $this->setOptionLegacy( 'preSaveTransform', $x );
    }

    /**
     * Parsoid-format HTML output, or legacy wikitext parser HTML?
     * @see T300191
     * @unstable
     * @since 1.41
     * @return bool
     */
    public function getUseParsoid(): bool {
        return $this->getOption( 'useParsoid' );
    }

    /**
     * Request Parsoid-format HTML output.
     * @see T300191
     * @unstable
     * @since 1.41
     */
    public function setUseParsoid() {
        $this->setOption( 'useParsoid', true );
    }

    /**
     * Date format index
     * @return string
     */
    public function getDateFormat() {
        return $this->getOption( 'dateformat' );
    }

    /**
     * Lazy initializer for dateFormat
     * @param ParserOptions $popt
     * @return string
     */
    private static function initDateFormat( ParserOptions $popt ) {
        $userFactory = MediaWikiServices::getInstance()->getUserFactory();
        return $userFactory->newFromUserIdentity( $popt->getUserIdentity() )->getDatePreference();
    }

    /**
     * Date format index
     * @param string|null $x New value (null is no change)
     * @return string Old value
     */
    public function setDateFormat( $x ) {
        return $this->setOptionLegacy( 'dateformat', $x );
    }

    /**
     * Get the user language used by the parser for this page and split the parser cache.
     *
     * @warning Calling this causes the parser cache to be fragmented by user language!
     * To avoid cache fragmentation, output should not depend on the user language.
     * Use Parser::getTargetLanguage() instead!
     *
     * @note This function will trigger a cache fragmentation by recording the
     * 'userlang' option, see optionUsed(). This is done to avoid cache pollution
     * when the page is rendered based on the language of the user.
     *
     * @note When saving, this will return the default language instead of the user's.
     * {{int: }} uses this which used to produce inconsistent link tables (T16404).
     *
     * @return Language
     * @since 1.19
     */
    public function getUserLangObj() {
        return $this->getOption( 'userlang' );
    }

    /**
     * Same as getUserLangObj() but returns a string instead.
     *
     * @warning Calling this causes the parser cache to be fragmented by user language!
     * To avoid cache fragmentation, output should not depend on the user language.
     * Use Parser::getTargetLanguage() instead!
     *
     * @see getUserLangObj()
     *
     * @return string Language code
     * @since 1.17
     */
    public function getUserLang() {
        return $this->getUserLangObj()->getCode();
    }

    /**
     * Set the user language used by the parser for this page and split the parser cache.
     * @param string|Language $x New value
     * @return Language Old value
     */
    public function setUserLang( $x ) {
        if ( is_string( $x ) ) {
            $x = MediaWikiServices::getInstance()->getLanguageFactory()->getLanguage( $x );
        }

        return $this->setOptionLegacy( 'userlang', $x );
    }

    /**
     * Are magic ISBN links enabled?
     * @since 1.28
     * @return bool
     */
    public function getMagicISBNLinks() {
        return $this->getOption( 'magicISBNLinks' );
    }

    /**
     * Are magic PMID links enabled?
     * @since 1.28
     * @return bool
     */
    public function getMagicPMIDLinks() {
        return $this->getOption( 'magicPMIDLinks' );
    }

    /**
     * Are magic RFC links enabled?
     * @since 1.28
     * @return bool
     */
    public function getMagicRFCLinks() {
        return $this->getOption( 'magicRFCLinks' );
    }

    /**
     * Should the table of contents be suppressed?
     * Used when parsing "code" pages (like JavaScript) as wikitext
     * for backlink support and categories, but where we don't want
     * other metadata generated (like the table of contents).
     * @see T307691
     * @since 1.39
     * @return bool
     */
    public function getSuppressTOC() {
        return $this->getOption( 'suppressTOC' );
    }

    /**
     * Suppress generation of the table of contents.
     * Used when parsing "code" pages (like JavaScript) as wikitext
     * for backlink support and categories, but where we don't want
     * other metadata generated (like the table of contents).
     * @see T307691
     * @since 1.39
     * @deprecated since 1.42; just clear the metadata in the final
     *  parser output
     */
    public function setSuppressTOC() {
        wfDeprecated( __METHOD__, '1.42' );
        $this->setOption( 'suppressTOC', true );
    }

    /**
     * Should section edit links be suppressed?
     * Used when parsing wikitext which will be presented in a
     * non-interactive context: previews, UX text, etc.
     * @since 1.42
     * @return bool
     */
    public function getSuppressSectionEditLinks() {
        return $this->getOption( 'suppressSectionEditLinks' );
    }

    /**
     * Suppress section edit links in the output.
     * Used when parsing wikitext which will be presented in a
     * non-interactive context: previews, UX text, etc.
     * @since 1.42
     */
    public function setSuppressSectionEditLinks() {
        $this->setOption( 'suppressSectionEditLinks', true );
    }

    /**
     * If the wiki is configured to allow raw html ($wgRawHtml = true)
     * is it allowed in the specific case of parsing this page.
     *
     * This is meant to disable unsafe parser tags in cases where
     * a malicious user may control the input to the parser.
     *
     * @note This is expected to be true for normal pages even if the
     *  wiki has $wgRawHtml disabled in general. The setting only
     *  signifies that raw html would be unsafe in the current context
     *  provided that raw html is allowed at all.
     * @since 1.29
     * @return bool
     */
    public function getAllowUnsafeRawHtml() {
        return $this->getOption( 'allowUnsafeRawHtml' );
    }

    /**
     * If the wiki is configured to allow raw html ($wgRawHtml = true)
     * is it allowed in the specific case of parsing this page.
     * @see self::getAllowUnsafeRawHtml()
     * @since 1.29
     * @param bool|null $x Value to set or null to get current value
     * @return bool Current value for allowUnsafeRawHtml
     */
    public function setAllowUnsafeRawHtml( $x ) {
        return $this->setOptionLegacy( 'allowUnsafeRawHtml', $x );
    }

    /**
     * Class to use to wrap output from Parser::parse()
     * @since 1.30
     * @return string|false
     */
    public function getWrapOutputClass() {
        return $this->getOption( 'wrapclass' );
    }

    /**
     * CSS class to use to wrap output from Parser::parse()
     * @since 1.30
     * @param string $className Class name to use for wrapping.
     *   Passing false to indicate "no wrapping" was deprecated in MediaWiki 1.31.
     * @return string|false Current value
     */
    public function setWrapOutputClass( $className ) {
        if ( $className === true ) { // DWIM, they probably want the default class name
            $className = 'mw-parser-output';
        }
        if ( $className === false ) {
            wfDeprecated( __METHOD__ . '( false )', '1.31' );
        }
        return $this->setOption( 'wrapclass', $className );
    }

    /**
     * Callback for current revision fetching; first argument to call_user_func().
     * @internal
     * @since 1.35
     * @return callable
     */
    public function getCurrentRevisionRecordCallback() {
        return $this->getOption( 'currentRevisionRecordCallback' );
    }

    /**
     * Callback for current revision fetching; first argument to call_user_func().
     * @internal
     * @since 1.35
     * @param callable|null $x New value
     * @return callable Old value
     */
    public function setCurrentRevisionRecordCallback( $x ) {
        return $this->setOption( 'currentRevisionRecordCallback', $x );
    }

    /**
     * Callback for template fetching; first argument to call_user_func().
     * @return callable
     */
    public function getTemplateCallback() {
        return $this->getOption( 'templateCallback' );
    }

    /**
     * Callback for template fetching; first argument to call_user_func().
     * @param callable|null $x New value (null is no change)
     * @return callable Old value
     */
    public function setTemplateCallback( $x ) {
        return $this->setOptionLegacy( 'templateCallback', $x );
    }

    /**
     * A guess for {{REVISIONID}}, calculated using the callback provided via
     * setSpeculativeRevIdCallback(). For consistency, the value will be calculated upon the
     * first call of this method, and re-used for subsequent calls.
     *
     * If no callback was defined via setSpeculativeRevIdCallback(), this method will return false.
     *
     * @since 1.32
     * @return int|false
     */
    public function getSpeculativeRevId() {
        return $this->getOption( 'speculativeRevId' );
    }

    /**
     * A guess for {{PAGEID}}, calculated using the callback provided via
     * setSpeculativeRevPageCallback(). For consistency, the value will be calculated upon the
     * first call of this method, and re-used for subsequent calls.
     *
     * If no callback was defined via setSpeculativePageIdCallback(), this method will return false.
     *
     * @since 1.34
     * @return int|false
     */
    public function getSpeculativePageId() {
        return $this->getOption( 'speculativePageId' );
    }

    /**
     * Callback registered with ParserOptions::$lazyOptions, triggered by getSpeculativeRevId().
     *
     * @param ParserOptions $popt
     * @return int|false
     */
    private static function initSpeculativeRevId( ParserOptions $popt ) {
        $cb = $popt->getOption( 'speculativeRevIdCallback' );
        $id = $cb ? $cb() : null;

        // returning null would result in this being re-called every access
        return $id ?? false;
    }

    /**
     * Callback registered with ParserOptions::$lazyOptions, triggered by getSpeculativePageId().
     *
     * @param ParserOptions $popt
     * @return int|false
     */
    private static function initSpeculativePageId( ParserOptions $popt ) {
        $cb = $popt->getOption( 'speculativePageIdCallback' );
        $id = $cb ? $cb() : null;

        // returning null would result in this being re-called every access
        return $id ?? false;
    }

    /**
     * Callback to generate a guess for {{REVISIONID}}
     * @param callable|null $x New value
     * @return callable|null Old value
     * @since 1.28
     */
    public function setSpeculativeRevIdCallback( $x ) {
        $this->setOption( 'speculativeRevId', null ); // reset
        return $this->setOption( 'speculativeRevIdCallback', $x );
    }

    /**
     * Callback to generate a guess for {{PAGEID}}
     * @param callable|null $x New value
     * @return callable|null Old value
     * @since 1.34
     */
    public function setSpeculativePageIdCallback( $x ) {
        $this->setOption( 'speculativePageId', null ); // reset
        return $this->setOption( 'speculativePageIdCallback', $x );
    }

    /**
     * Timestamp used for {{CURRENTDAY}} etc.
     * @return string TS_MW timestamp
     */
    public function getTimestamp() {
        if ( !isset( $this->mTimestamp ) ) {
            $this->mTimestamp = wfTimestampNow();
        }
        return $this->mTimestamp;
    }

    /**
     * Timestamp used for {{CURRENTDAY}} etc.
     * @param string|null $x New value (null is no change)
     * @return string Old value
     */
    public function setTimestamp( $x ) {
        return wfSetVar( $this->mTimestamp, $x );
    }

    /**
     * Note that setting or changing this does not *make* the page a redirect
     * or change its target, it merely records the information for reference
     * during the parse.
     *
     * @since 1.24
     * @param Title|null $title
     */
    public function setRedirectTarget( $title ) {
        $this->redirectTarget = $title;
    }

    /**
     * Get the previously-set redirect target.
     *
     * @since 1.24
     * @return Title|null
     */
    public function getRedirectTarget() {
        return $this->redirectTarget;
    }

    /**
     * Extra key that should be present in the parser cache key.
     * @warning Consider registering your additional options with the
     *  ParserOptionsRegister hook instead of using this method.
     * @param string $key
     */
    public function addExtraKey( $key ) {
        $this->mExtraKey .= '!' . $key;
    }

    /**
     * Get the identity of the user for whom the parse is made.
     * @since 1.36
     * @return UserIdentity
     */
    public function getUserIdentity(): UserIdentity {
        return $this->mUser;
    }

    /**
     * @param UserIdentity $user
     * @param Language|null $lang
     */
    public function __construct( UserIdentity $user, $lang = null ) {
        if ( $lang === null ) {
            global $wgLang;
            StubObject::unstub( $wgLang );
            $lang = $wgLang;
        }
        $this->initialiseFromUser( $user, $lang );
    }

    /**
     * Get a ParserOptions object for an anonymous user
     * @since 1.27
     * @return ParserOptions
     */
    public static function newFromAnon() {
        return new ParserOptions( MediaWikiServices::getInstance()->getUserFactory()->newAnonymous(),
            MediaWikiServices::getInstance()->getContentLanguage() );
    }

    /**
     * Get a ParserOptions object from a given user.
     * Language will be taken from $wgLang.
     *
     * @param UserIdentity $user
     * @return ParserOptions
     */
    public static function newFromUser( $user ) {
        return new ParserOptions( $user );
    }

    /**
     * Get a ParserOptions object from a given user and language
     *
     * @param UserIdentity $user
     * @param Language $lang
     * @return ParserOptions
     */
    public static function newFromUserAndLang( UserIdentity $user, Language $lang ) {
        return new ParserOptions( $user, $lang );
    }

    /**
     * Get a ParserOptions object from a IContextSource object
     *
     * @param IContextSource $context
     * @return ParserOptions
     */
    public static function newFromContext( IContextSource $context ) {
        return new ParserOptions( $context->getUser(), $context->getLanguage() );
    }

    /**
     * Creates a "canonical" ParserOptions object
     *
     * For historical reasons, certain options have default values that are
     * different from the canonical values used for caching.
     *
     * @since 1.30
     * @since 1.32 Added string and IContextSource as options for the first parameter
     * @since 1.36 UserIdentity is also allowed
     * @deprecated since 1.38. Use ::newFromContext, ::newFromAnon or ::newFromUserAndLang instead.
     *   Canonical ParserOptions are now exactly the same as non-canonical.
     * @param IContextSource|string|UserIdentity $context
     *  - If an IContextSource, the options are initialized based on the source's UserIdentity and Language.
     *  - If the string 'canonical', the options are initialized with an anonymous user and
     *    the content language.
     *  - If a UserIdentity, the options are initialized for that UserIdentity
     *    'userlang' is taken from the $userLang parameter, defaulting to $wgLang if that is null.
     * @param Language|StubObject|null $userLang (see above)
     * @return ParserOptions
     */
    public static function newCanonical( $context, $userLang = null ) {
        if ( $context instanceof IContextSource ) {
            $ret = self::newFromContext( $context );
        } elseif ( $context === 'canonical' ) {
            $ret = self::newFromAnon();
        } elseif ( $context instanceof UserIdentity ) {
            $ret = new self( $context, $userLang );
        } else {
            throw new InvalidArgumentException(
                '$context must be an IContextSource, the string "canonical", or a UserIdentity'
            );
        }
        return $ret;
    }

    /**
     * Reset static caches
     * @internal For testing
     */
    public static function clearStaticCache() {
        if ( !defined( 'MW_PHPUNIT_TEST' ) && !defined( 'MW_PARSER_TEST' ) ) {
            throw new LogicException( __METHOD__ . ' is just for testing' );
        }
        self::$defaults = null;
        self::$lazyOptions = null;
        self::$cacheVaryingOptionsHash = null;
    }

    /**
     * Get default option values
     * @warning If you change the default for an existing option, all existing
     *  parser cache entries will be invalid. To avoid bugs, you'll need to handle
     *  that somehow (e.g. with the RejectParserCacheValue hook) because
     *  MediaWiki won't do it for you.
     * @return array
     */
    private static function getDefaults() {
        $services = MediaWikiServices::getInstance();
        $mainConfig = $services->getMainConfig();
        $interwikiMagic = $mainConfig->get( MainConfigNames::InterwikiMagic );
        $allowExternalImages = $mainConfig->get( MainConfigNames::AllowExternalImages );
        $allowExternalImagesFrom = $mainConfig->get( MainConfigNames::AllowExternalImagesFrom );
        $enableImageWhitelist = $mainConfig->get( MainConfigNames::EnableImageWhitelist );
        $allowSpecialInclusion = $mainConfig->get( MainConfigNames::AllowSpecialInclusion );
        $maxArticleSize = $mainConfig->get( MainConfigNames::MaxArticleSize );
        $maxPPNodeCount = $mainConfig->get( MainConfigNames::MaxPPNodeCount );
        $maxTemplateDepth = $mainConfig->get( MainConfigNames::MaxTemplateDepth );
        $maxPPExpandDepth = $mainConfig->get( MainConfigNames::MaxPPExpandDepth );
        $cleanSignatures = $mainConfig->get( MainConfigNames::CleanSignatures );
        $externalLinkTarget = $mainConfig->get( MainConfigNames::ExternalLinkTarget );
        $expensiveParserFunctionLimit = $mainConfig->get( MainConfigNames::ExpensiveParserFunctionLimit );
        $enableMagicLinks = $mainConfig->get( MainConfigNames::EnableMagicLinks );
        $languageConverterFactory = $services->getLanguageConverterFactory();
        $userOptionsLookup = $services->getUserOptionsLookup();
        $contentLanguage = $services->getContentLanguage();

        if ( self::$defaults === null ) {
            // *UPDATE* ParserOptions::matches() if any of this changes as needed
            self::$defaults = [
                'dateformat' => null,
                'interfaceMessage' => false,
                'targetLanguage' => null,
                'removeComments' => true,
                'suppressTOC' => false,
                'suppressSectionEditLinks' => false,
                'enableLimitReport' => false,
                'preSaveTransform' => true,
                'isPreview' => false,
                'isSectionPreview' => false,
                'printable' => false,
                'allowUnsafeRawHtml' => true,
                'wrapclass' => 'mw-parser-output',
                'currentRevisionRecordCallback' => [ Parser::class, 'statelessFetchRevisionRecord' ],
                'templateCallback' => [ Parser::class, 'statelessFetchTemplate' ],
                'speculativeRevIdCallback' => null,
                'speculativeRevId' => null,
                'speculativePageIdCallback' => null,
                'speculativePageId' => null,
                'useParsoid' => false,
            ];

            self::$cacheVaryingOptionsHash = self::$initialCacheVaryingOptionsHash;
            self::$lazyOptions = self::$initialLazyOptions;

            ( new HookRunner( $services->getHookContainer() ) )->onParserOptionsRegister(
                self::$defaults,
                self::$cacheVaryingOptionsHash,
                self::$lazyOptions
            );

            ksort( self::$cacheVaryingOptionsHash );
        }

        // Unit tests depend on being able to modify the globals at will
        return self::$defaults + [
            'interwikiMagic' => $interwikiMagic,
            'allowExternalImages' => $allowExternalImages,
            'allowExternalImagesFrom' => $allowExternalImagesFrom,
            'enableImageWhitelist' => $enableImageWhitelist,
            'allowSpecialInclusion' => $allowSpecialInclusion,
            'maxIncludeSize' => $maxArticleSize * 1024,
            'maxPPNodeCount' => $maxPPNodeCount,
            'maxPPExpandDepth' => $maxPPExpandDepth,
            'maxTemplateDepth' => $maxTemplateDepth,
            'expensiveParserFunctionLimit' => $expensiveParserFunctionLimit,
            'externalLinkTarget' => $externalLinkTarget,
            'cleanSignatures' => $cleanSignatures,
            'disableContentConversion' => $languageConverterFactory->isConversionDisabled(),
            'disableTitleConversion' => $languageConverterFactory->isLinkConversionDisabled(),
            // FIXME: The fallback to false for enableMagicLinks is a band-aid to allow
            // the phpunit entrypoint patch (I82045c207738d152d5b0006f353637cfaa40bb66)
            // to be merged.
            // It is possible that a test somewhere is globally resetting $wgEnableMagicLinks
            // to null, or that ParserOptions is somehow similarly getting reset in such a way
            // that $enableMagicLinks ends up as null rather than an array. This workaround
            // seems harmless, but would be nice to eventually fix the underlying issue.
            'magicISBNLinks' => $enableMagicLinks['ISBN'] ?? false,
            'magicPMIDLinks' => $enableMagicLinks['PMID'] ?? false,
            'magicRFCLinks' => $enableMagicLinks['RFC'] ?? false,
            'thumbsize' => $userOptionsLookup->getDefaultOption( 'thumbsize' ),
            'userlang' => $contentLanguage,
        ];
    }

    /**
     * Get user options
     *
     * @param UserIdentity $user
     * @param Language $lang
     */
    private function initialiseFromUser( UserIdentity $user, Language $lang ) {
        // Initially lazy loaded option defaults must not be taken into account,
        // otherwise lazy loading does not work. Setting a default for lazy option
        // is useful for matching with canonical options.
        $this->options = $this->nullifyLazyOption( self::getDefaults() );

        $this->mUser = $user;
        $services = MediaWikiServices::getInstance();
        $optionsLookup = $services->getUserOptionsLookup();
        $this->options['thumbsize'] = $optionsLookup->getOption( $user, 'thumbsize' );
        $this->options['userlang'] = $lang;
    }

    /**
     * Check if these options match that of another options set
     *
     * This ignores report limit settings that only affect HTML comments
     *
     * @param ParserOptions $other
     * @return bool
     * @since 1.25
     */
    public function matches( ParserOptions $other ) {
        // Compare most options
        $options = array_keys( $this->options );
        $options = array_diff( $options, [
            'enableLimitReport', // only affects HTML comments
            'tidy', // Has no effect since 1.35; removed in 1.36
        ] );
        foreach ( $options as $option ) {
            // Resolve any lazy options
            $this->lazyLoadOption( $option );
            $other->lazyLoadOption( $option );

            $o1 = $this->optionToString( $this->options[$option] );
            $o2 = $this->optionToString( $other->options[$option] );
            if ( $o1 !== $o2 ) {
                return false;
            }
        }

        // Compare most other fields
        foreach ( ( new ReflectionClass( $this ) )->getProperties() as $property ) {
            $field = $property->getName();
            if ( $property->isStatic() ) {
                continue;
            }
            if ( in_array( $field, [
                'options', // Already checked above
                'onAccessCallback', // only used for ParserOutput option tracking
            ] ) ) {
                continue;
            }

            if ( !is_object( $this->$field ) && $this->$field !== $other->$field ) {
                return false;
            }
        }

        return true;
    }

    /**
     * @param ParserOptions $other
     * @return bool Whether the cache key relevant options match those of $other
     * @since 1.33
     */
    public function matchesForCacheKey( ParserOptions $other ) {
        foreach ( self::allCacheVaryingOptions() as $option ) {
            // Populate any lazy options
            $this->lazyLoadOption( $option );
            $other->lazyLoadOption( $option );

            $o1 = $this->optionToString( $this->options[$option] );
            $o2 = $this->optionToString( $other->options[$option] );
            if ( $o1 !== $o2 ) {
                return false;
            }
        }

        return true;
    }

    /**
     * Registers a callback for tracking which ParserOptions which are used.
     *
     * @since 1.16
     * @param callable|null $callback
     */
    public function registerWatcher( $callback ) {
        $this->onAccessCallback = $callback;
    }

    /**
     * Record that an option was internally accessed.
     *
     * This calls the watcher set by ParserOptions::registerWatcher().
     * Typically, the watcher callback is ParserOutput::recordOption().
     * The information registered this way is consumed by ParserCache::save().
     *
     * @param string $optionName Name of the option
     */
    private function optionUsed( $optionName ) {
        if ( $this->onAccessCallback ) {
            call_user_func( $this->onAccessCallback, $optionName );
        }
    }

    /**
     * Return all option keys that vary the options hash
     * @since 1.30
     * @return string[]
     */
    public static function allCacheVaryingOptions() {
        return array_keys( array_filter( self::getCacheVaryingOptionsHash() ) );
    }

    /**
     * Convert an option to a string value
     * @param mixed $value
     * @return string
     */
    private function optionToString( $value ) {
        if ( $value === true ) {
            return '1';
        } elseif ( $value === false ) {
            return '0';
        } elseif ( $value === null ) {
            return '';
        } elseif ( $value instanceof Language ) {
            return $value->getCode();
        } elseif ( is_array( $value ) ) {
            return '[' . implode( ',', array_map( [ $this, 'optionToString' ], $value ) ) . ']';
        } else {
            return (string)$value;
        }
    }

    /**
     * Generate a hash string with the values set on these ParserOptions
     * for the keys given in the array.
     * This will be used as part of the hash key for the parser cache,
     * so users sharing the options with vary for the same page share
     * the same cached data safely.
     *
     * @since 1.17
     * @param string[] $forOptions
     * @param Title|null $title Used to get the content language of the page (since r97636)
     * @return string Page rendering hash
     */
    public function optionsHash( $forOptions, $title = null ) {
        $renderHashAppend = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::RenderHashAppend );

        $inCacheKey = self::allCacheVaryingOptions();

        // Resolve any lazy options
        $lazyOpts = array_intersect( $forOptions,
            $inCacheKey, array_keys( self::getLazyOptions() ) );
        foreach ( $lazyOpts as $k ) {
            $this->lazyLoadOption( $k );
        }

        $options = $this->options;
        $defaults = self::getDefaults();

        // We only include used options with non-canonical values in the key
        // so adding a new option doesn't invalidate the entire parser cache.
        // The drawback to this is that changing the default value of an option
        // requires manual invalidation of existing cache entries, as mentioned
        // in the docs on the relevant methods and hooks.
        $values = [];
        foreach ( array_intersect( $inCacheKey, $forOptions ) as $option ) {
            $v = $this->optionToString( $options[$option] );
            $d = $this->optionToString( $defaults[$option] );
            if ( $v !== $d ) {
                $values[] = "$option=$v";
            }
        }

        $confstr = $values ? implode( '!', $values ) : 'canonical';

        // add in language specific options, if any
        // @todo FIXME: This is just a way of retrieving the url/user preferred variant
        $services = MediaWikiServices::getInstance();
        $lang = $title ? $title->getPageLanguage() : $services->getContentLanguage();
        $converter = $services->getLanguageConverterFactory()->getLanguageConverter( $lang );
        $confstr .= $converter->getExtraHashOptions();

        $confstr .= $renderHashAppend;

        if ( $this->mExtraKey != '' ) {
            $confstr .= $this->mExtraKey;
        }

        $user = $services->getUserFactory()->newFromUserIdentity( $this->getUserIdentity() );
        // Give a chance for extensions to modify the hash, if they have
        // extra options or other effects on the parser cache.
        ( new HookRunner( $services->getHookContainer() ) )->onPageRenderingHash(
            $confstr,
            $user,
            $forOptions
        );

        // Make it a valid memcached key fragment
        $confstr = str_replace( ' ', '_', $confstr );

        return $confstr;
    }

    /**
     * Test whether these options are safe to cache
     * @param string[]|null $usedOptions the list of options actually used in the parse. Defaults to all options.
     * @return bool
     * @since 1.30
     */
    public function isSafeToCache( array $usedOptions = null ) {
        $defaults = self::getDefaults();
        $inCacheKey = self::getCacheVaryingOptionsHash();
        $usedOptions ??= array_keys( $this->options );
        foreach ( $usedOptions as $option ) {
            if ( empty( $inCacheKey[$option] ) && empty( self::$callbacks[$option] ) ) {
                $v = $this->optionToString( $this->options[$option] ?? null );
                $d = $this->optionToString( $defaults[$option] ?? null );
                if ( $v !== $d ) {
                    return false;
                }
            }
        }
        return true;
    }

    /**
     * Sets a hook to force that a page exists, and sets a current revision callback to return
     * a revision with custom content when the current revision of the page is requested.
     *
     * @since 1.25
     * @param Title $title
     * @param Content $content
     * @param UserIdentity $user The user that the fake revision is attributed to
     * @return ScopedCallback to unset the hook
     */
    public function setupFakeRevision( $title, $content, $user ) {
        $oldCallback = $this->setCurrentRevisionRecordCallback(
            static function (
                $titleToCheck, $parser = null ) use ( $title, $content, $user, &$oldCallback
            ) {
                if ( $titleToCheck->equals( $title ) ) {
                    $revRecord = new MutableRevisionRecord( $title );
                    $revRecord->setContent( SlotRecord::MAIN, $content )
                        ->setUser( $user )
                        ->setTimestamp( MWTimestamp::now( TS_MW ) )
                        ->setPageId( $title->getArticleID() )
                        ->setParentId( $title->getLatestRevID() );
                    return $revRecord;
                } else {
                    return call_user_func( $oldCallback, $titleToCheck, $parser );
                }
            }
        );

        $hookContainer = MediaWikiServices::getInstance()->getHookContainer();
        $hookScope = $hookContainer->scopedRegister(
            'TitleExists',
            static function ( $titleToCheck, &$exists ) use ( $title ) {
                if ( $titleToCheck->equals( $title ) ) {
                    $exists = true;
                }
            }
        );

        $linkCache = MediaWikiServices::getInstance()->getLinkCache();
        $linkCache->clearBadLink( $title->getPrefixedDBkey() );

        return new ScopedCallback( function () use ( $title, $hookScope, $linkCache, $oldCallback ) {
            ScopedCallback::consume( $hookScope );
            $linkCache->clearLink( $title );
            $this->setCurrentRevisionRecordCallback( $oldCallback );
        } );
    }

    /**
     * Returns reason for rendering the content. This human-readable, intended for logging and debugging only.
     * Expected values include "edit", "view", "purge", "LinksUpdate", etc.
     * @return string
     */
    public function getRenderReason(): string {
        return $this->renderReason;
    }

    /**
     * Sets reason for rendering the content. This human-readable, intended for logging and debugging only.
     * Expected values include "edit", "view", "purge", "LinksUpdate", etc.
     * @param string $renderReason
     */
    public function setRenderReason( string $renderReason ): void {
        $this->renderReason = $renderReason;
    }
}

/**
 * For really cool vim folding this needs to be at the end:
 * vim: foldmarker=@{,@} foldmethod=marker
 */