From: Brad Jorsch Date: Fri, 12 May 2017 21:38:12 +0000 (-0400) Subject: Try harder to avoid parser cache pollution X-Git-Tag: 1.31.0-rc.0~3043^2 X-Git-Url: http://git.cyclocoop.org/%24self?a=commitdiff_plain;h=0facbe3e3dfa4df81c01323b5f2aeacf880a1054;p=lhc%2Fweb%2Fwiklou.git Try harder to avoid parser cache pollution * ParserOptions is reorganized so it knows all the options and their defaults, and can report whether the non-key options are at their defaults. * Definition of the "canonical" ParserOptions (which is unfortunately different from the "default" ParserOptions) is moved from ContentHandler to ParserOptions. * WikiPage uses this to throw an exception if it's asked to cache with options that aren't used in the cache key. * ParserCache gets some temporary code to try to avoid a massive cache stampede on upgrade. Bug: T110269 Change-Id: I7fb9ffca96e6bd04db44d2d5f2509ec96ad9371f Depends-On: I4070a8f51927121f690469716625db4a1064dea5 --- diff --git a/RELEASE-NOTES-1.30 b/RELEASE-NOTES-1.30 index 22fed0c84a..64fe822d12 100644 --- a/RELEASE-NOTES-1.30 +++ b/RELEASE-NOTES-1.30 @@ -23,6 +23,10 @@ production. * $wgExceptionHooks has been removed. * $wgShellLocale is now applied for all requests. wfInitShellLocale() is deprecated and a no-op, as it is no longer needed. +* WikiPage::getParserOutput() will now throw an exception if passed + ParserOptions would pollute the parser cache. Callers should use + WikiPage::makeParserOptions() to create the ParserOptions object and only + change options that affect the parser cache key. === New features in 1.30 === * (T37247) Output from Parser::parse() will now be wrapped in a div with @@ -33,6 +37,8 @@ production. * File storage backends that supports headers (eg. Swift) now store an X-Content-Dimensions header for originals that contain the media's dimensions as page ranges keyed by dimensions. +* Added a 'ParserOptionsRegister' hook to allow extensions to register + additional parser options. === Languages updated in 1.30 === diff --git a/docs/hooks.txt b/docs/hooks.txt index 62b22e153f..0e8b50829d 100644 --- a/docs/hooks.txt +++ b/docs/hooks.txt @@ -2417,7 +2417,8 @@ constructed. &$pager: the pager &$queryInfo: the query parameters -'PageRenderingHash': Alter the parser cache option hash key. A parser extension +'PageRenderingHash': NOTE: Consider using ParserOptionsRegister instead. +Alter the parser cache option hash key. A parser extension which depends on user options should install this hook and append its values to the key. &$confstr: reference to a hash key string which can be modified @@ -2541,6 +2542,16 @@ $file: file object that will be used to create the image &$params: 2-D array of parameters $parser: Parser object that called the hook +'ParserOptionsRegister': Register additional parser options. Note that if you +change the default value for an 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. +&$defaults: Set the default value for your option here. +&$inCacheKey: To fragment the parser cache on your option, set a truthy value here. +&$lazyLoad: To lazy-initialize your option, set it null in $defaults and set a + callable here. The callable is passed the ParserOptions object and the option + name. + 'ParserSectionCreate': Called each time the parser creates a document section from wikitext. Use this to apply per-section modifications to HTML (like wrapping the section in a DIV). Caveat: DIVs are valid wikitext, and a DIV diff --git a/includes/content/ContentHandler.php b/includes/content/ContentHandler.php index bccb147e5b..85894ed539 100644 --- a/includes/content/ContentHandler.php +++ b/includes/content/ContentHandler.php @@ -1007,22 +1007,22 @@ abstract class ContentHandler { * @return ParserOptions */ public function makeParserOptions( $context ) { - global $wgContLang, $wgEnableParserLimitReporting; + global $wgContLang; if ( $context instanceof IContextSource ) { - $options = ParserOptions::newFromContext( $context ); + $user = $context->getUser(); + $lang = $context->getLanguage(); } elseif ( $context instanceof User ) { // settings per user (even anons) - $options = ParserOptions::newFromUser( $context ); + $user = $context; + $lang = null; } elseif ( $context === 'canonical' ) { // canonical settings - $options = ParserOptions::newFromUserAndLang( new User, $wgContLang ); + $user = new User; + $lang = $wgContLang; } else { throw new MWException( "Bad context for parser options: $context" ); } - $options->enableLimitReport( $wgEnableParserLimitReporting ); // show inclusion/loop reports - $options->setTidy( true ); // fix bad HTML - - return $options; + return ParserOptions::newCanonical( $user, $lang ); } /** diff --git a/includes/page/WikiPage.php b/includes/page/WikiPage.php index 2adc5fbc10..0e23a88c1d 100644 --- a/includes/page/WikiPage.php +++ b/includes/page/WikiPage.php @@ -1055,6 +1055,13 @@ class WikiPage implements Page, IDBAccessObject { ) { $useParserCache = ( !$forceParse ) && $this->shouldCheckParserCache( $parserOptions, $oldid ); + + if ( $useParserCache && !$parserOptions->isSafeToCache() ) { + throw new InvalidArgumentException( + 'The supplied ParserOptions are not safe to cache. Fix the options or set $forceParse = true.' + ); + } + wfDebug( __METHOD__ . ': using parser cache: ' . ( $useParserCache ? 'yes' : 'no' ) . "\n" ); if ( $parserOptions->getStubThreshold() ) { diff --git a/includes/parser/ParserCache.php b/includes/parser/ParserCache.php index 76a7e1ed7a..9c6cf93e78 100644 --- a/includes/parser/ParserCache.php +++ b/includes/parser/ParserCache.php @@ -138,6 +138,20 @@ class ParserCache { * @return bool|mixed|string */ public function getKey( $article, $popts, $useOutdated = true ) { + $dummy = null; + return $this->getKeyReal( $article, $popts, $useOutdated, $dummy ); + } + + /** + * Temporary internal function to allow accessing $usedOptions + * @todo Merge this back to self::getKey() when ParserOptions::optionsHashPre30() is removed + * @param WikiPage $article + * @param ParserOptions $popts + * @param bool $useOutdated (default true) + * @param array &$usedOptions Don't use this, it will go away soon + * @return bool|mixed|string + */ + private function getKeyReal( $article, $popts, $useOutdated, &$usedOptions ) { global $wgCacheEpoch; if ( $popts instanceof User ) { @@ -204,7 +218,8 @@ class ParserCache { $touched = $article->getTouched(); - $parserOutputKey = $this->getKey( $article, $popts, $useOutdated ); + $usedOptions = null; + $parserOutputKey = $this->getKeyReal( $article, $popts, $useOutdated, $usedOptions ); if ( $parserOutputKey === false ) { wfIncrStats( 'pcache.miss.absent' ); return false; @@ -213,6 +228,13 @@ class ParserCache { $casToken = null; /** @var ParserOutput $value */ $value = $this->mMemc->get( $parserOutputKey, $casToken, BagOStuff::READ_VERIFIED ); + if ( !$value ) { + $parserOutputKey = $this->getParserOutputKey( + $article, + $popts->optionsHashPre30( $usedOptions, $article->getTitle() ) + ); + $value = $this->mMemc->get( $parserOutputKey, $casToken, BagOStuff::READ_VERIFIED ); + } if ( !$value ) { wfDebug( "ParserOutput cache miss.\n" ); wfIncrStats( "pcache.miss.absent" ); diff --git a/includes/parser/ParserOptions.php b/includes/parser/ParserOptions.php index d097414aff..f8ed63fc84 100644 --- a/includes/parser/ParserOptions.php +++ b/includes/parser/ParserOptions.php @@ -25,397 +25,652 @@ use Wikimedia\ScopedCallback; /** * @brief Set options of the Parser * - * All member variables are supposed to be private in theory, although in - * practice this is not the case. + * 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 { /** - * Interlanguage links are removed and returned in an array + * Default values for all options that are relevant for caching. + * @see self::getDefaults() + * @var array|null */ - private $mInterwikiMagic; + private static $defaults = null; /** - * Allow external images inline? + * Lazy-loaded options + * @var callback[] */ - private $mAllowExternalImages; + private static $lazyOptions = [ + 'dateformat' => [ __CLASS__, 'initDateFormat' ], + ]; /** - * If not, any exception? + * Specify options that are included in the cache key + * @var array */ - private $mAllowExternalImagesFrom; + private static $inCacheKey = [ + 'dateformat' => true, + 'editsection' => true, + 'numberheadings' => true, + 'thumbsize' => true, + 'stubthreshold' => true, + 'printable' => true, + 'userlang' => true, + 'wrapclass' => true, + ]; /** - * If not or it doesn't match, should we check an on-wiki whitelist? + * Current values for all options that are relevant for caching. + * @var array */ - private $mEnableImageWhitelist; + private $options; /** - * Date format index + * Timestamp used for {{CURRENTDAY}} etc. + * @var string|null + * @note Caching based on parse time is handled externally */ - private $mDateFormat = null; + private $mTimestamp; /** - * Create "edit section" links? + * Stored user object + * @var User + * @todo Track this for caching somehow without fragmenting the cache insanely */ - private $mEditSection = true; + private $mUser; /** - * Allow inclusion of special pages? + * Function to be called when an option is accessed. + * @var callable|null + * @note Used for collecting used options, does not affect caching */ - private $mAllowSpecialInclusion; + private $onAccessCallback = null; /** - * Use tidy to cleanup output HTML? + * If the page being parsed is a redirect, this should hold the redirect + * target. + * @var Title|null + * @todo Track this for caching somehow */ - private $mTidy = false; + private $redirectTarget = null; /** - * Which lang to call for PLURAL and GRAMMAR + * Appended to the options hash */ - private $mInterfaceMessage = false; + private $mExtraKey = ''; /** - * Overrides $mInterfaceMessage with arbitrary language + * @name Option accessors + * @{ */ - private $mTargetLanguage = null; /** - * Maximum size of template expansions, in bytes + * Fetch an option, generically + * @since 1.30 + * @param string $name Option name + * @return mixed */ - private $mMaxIncludeSize; + public function getOption( $name ) { + if ( !array_key_exists( $name, $this->options ) ) { + throw new InvalidArgumentException( "Unknown parser option $name" ); + } - /** - * Maximum number of nodes touched by PPFrame::expand() - */ - private $mMaxPPNodeCount; + if ( isset( self::$lazyOptions[$name] ) && $this->options[$name] === null ) { + $this->options[$name] = call_user_func( self::$lazyOptions[$name], $this, $name ); + } + if ( !empty( self::$inCacheKey[$name] ) ) { + $this->optionUsed( $name ); + } + return $this->options[$name]; + } /** - * Maximum number of nodes generated by Preprocessor::preprocessToObj() - */ - private $mMaxGeneratedPPNodeCount; + * 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; + } /** - * Maximum recursion depth in PPFrame::expand() + * 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 */ - private $mMaxPPExpandDepth; + 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 ); + } /** - * Maximum recursion depth for templates within templates + * Whether to extract interlanguage links + * + * When true, interlanguage links will be returned by + * ParserOutput::getLanguageLinks() instead of generating link HTML. + * + * @return bool */ - private $mMaxTemplateDepth; + public function getInterwikiMagic() { + return $this->getOption( 'interwikiMagic' ); + } /** - * Maximum number of calls per parse to expensive parser functions + * Specify whether to extract interlanguage links + * @param bool|null $x New value (null is no change) + * @return bool Old value */ - private $mExpensiveParserFunctionLimit; + public function setInterwikiMagic( $x ) { + return $this->setOptionLegacy( 'interwikiMagic', $x ); + } /** - * Remove HTML comments. ONLY APPLIES TO PREPROCESS OPERATIONS + * Allow all external images inline? + * @return bool */ - private $mRemoveComments = true; + public function getAllowExternalImages() { + return $this->getOption( 'allowExternalImages' ); + } /** - * @var callable Callback for current revision fetching; first argument to call_user_func(). + * Allow all external images inline? + * @param bool|null $x New value (null is no change) + * @return bool Old value */ - private $mCurrentRevisionCallback = - [ 'Parser', 'statelessFetchRevision' ]; + public function setAllowExternalImages( $x ) { + return $this->setOptionLegacy( 'allowExternalImages', $x ); + } /** - * @var callable Callback for template fetching; first argument to call_user_func(). + * External images to allow + * + * When self::getAllowExternalImages() is false + * + * @return string|string[] URLs to allow */ - private $mTemplateCallback = - [ 'Parser', 'statelessFetchTemplate' ]; + public function getAllowExternalImagesFrom() { + return $this->getOption( 'allowExternalImagesFrom' ); + } /** - * @var callable|null Callback to generate a guess for {{REVISIONID}} + * 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 */ - private $mSpeculativeRevIdCallback; + public function setAllowExternalImagesFrom( $x ) { + return $this->setOptionLegacy( 'allowExternalImagesFrom', $x ); + } /** - * Enable limit report in an HTML comment on output + * Use the on-wiki external image whitelist? + * @return bool */ - private $mEnableLimitReport = false; + public function getEnableImageWhitelist() { + return $this->getOption( 'enableImageWhitelist' ); + } /** - * Timestamp used for {{CURRENTDAY}} etc. + * Use the on-wiki external image whitelist? + * @param bool|null $x New value (null is no change) + * @return bool Old value */ - private $mTimestamp; + public function setEnableImageWhitelist( $x ) { + return $this->setOptionLegacy( 'enableImageWhitelist', $x ); + } /** - * Target attribute for external links + * Create "edit section" links? + * @return bool */ - private $mExternalLinkTarget; + public function getEditSection() { + return $this->getOption( 'editsection' ); + } /** - * Clean up signature texts? - * @see Parser::cleanSig + * Create "edit section" links? + * @param bool|null $x New value (null is no change) + * @return bool Old value */ - private $mCleanSignatures; + public function setEditSection( $x ) { + return $this->setOptionLegacy( 'editsection', $x ); + } /** - * Transform wiki markup when saving the page? + * Automatically number headings? + * @return bool */ - private $mPreSaveTransform = true; + public function getNumberHeadings() { + return $this->getOption( 'numberheadings' ); + } /** - * Whether content conversion should be disabled + * Automatically number headings? + * @param bool|null $x New value (null is no change) + * @return bool Old value */ - private $mDisableContentConversion; + public function setNumberHeadings( $x ) { + return $this->setOptionLegacy( 'numberheadings', $x ); + } /** - * Whether title conversion should be disabled + * Allow inclusion of special pages? + * @return bool */ - private $mDisableTitleConversion; + public function getAllowSpecialInclusion() { + return $this->getOption( 'allowSpecialInclusion' ); + } /** - * Automatically number headings? + * Allow inclusion of special pages? + * @param bool|null $x New value (null is no change) + * @return bool Old value */ - private $mNumberHeadings; + public function setAllowSpecialInclusion( $x ) { + return $this->setOptionLegacy( 'allowSpecialInclusion', $x ); + } /** - * Thumb size preferred by the user. + * Use tidy to cleanup output HTML? + * @return bool */ - private $mThumbSize; + public function getTidy() { + return $this->getOption( 'tidy' ); + } /** - * Maximum article size of an article to be marked as "stub" + * Use tidy to cleanup output HTML? + * @param bool|null $x New value (null is no change) + * @return bool Old value */ - private $mStubThreshold; + public function setTidy( $x ) { + return $this->setOptionLegacy( 'tidy', $x ); + } /** - * Language object of the User language. + * Parsing an interface message? + * @return bool */ - private $mUserLang; + public function getInterfaceMessage() { + return $this->getOption( 'interfaceMessage' ); + } /** - * @var User - * Stored user object + * Parsing an interface message? + * @param bool|null $x New value (null is no change) + * @return bool Old value */ - private $mUser; + public function setInterfaceMessage( $x ) { + return $this->setOptionLegacy( 'interfaceMessage', $x ); + } /** - * Parsing the page for a "preview" operation? + * Target language for the parse + * @return Language|null */ - private $mIsPreview = false; + public function getTargetLanguage() { + return $this->getOption( 'targetLanguage' ); + } /** - * Parsing the page for a "preview" operation on a single section? + * Target language for the parse + * @param Language|null $x New value + * @return Language|null Old value */ - private $mIsSectionPreview = false; + public function setTargetLanguage( $x ) { + return $this->setOption( 'targetLanguage', $x ); + } /** - * Parsing the printable version of the page? + * Maximum size of template expansions, in bytes + * @return int */ - private $mIsPrintable = false; + public function getMaxIncludeSize() { + return $this->getOption( 'maxIncludeSize' ); + } /** - * Extra key that should be present in the caching key. + * Maximum size of template expansions, in bytes + * @param int|null $x New value (null is no change) + * @return int Old value */ - private $mExtraKey = ''; + public function setMaxIncludeSize( $x ) { + return $this->setOptionLegacy( 'maxIncludeSize', $x ); + } /** - * Are magic ISBN links enabled? + * Maximum number of nodes touched by PPFrame::expand() + * @return int */ - private $mMagicISBNLinks = true; + public function getMaxPPNodeCount() { + return $this->getOption( 'maxPPNodeCount' ); + } /** - * Are magic PMID links enabled? + * Maximum number of nodes touched by PPFrame::expand() + * @param int|null $x New value (null is no change) + * @return int Old value */ - private $mMagicPMIDLinks = true; + public function setMaxPPNodeCount( $x ) { + return $this->setOptionLegacy( 'maxPPNodeCount', $x ); + } /** - * Are magic RFC links enabled? + * Maximum number of nodes generated by Preprocessor::preprocessToObj() + * @return int */ - private $mMagicRFCLinks = true; + public function getMaxGeneratedPPNodeCount() { + return $this->getOption( 'maxGeneratedPPNodeCount' ); + } /** - * Function to be called when an option is accessed. + * Maximum number of nodes generated by Preprocessor::preprocessToObj() + * @param int|null $x New value (null is no change) + * @return int */ - private $onAccessCallback = null; + public function setMaxGeneratedPPNodeCount( $x ) { + return $this->setOptionLegacy( 'maxGeneratedPPNodeCount', $x ); + } /** - * If the page being parsed is a redirect, this should hold the redirect - * target. - * @var Title|null + * Maximum recursion depth in PPFrame::expand() + * @return int */ - private $redirectTarget = null; + public function getMaxPPExpandDepth() { + return $this->getOption( 'maxPPExpandDepth' ); + } /** - * 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. - * @var boolean + * Maximum recursion depth for templates within templates + * @return int */ - private $allowUnsafeRawHtml = true; + public function getMaxTemplateDepth() { + return $this->getOption( 'maxTemplateDepth' ); + } /** - * CSS class to use to wrap output from Parser::parse(). - * @var string|false + * Maximum recursion depth for templates within templates + * @param int|null $x New value (null is no change) + * @return int Old value */ - private $wrapOutputClass = 'mw-parser-output'; - - public function getInterwikiMagic() { - return $this->mInterwikiMagic; - } - - public function getAllowExternalImages() { - return $this->mAllowExternalImages; - } - - public function getAllowExternalImagesFrom() { - return $this->mAllowExternalImagesFrom; - } - - public function getEnableImageWhitelist() { - return $this->mEnableImageWhitelist; - } - - public function getEditSection() { - return $this->mEditSection; + public function setMaxTemplateDepth( $x ) { + return $this->setOptionLegacy( 'maxTemplateDepth', $x ); } - public function getNumberHeadings() { - $this->optionUsed( 'numberheadings' ); - - return $this->mNumberHeadings; + /** + * Maximum number of calls per parse to expensive parser functions + * @since 1.20 + * @return int + */ + public function getExpensiveParserFunctionLimit() { + return $this->getOption( 'expensiveParserFunctionLimit' ); } - public function getAllowSpecialInclusion() { - return $this->mAllowSpecialInclusion; + /** + * 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 ); } - public function getTidy() { - return $this->mTidy; + /** + * Remove HTML comments + * @warning Only applies to preprocess operations + * @return bool + */ + public function getRemoveComments() { + return $this->getOption( 'removeComments' ); } - public function getInterfaceMessage() { - return $this->mInterfaceMessage; + /** + * 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 ); } - public function getTargetLanguage() { - return $this->mTargetLanguage; + /** + * Enable limit report in an HTML comment on output + * @return bool + */ + public function getEnableLimitReport() { + return $this->getOption( 'enableLimitReport' ); } - public function getMaxIncludeSize() { - return $this->mMaxIncludeSize; + /** + * 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 $this->setOptionLegacy( 'enableLimitReport', $x ); } - public function getMaxPPNodeCount() { - return $this->mMaxPPNodeCount; + /** + * Clean up signature texts? + * @see Parser::cleanSig + * @return bool + */ + public function getCleanSignatures() { + return $this->getOption( 'cleanSignatures' ); } - public function getMaxGeneratedPPNodeCount() { - return $this->mMaxGeneratedPPNodeCount; + /** + * 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 ); } - public function getMaxPPExpandDepth() { - return $this->mMaxPPExpandDepth; + /** + * Target attribute for external links + * @return string + */ + public function getExternalLinkTarget() { + return $this->getOption( 'externalLinkTarget' ); } - public function getMaxTemplateDepth() { - return $this->mMaxTemplateDepth; + /** + * Target attribute for external links + * @param string|null $x New value (null is no change) + * @return string Old value + */ + public function setExternalLinkTarget( $x ) { + return $this->setOptionLegacy( 'externalLinkTarget', $x ); } - /* @since 1.20 */ - public function getExpensiveParserFunctionLimit() { - return $this->mExpensiveParserFunctionLimit; + /** + * Whether content conversion should be disabled + * @return bool + */ + public function getDisableContentConversion() { + return $this->getOption( 'disableContentConversion' ); } - public function getRemoveComments() { - return $this->mRemoveComments; + /** + * 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 ); } - /* @since 1.24 */ - public function getCurrentRevisionCallback() { - return $this->mCurrentRevisionCallback; + /** + * Whether title conversion should be disabled + * @return bool + */ + public function getDisableTitleConversion() { + return $this->getOption( 'disableTitleConversion' ); } - public function getTemplateCallback() { - return $this->mTemplateCallback; + /** + * 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 ); } - /** @since 1.28 */ - public function getSpeculativeRevIdCallback() { - return $this->mSpeculativeRevIdCallback; + /** + * Thumb size preferred by the user. + * @return int + */ + public function getThumbSize() { + return $this->getOption( 'thumbsize' ); } - public function getEnableLimitReport() { - return $this->mEnableLimitReport; + /** + * 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 ); } - public function getCleanSignatures() { - return $this->mCleanSignatures; + /** + * Thumb size preferred by the user. + * @return int + */ + public function getStubThreshold() { + return $this->getOption( 'stubthreshold' ); } - public function getExternalLinkTarget() { - return $this->mExternalLinkTarget; + /** + * Thumb size preferred by the user. + * @param int|null $x New value (null is no change) + * @return int Old value + */ + public function setStubThreshold( $x ) { + return $this->setOptionLegacy( 'stubthreshold', $x ); } - public function getDisableContentConversion() { - return $this->mDisableContentConversion; + /** + * Parsing the page for a "preview" operation? + * @return bool + */ + public function getIsPreview() { + return $this->getOption( 'isPreview' ); } - public function getDisableTitleConversion() { - return $this->mDisableTitleConversion; + /** + * 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 ); } - public function getThumbSize() { - $this->optionUsed( 'thumbsize' ); - - return $this->mThumbSize; + /** + * Parsing the page for a "preview" operation on a single section? + * @return bool + */ + public function getIsSectionPreview() { + return $this->getOption( 'isSectionPreview' ); } - public function getStubThreshold() { - $this->optionUsed( 'stubthreshold' ); - - return $this->mStubThreshold; + /** + * 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 ); } - public function getIsPreview() { - return $this->mIsPreview; + /** + * Parsing the printable version of the page? + * @return bool + */ + public function getIsPrintable() { + return $this->getOption( 'printable' ); } - public function getIsSectionPreview() { - return $this->mIsSectionPreview; + /** + * 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 ); } - public function getIsPrintable() { - $this->optionUsed( 'printable' ); - - return $this->mIsPrintable; + /** + * Transform wiki markup when saving the page? + * @return bool + */ + public function getPreSaveTransform() { + return $this->getOption( 'preSaveTransform' ); } - public function getUser() { - return $this->mUser; + /** + * 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 ); } - public function getPreSaveTransform() { - return $this->mPreSaveTransform; + /** + * Date format index + * @return string + */ + public function getDateFormat() { + return $this->getOption( 'dateformat' ); } - public function getDateFormat() { - $this->optionUsed( 'dateformat' ); - if ( !isset( $this->mDateFormat ) ) { - $this->mDateFormat = $this->mUser->getDatePreference(); - } - return $this->mDateFormat; + /** + * Lazy initializer for dateFormat + */ + private static function initDateFormat( $popt ) { + return $popt->mUser->getDatePreference(); } - public function getTimestamp() { - if ( !isset( $this->mTimestamp ) ) { - $this->mTimestamp = wfTimestampNow(); - } - return $this->mTimestamp; + /** + * 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 ); } /** @@ -436,8 +691,7 @@ class ParserOptions { * @since 1.19 */ public function getUserLangObj() { - $this->optionUsed( 'userlang' ); - return $this->mUserLang; + return $this->getOption( 'userlang' ); } /** @@ -457,34 +711,72 @@ class ParserOptions { } /** + * 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 = Language::factory( $x ); + } + + return $this->setOptionLegacy( 'userlang', $x ); + } + + /** + * Are magic ISBN links enabled? * @since 1.28 * @return bool */ public function getMagicISBNLinks() { - return $this->mMagicISBNLinks; + return $this->getOption( 'magicISBNLinks' ); } /** + * Are magic PMID links enabled? * @since 1.28 * @return bool */ public function getMagicPMIDLinks() { - return $this->mMagicPMIDLinks; + return $this->getOption( 'magicPMIDLinks' ); } /** + * Are magic RFC links enabled? * @since 1.28 * @return bool */ - public function getMagicRFCLinks() { - return $this->mMagicRFCLinks; + public function getMagicRFCLinks() { + return $this->getOption( 'magicRFCLinks' ); + } + + /** + * 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 - * @return bool + * @param bool|null Value to set or null to get current value + * @return bool Current value for allowUnsafeRawHtml */ - public function getAllowUnsafeRawHtml() { - return $this->allowUnsafeRawHtml; + public function setAllowUnsafeRawHtml( $x ) { + return $this->setOptionLegacy( 'allowUnsafeRawHtml', $x ); } /** @@ -493,169 +785,97 @@ class ParserOptions { * @return string|bool */ public function getWrapOutputClass() { - $this->optionUsed( 'wrapclass' ); - return $this->wrapOutputClass; - } - - public function setInterwikiMagic( $x ) { - return wfSetVar( $this->mInterwikiMagic, $x ); - } - - public function setAllowExternalImages( $x ) { - return wfSetVar( $this->mAllowExternalImages, $x ); - } - - public function setAllowExternalImagesFrom( $x ) { - return wfSetVar( $this->mAllowExternalImagesFrom, $x ); - } - - public function setEnableImageWhitelist( $x ) { - return wfSetVar( $this->mEnableImageWhitelist, $x ); - } - - public function setDateFormat( $x ) { - return wfSetVar( $this->mDateFormat, $x ); - } - - public function setEditSection( $x ) { - return wfSetVar( $this->mEditSection, $x ); - } - - public function setNumberHeadings( $x ) { - return wfSetVar( $this->mNumberHeadings, $x ); + return $this->getOption( 'wrapclass' ); } - public function setAllowSpecialInclusion( $x ) { - return wfSetVar( $this->mAllowSpecialInclusion, $x ); - } - - public function setTidy( $x ) { - return wfSetVar( $this->mTidy, $x ); - } - - public function setInterfaceMessage( $x ) { - return wfSetVar( $this->mInterfaceMessage, $x ); - } - - public function setTargetLanguage( $x ) { - return wfSetVar( $this->mTargetLanguage, $x, true ); - } - - public function setMaxIncludeSize( $x ) { - return wfSetVar( $this->mMaxIncludeSize, $x ); - } - - public function setMaxPPNodeCount( $x ) { - return wfSetVar( $this->mMaxPPNodeCount, $x ); - } - - public function setMaxGeneratedPPNodeCount( $x ) { - return wfSetVar( $this->mMaxGeneratedPPNodeCount, $x ); - } - - public function setMaxTemplateDepth( $x ) { - return wfSetVar( $this->mMaxTemplateDepth, $x ); - } - - /* @since 1.20 */ - public function setExpensiveParserFunctionLimit( $x ) { - return wfSetVar( $this->mExpensiveParserFunctionLimit, $x ); + /** + * CSS class to use to wrap output from Parser::parse() + * @since 1.30 + * @param string|bool $className Set false to disable wrapping. + * @return string|bool Current value + */ + public function setWrapOutputClass( $className ) { + if ( $className === true ) { // DWIM, they probably want the default class name + $className = 'mw-parser-output'; + } + return $this->setOption( 'wrapclass', $className ); } - public function setRemoveComments( $x ) { - return wfSetVar( $this->mRemoveComments, $x ); + /** + * Callback for current revision fetching; first argument to call_user_func(). + * @since 1.24 + * @return callable + */ + public function getCurrentRevisionCallback() { + return $this->getOption( 'currentRevisionCallback' ); } - /* @since 1.24 */ + /** + * Callback for current revision fetching; first argument to call_user_func(). + * @since 1.24 + * @param callable|null $x New value (null is no change) + * @return callable Old value + */ public function setCurrentRevisionCallback( $x ) { - return wfSetVar( $this->mCurrentRevisionCallback, $x ); + return $this->setOptionLegacy( 'currentRevisionCallback', $x ); } - /** @since 1.28 */ - public function setSpeculativeRevIdCallback( $x ) { - return wfSetVar( $this->mSpeculativeRevIdCallback, $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 wfSetVar( $this->mTemplateCallback, $x ); - } - - public function enableLimitReport( $x = true ) { - return wfSetVar( $this->mEnableLimitReport, $x ); - } - - public function setTimestamp( $x ) { - return wfSetVar( $this->mTimestamp, $x ); - } - - public function setCleanSignatures( $x ) { - return wfSetVar( $this->mCleanSignatures, $x ); - } - - public function setExternalLinkTarget( $x ) { - return wfSetVar( $this->mExternalLinkTarget, $x ); - } - - public function disableContentConversion( $x = true ) { - return wfSetVar( $this->mDisableContentConversion, $x ); - } - - public function disableTitleConversion( $x = true ) { - return wfSetVar( $this->mDisableTitleConversion, $x ); - } - - public function setUserLang( $x ) { - if ( is_string( $x ) ) { - $x = Language::factory( $x ); - } - - return wfSetVar( $this->mUserLang, $x ); - } - - public function setThumbSize( $x ) { - return wfSetVar( $this->mThumbSize, $x ); - } - - public function setStubThreshold( $x ) { - return wfSetVar( $this->mStubThreshold, $x ); - } - - public function setPreSaveTransform( $x ) { - return wfSetVar( $this->mPreSaveTransform, $x ); + return $this->setOptionLegacy( 'templateCallback', $x ); } - public function setIsPreview( $x ) { - return wfSetVar( $this->mIsPreview, $x ); + /** + * Callback to generate a guess for {{REVISIONID}} + * @since 1.28 + * @return callable|null + */ + public function getSpeculativeRevIdCallback() { + return $this->getOption( 'speculativeRevIdCallback' ); } - public function setIsSectionPreview( $x ) { - return wfSetVar( $this->mIsSectionPreview, $x ); + /** + * Callback to generate a guess for {{REVISIONID}} + * @since 1.28 + * @param callable|null $x New value (null is no change) + * @return callable|null Old value + */ + public function setSpeculativeRevIdCallback( $x ) { + return $this->setOptionLegacy( 'speculativeRevIdCallback', $x ); } - public function setIsPrintable( $x ) { - return wfSetVar( $this->mIsPrintable, $x ); - } + /**@}*/ /** - * @param bool|null Value to set or null to get current value - * @return bool Current value for allowUnsafeRawHtml - * @since 1.29 + * Timestamp used for {{CURRENTDAY}} etc. + * @return string */ - public function setAllowUnsafeRawHtml( $x ) { - return wfSetVar( $this->allowUnsafeRawHtml, $x ); + public function getTimestamp() { + if ( !isset( $this->mTimestamp ) ) { + $this->mTimestamp = wfTimestampNow(); + } + return $this->mTimestamp; } /** - * CSS class to use to wrap output from Parser::parse() - * @since 1.30 - * @param string|bool $className Set false to disable wrapping. - * @return string|bool Current value + * Timestamp used for {{CURRENTDAY}} etc. + * @param string|null $x New value (null is no change) + * @return string Old value */ - public function setWrapOutputClass( $className ) { - if ( $className === true ) { // DWIM, they probably want the default class name - $className = 'mw-parser-output'; - } - return wfSetVar( $this->wrapOutputClass, $className ); + public function setTimestamp( $x ) { + return wfSetVar( $this->mTimestamp, $x ); } /** @@ -684,14 +904,27 @@ class ParserOptions { /** * 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; } + /** + * Current user + * @return User + */ + public function getUser() { + return $this->mUser; + } + /** * Constructor + * @warning For interaction with the parser cache, use + * WikiPage::makeParserOptions(), ContentHandler::makeParserOptions(), or + * ParserOptions::newCanonical() instead. * @param User $user * @param Language $lang */ @@ -716,6 +949,9 @@ class ParserOptions { /** * Get a ParserOptions object for an anonymous user + * @warning For interaction with the parser cache, use + * WikiPage::makeParserOptions(), ContentHandler::makeParserOptions(), or + * ParserOptions::newCanonical() instead. * @since 1.27 * @return ParserOptions */ @@ -728,6 +964,9 @@ class ParserOptions { * Get a ParserOptions object from a given user. * Language will be taken from $wgLang. * + * @warning For interaction with the parser cache, use + * WikiPage::makeParserOptions(), ContentHandler::makeParserOptions(), or + * ParserOptions::newCanonical() instead. * @param User $user * @return ParserOptions */ @@ -738,6 +977,9 @@ class ParserOptions { /** * Get a ParserOptions object from a given user and language * + * @warning For interaction with the parser cache, use + * WikiPage::makeParserOptions(), ContentHandler::makeParserOptions(), or + * ParserOptions::newCanonical() instead. * @param User $user * @param Language $lang * @return ParserOptions @@ -749,6 +991,9 @@ class ParserOptions { /** * Get a ParserOptions object from a IContextSource object * + * @warning For interaction with the parser cache, use + * WikiPage::makeParserOptions(), ContentHandler::makeParserOptions(), or + * ParserOptions::newCanonical() instead. * @param IContextSource $context * @return ParserOptions */ @@ -757,44 +1002,130 @@ class ParserOptions { } /** - * Get user options + * Creates a "canonical" ParserOptions object * - * @param User $user - * @param Language $lang + * For historical reasons, certain options have default values that are + * different from the canonical values used for caching. + * + * @since 1.30 + * @param User|null $user + * @param Language|StubObject|null $lang + * @return ParserOptions */ - private function initialiseFromUser( $user, $lang ) { + public static function newCanonical( User $user = null, $lang = null ) { + $ret = new ParserOptions( $user, $lang ); + foreach ( self::getCanonicalOverrides() as $k => $v ) { + $ret->setOption( $k, $v ); + } + return $ret; + } + + /** + * Get default option values + * @warning If you change the default for an existing option (unless it's + * being overridden by self::getCanonicalOverrides()), 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() { global $wgInterwikiMagic, $wgAllowExternalImages, $wgAllowExternalImagesFrom, $wgEnableImageWhitelist, $wgAllowSpecialInclusion, $wgMaxArticleSize, $wgMaxPPNodeCount, $wgMaxTemplateDepth, $wgMaxPPExpandDepth, $wgCleanSignatures, $wgExternalLinkTarget, $wgExpensiveParserFunctionLimit, $wgMaxGeneratedPPNodeCount, $wgDisableLangConversion, $wgDisableTitleConversion, - $wgEnableMagicLinks; - - // *UPDATE* ParserOptions::matches() if any of this changes as needed - $this->mInterwikiMagic = $wgInterwikiMagic; - $this->mAllowExternalImages = $wgAllowExternalImages; - $this->mAllowExternalImagesFrom = $wgAllowExternalImagesFrom; - $this->mEnableImageWhitelist = $wgEnableImageWhitelist; - $this->mAllowSpecialInclusion = $wgAllowSpecialInclusion; - $this->mMaxIncludeSize = $wgMaxArticleSize * 1024; - $this->mMaxPPNodeCount = $wgMaxPPNodeCount; - $this->mMaxGeneratedPPNodeCount = $wgMaxGeneratedPPNodeCount; - $this->mMaxPPExpandDepth = $wgMaxPPExpandDepth; - $this->mMaxTemplateDepth = $wgMaxTemplateDepth; - $this->mExpensiveParserFunctionLimit = $wgExpensiveParserFunctionLimit; - $this->mCleanSignatures = $wgCleanSignatures; - $this->mExternalLinkTarget = $wgExternalLinkTarget; - $this->mDisableContentConversion = $wgDisableLangConversion; - $this->mDisableTitleConversion = $wgDisableLangConversion || $wgDisableTitleConversion; - $this->mMagicISBNLinks = $wgEnableMagicLinks['ISBN']; - $this->mMagicPMIDLinks = $wgEnableMagicLinks['PMID']; - $this->mMagicRFCLinks = $wgEnableMagicLinks['RFC']; + $wgEnableMagicLinks, $wgContLang; + + if ( self::$defaults === null ) { + // *UPDATE* ParserOptions::matches() if any of this changes as needed + self::$defaults = [ + 'dateformat' => null, + 'editsection' => true, + 'tidy' => false, + 'interfaceMessage' => false, + 'targetLanguage' => null, + 'removeComments' => true, + 'enableLimitReport' => false, + 'preSaveTransform' => true, + 'isPreview' => false, + 'isSectionPreview' => false, + 'printable' => false, + 'allowUnsafeRawHtml' => true, + 'wrapclass' => 'mw-parser-output', + 'currentRevisionCallback' => [ 'Parser', 'statelessFetchRevision' ], + 'templateCallback' => [ 'Parser', 'statelessFetchTemplate' ], + 'speculativeRevIdCallback' => null, + ]; + + Hooks::run( 'ParserOptionsRegister', [ + &self::$defaults, + &self::$inCacheKey, + &self::$lazyOptions, + ] ); + + ksort( self::$inCacheKey ); + } + + // Unit tests depend on being able to modify the globals at will + return self::$defaults + [ + 'interwikiMagic' => $wgInterwikiMagic, + 'allowExternalImages' => $wgAllowExternalImages, + 'allowExternalImagesFrom' => $wgAllowExternalImagesFrom, + 'enableImageWhitelist' => $wgEnableImageWhitelist, + 'allowSpecialInclusion' => $wgAllowSpecialInclusion, + 'maxIncludeSize' => $wgMaxArticleSize * 1024, + 'maxPPNodeCount' => $wgMaxPPNodeCount, + 'maxGeneratedPPNodeCount' => $wgMaxGeneratedPPNodeCount, + 'maxPPExpandDepth' => $wgMaxPPExpandDepth, + 'maxTemplateDepth' => $wgMaxTemplateDepth, + 'expensiveParserFunctionLimit' => $wgExpensiveParserFunctionLimit, + 'externalLinkTarget' => $wgExternalLinkTarget, + 'cleanSignatures' => $wgCleanSignatures, + 'disableContentConversion' => $wgDisableLangConversion, + 'disableTitleConversion' => $wgDisableLangConversion || $wgDisableTitleConversion, + 'magicISBNLinks' => $wgEnableMagicLinks['ISBN'], + 'magicPMIDLinks' => $wgEnableMagicLinks['PMID'], + 'magicRFCLinks' => $wgEnableMagicLinks['RFC'], + 'numberheadings' => User::getDefaultOption( 'numberheadings' ), + 'thumbsize' => User::getDefaultOption( 'thumbsize' ), + 'stubthreshold' => 0, + 'userlang' => $wgContLang, + ]; + } + + /** + * Get "canonical" non-default option values + * @see self::newCanonical + * @warning If you change the override 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 getCanonicalOverrides() { + global $wgEnableParserLimitReporting; + + return [ + 'tidy' => true, + 'enableLimitReport' => $wgEnableParserLimitReporting, + ]; + } + + /** + * Get user options + * + * @param User $user + * @param Language $lang + */ + private function initialiseFromUser( $user, $lang ) { + $this->options = self::getDefaults(); $this->mUser = $user; - $this->mNumberHeadings = $user->getOption( 'numberheadings' ); - $this->mThumbSize = $user->getOption( 'thumbsize' ); - $this->mStubThreshold = $user->getStubThreshold(); - $this->mUserLang = $lang; + $this->options['numberheadings'] = $user->getOption( 'numberheadings' ); + $this->options['thumbsize'] = $user->getOption( 'thumbsize' ); + $this->options['stubthreshold'] = $user->getStubThreshold(); + $this->options['userlang'] = $lang; } /** @@ -807,9 +1138,36 @@ class ParserOptions { * @since 1.25 */ public function matches( ParserOptions $other ) { + // Populate lazy options + foreach ( self::$lazyOptions as $name => $callback ) { + if ( $this->options[$name] === null ) { + $this->options[$name] = call_user_func( $callback, $this, $name ); + } + if ( $other->options[$name] === null ) { + $other->options[$name] = call_user_func( $callback, $other, $name ); + } + } + + // Compare most options + $options = array_keys( $this->options ); + $options = array_diff( $options, [ + 'enableLimitReport', // only affects HTML comments + ] ); + foreach ( $options as $option ) { + $o1 = $this->optionToString( $this->options[$option] ); + $o2 = $this->optionToString( $other->options[$option] ); + if ( $o1 !== $o2 ) { + return false; + } + } + + // Compare most other fields $fields = array_keys( get_class_vars( __CLASS__ ) ); $fields = array_diff( $fields, [ - 'mEnableLimitReport', // only effects HTML comments + 'defaults', // static + 'lazyOptions', // static + 'inCacheKey', // static + 'options', // Already checked above 'onAccessCallback', // only used for ParserOutput option tracking ] ); foreach ( $fields as $field ) { @@ -817,11 +1175,8 @@ class ParserOptions { return false; } } - // Check the object and lazy-loaded options - return ( - $this->mUserLang->equals( $other->mUserLang ) && - $this->getDateFormat() === $other->getDateFormat() - ); + + return true; } /** @@ -851,6 +1206,7 @@ class ParserOptions { * Returns the full array of options that would have been used by * in 1.16. * Used to get the old parser cache entries when available. + * @todo 1.16 was years ago, can we remove this? * @return array */ public static function legacyOptions() { @@ -864,6 +1220,27 @@ class ParserOptions { ]; } + /** + * 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 '[' . join( ',', 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. @@ -871,10 +1248,6 @@ class ParserOptions { * so users sharing the options with vary for the same page share * the same cached data safely. * - * Extensions which require it should install 'PageRenderingHash' hook, - * which will give them a chance to modify this key based on their own - * settings. - * * @since 1.17 * @param array $forOptions * @param Title $title Used to get the content language of the page (since r97636) @@ -883,6 +1256,61 @@ class ParserOptions { public function optionsHash( $forOptions, $title = null ) { global $wgRenderHashAppend; + // 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. + $defaults = self::getCanonicalOverrides() + self::getDefaults(); + $values = []; + foreach ( self::$inCacheKey as $option => $include ) { + if ( $include && in_array( $option, $forOptions, true ) ) { + $v = $this->optionToString( $this->options[$option] ); + $d = $this->optionToString( $defaults[$option] ); + if ( $v !== $d ) { + $values[] = "$option=$v"; + } + } + } + + $confstr = $values ? join( '!', $values ) : 'canonical'; + + // add in language specific options, if any + // @todo FIXME: This is just a way of retrieving the url/user preferred variant + if ( !is_null( $title ) ) { + $confstr .= $title->getPageLanguage()->getExtraHashOptions(); + } else { + global $wgContLang; + $confstr .= $wgContLang->getExtraHashOptions(); + } + + $confstr .= $wgRenderHashAppend; + + if ( $this->mExtraKey != '' ) { + $confstr .= $this->mExtraKey; + } + + // Give a chance for extensions to modify the hash, if they have + // extra options or other effects on the parser cache. + Hooks::run( 'PageRenderingHash', [ &$confstr, $this->getUser(), &$forOptions ] ); + + // Make it a valid memcached key fragment + $confstr = str_replace( ' ', '_', $confstr ); + + return $confstr; + } + + /** + * Generate the hash used before MediaWiki 1.30 + * @since 1.30 + * @deprecated since 1.30. Do not use this unless you're ParserCache. + * @param array $forOptions + * @param Title $title Used to get the content language of the page (since r97636) + * @return string Page rendering hash + */ + public function optionsHashPre30( $forOptions, $title = null ) { + global $wgRenderHashAppend; + // FIXME: Once the cache key is reorganized this argument // can be dropped. It was used when the math extension was // part of core. @@ -892,7 +1320,7 @@ class ParserOptions { // since it disables the parser cache, its value will always // be 0 when this function is called by parsercache. if ( in_array( 'stubthreshold', $forOptions ) ) { - $confstr .= '!' . $this->mStubThreshold; + $confstr .= '!' . $this->options['stubthreshold']; } else { $confstr .= '!*'; } @@ -902,19 +1330,19 @@ class ParserOptions { } if ( in_array( 'numberheadings', $forOptions ) ) { - $confstr .= '!' . ( $this->mNumberHeadings ? '1' : '' ); + $confstr .= '!' . ( $this->options['numberheadings'] ? '1' : '' ); } else { $confstr .= '!*'; } if ( in_array( 'userlang', $forOptions ) ) { - $confstr .= '!' . $this->mUserLang->getCode(); + $confstr .= '!' . $this->options['userlang']->getCode(); } else { $confstr .= '!*'; } if ( in_array( 'thumbsize', $forOptions ) ) { - $confstr .= '!' . $this->mThumbSize; + $confstr .= '!' . $this->options['thumbsize']; } else { $confstr .= '!*'; } @@ -936,16 +1364,18 @@ class ParserOptions { // directly. At least Wikibase does at this point in time. if ( !in_array( 'editsection', $forOptions ) ) { $confstr .= '!*'; - } elseif ( !$this->mEditSection ) { + } elseif ( !$this->options['editsection'] ) { $confstr .= '!edit=0'; } - if ( $this->mIsPrintable && in_array( 'printable', $forOptions ) ) { + if ( $this->options['printable'] && in_array( 'printable', $forOptions ) ) { $confstr .= '!printable=1'; } - if ( $this->wrapOutputClass !== 'mw-parser-output' && in_array( 'wrapclass', $forOptions ) ) { - $confstr .= '!wrapclass=' . $this->wrapOutputClass; + if ( $this->options['wrapclass'] !== 'mw-parser-output' && + in_array( 'wrapclass', $forOptions ) + ) { + $confstr .= '!wrapclass=' . $this->options['wrapclass']; } if ( $this->mExtraKey != '' ) { @@ -962,6 +1392,25 @@ class ParserOptions { return $confstr; } + /** + * Test whether these options are safe to cache + * @since 1.30 + * @return bool + */ + public function isSafeToCache() { + $defaults = self::getCanonicalOverrides() + self::getDefaults(); + foreach ( $this->options as $option => $value ) { + if ( empty( self::$inCacheKey[$option] ) ) { + $v = $this->optionToString( $value ); + $d = $this->optionToString( $defaults[$option] ); + 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. @@ -1009,3 +1458,8 @@ class ParserOptions { } ); } } + +/** + * For really cool vim folding this needs to be at the end: + * vim: foldmarker=@{,@} foldmethod=marker + */ diff --git a/tests/phpunit/includes/deferred/LinksUpdateTest.php b/tests/phpunit/includes/deferred/LinksUpdateTest.php index 9cc3ffdab7..639c323cf2 100644 --- a/tests/phpunit/includes/deferred/LinksUpdateTest.php +++ b/tests/phpunit/includes/deferred/LinksUpdateTest.php @@ -167,7 +167,7 @@ class LinksUpdateTest extends MediaWikiLangTestCase { $this->assertRecentChangeByCategorization( $title, - $wikiPage->getParserOutput( new ParserOptions() ), + $wikiPage->getParserOutput( ParserOptions::newCanonical() ), Title::newFromText( 'Category:Foo' ), [ [ 'Foo', '[[:Testing]] added to category' ] ] ); @@ -177,7 +177,7 @@ class LinksUpdateTest extends MediaWikiLangTestCase { $this->assertRecentChangeByCategorization( $title, - $wikiPage->getParserOutput( new ParserOptions() ), + $wikiPage->getParserOutput( ParserOptions::newCanonical() ), Title::newFromText( 'Category:Foo' ), [ [ 'Foo', '[[:Testing]] added to category' ], @@ -187,7 +187,7 @@ class LinksUpdateTest extends MediaWikiLangTestCase { $this->assertRecentChangeByCategorization( $title, - $wikiPage->getParserOutput( new ParserOptions() ), + $wikiPage->getParserOutput( ParserOptions::newCanonical() ), Title::newFromText( 'Category:Bar' ), [ [ 'Bar', '[[:Testing]] added to category' ], @@ -211,7 +211,7 @@ class LinksUpdateTest extends MediaWikiLangTestCase { $this->assertRecentChangeByCategorization( $templateTitle, - $templatePage->getParserOutput( new ParserOptions() ), + $templatePage->getParserOutput( ParserOptions::newCanonical() ), Title::newFromText( 'Baz' ), [] ); @@ -221,7 +221,7 @@ class LinksUpdateTest extends MediaWikiLangTestCase { $this->assertRecentChangeByCategorization( $templateTitle, - $templatePage->getParserOutput( new ParserOptions() ), + $templatePage->getParserOutput( ParserOptions::newCanonical() ), Title::newFromText( 'Baz' ), [ [ 'Baz', diff --git a/tests/phpunit/includes/parser/ParserOptionsTest.php b/tests/phpunit/includes/parser/ParserOptionsTest.php index aacdb1a33c..81f0564024 100644 --- a/tests/phpunit/includes/parser/ParserOptionsTest.php +++ b/tests/phpunit/includes/parser/ParserOptionsTest.php @@ -6,13 +6,46 @@ use Wikimedia\ScopedCallback; class ParserOptionsTest extends MediaWikiTestCase { /** - * @dataProvider provideOptionsHash + * @dataProvider provideIsSafeToCache + * @param bool $expect Expected value + * @param array $options Options to set + */ + public function testIsSafeToCache( $expect, $options ) { + $popt = ParserOptions::newCanonical(); + foreach ( $options as $name => $value ) { + $popt->setOption( $name, $value ); + } + $this->assertSame( $expect, $popt->isSafeToCache() ); + } + + public static function provideIsSafeToCache() { + return [ + 'No overrides' => [ true, [] ], + 'In-key options are ok' => [ true, [ + 'editsection' => false, + 'thumbsize' => 1e100, + 'wrapclass' => false, + ] ], + 'Non-in-key options are not ok' => [ false, [ + 'removeComments' => false, + ] ], + 'Canonical override, not default (1)' => [ true, [ + 'tidy' => true, + ] ], + 'Canonical override, not default (2)' => [ false, [ + 'tidy' => false, + ] ], + ]; + } + + /** + * @dataProvider provideOptionsHashPre30 * @param array $usedOptions Used options * @param string $expect Expected value * @param array $options Options to set * @param array $globals Globals to set */ - public function testOptionsHash( $usedOptions, $expect, $options, $globals = [] ) { + public function testOptionsHashPre30( $usedOptions, $expect, $options, $globals = [] ) { global $wgHooks; $globals += [ @@ -28,10 +61,10 @@ class ParserOptionsTest extends MediaWikiTestCase { foreach ( $options as $setter => $value ) { $popt->$setter( $value ); } - $this->assertSame( $expect, $popt->optionsHash( $usedOptions ) ); + $this->assertSame( $expect, $popt->optionsHashPre30( $usedOptions ) ); } - public static function provideOptionsHash() { + public static function provideOptionsHashPre30() { $used = [ 'wrapclass', 'editsection', 'printable' ]; return [ @@ -57,13 +90,99 @@ class ParserOptionsTest extends MediaWikiTestCase { ]; } + /** + * @dataProvider provideOptionsHash + * @param array $usedOptions Used options + * @param string $expect Expected value + * @param array $options Options to set + * @param array $globals Globals to set + */ + public function testOptionsHash( $usedOptions, $expect, $options, $globals = [] ) { + global $wgHooks; + + $globals += [ + 'wgRenderHashAppend' => '', + 'wgHooks' => [], + ]; + $globals['wgHooks'] += [ + 'PageRenderingHash' => [], + ] + $wgHooks; + $this->setMwGlobals( $globals ); + + $popt = ParserOptions::newCanonical(); + foreach ( $options as $name => $value ) { + $popt->setOption( $name, $value ); + } + $this->assertSame( $expect, $popt->optionsHash( $usedOptions ) ); + } + + public static function provideOptionsHash() { + $used = [ 'wrapclass', 'editsection', 'printable' ]; + + $classWrapper = TestingAccessWrapper::newFromClass( ParserOptions::class ); + $classWrapper->getDefaults(); + $allUsableOptions = array_diff( + array_keys( $classWrapper->inCacheKey ), + array_keys( $classWrapper->lazyOptions ) + ); + + return [ + 'Canonical options, nothing used' => [ [], 'canonical', [] ], + 'Canonical options, used some options' => [ $used, 'canonical', [] ], + 'Used some options, non-default values' => [ + $used, + 'printable=1!wrapclass=foobar', + [ + 'wrapclass' => 'foobar', + 'printable' => true, + ] + ], + 'Canonical options, used all non-lazy options' => [ $allUsableOptions, 'canonical', [] ], + 'Canonical options, nothing used, but with hooks and $wgRenderHashAppend' => [ + [], + 'canonical!wgRenderHashAppend!onPageRenderingHash', + [], + [ + 'wgRenderHashAppend' => '!wgRenderHashAppend', + 'wgHooks' => [ 'PageRenderingHash' => [ [ __CLASS__ . '::onPageRenderingHash' ] ] ], + ] + ], + ]; + } + public static function onPageRenderingHash( &$confstr ) { $confstr .= '!onPageRenderingHash'; } + /** + * @expectedException InvalidArgumentException + * @expectedExceptionMessage Unknown parser option bogus + */ + public function testGetInvalidOption() { + $popt = ParserOptions::newCanonical(); + $popt->getOption( 'bogus' ); + } + + /** + * @expectedException InvalidArgumentException + * @expectedExceptionMessage Unknown parser option bogus + */ + public function testSetInvalidOption() { + $popt = ParserOptions::newCanonical(); + $popt->setOption( 'bogus', true ); + } + public function testMatches() { - $popt1 = new ParserOptions(); - $popt2 = new ParserOptions(); + $classWrapper = TestingAccessWrapper::newFromClass( ParserOptions::class ); + $oldDefaults = $classWrapper->defaults; + $oldLazy = $classWrapper->lazyOptions; + $reset = new ScopedCallback( function () use ( $classWrapper, $oldDefaults, $oldLazy ) { + $classWrapper->defaults = $oldDefaults; + $classWrapper->lazyOptions = $oldLazy; + } ); + + $popt1 = ParserOptions::newCanonical(); + $popt2 = ParserOptions::newCanonical(); $this->assertTrue( $popt1->matches( $popt2 ) ); $popt1->enableLimitReport( true ); @@ -72,6 +191,17 @@ class ParserOptionsTest extends MediaWikiTestCase { $popt2->setTidy( !$popt2->getTidy() ); $this->assertFalse( $popt1->matches( $popt2 ) ); + + $ctr = 0; + $classWrapper->defaults += [ __METHOD__ => null ]; + $classWrapper->lazyOptions += [ __METHOD__ => function () use ( &$ctr ) { + return ++$ctr; + } ]; + $popt1 = ParserOptions::newCanonical(); + $popt2 = ParserOptions::newCanonical(); + $this->assertFalse( $popt1->matches( $popt2 ) ); + + ScopedCallback::consume( $reset ); } }