Try harder to avoid parser cache pollution
authorBrad Jorsch <bjorsch@wikimedia.org>
Fri, 12 May 2017 21:38:12 +0000 (17:38 -0400)
committerAnomie <bjorsch@wikimedia.org>
Mon, 5 Jun 2017 14:17:28 +0000 (14:17 +0000)
* 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

RELEASE-NOTES-1.30
docs/hooks.txt
includes/content/ContentHandler.php
includes/page/WikiPage.php
includes/parser/ParserCache.php
includes/parser/ParserOptions.php
tests/phpunit/includes/deferred/LinksUpdateTest.php
tests/phpunit/includes/parser/ParserOptionsTest.php

index 22fed0c..64fe822 100644 (file)
@@ -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 ===
 
index 62b22e1..0e8b508 100644 (file)
@@ -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
index bccb147..85894ed 100644 (file)
@@ -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 );
        }
 
        /**
index 2adc5fb..0e23a88 100644 (file)
@@ -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() ) {
index 76a7e1e..9c6cf93 100644 (file)
@@ -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" );
index d097414..f8ed63f 100644 (file)
@@ -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
+ */
index 9cc3ffd..639c323 100644 (file)
@@ -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',
index aacdb1a..81f0564 100644 (file)
@@ -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 );
        }
 
 }