]> scripts.mit.edu Git - autoinstallsdev/mediawiki.git/blobdiff - vendor/wikimedia/css-sanitizer/src/Grammar/MatcherFactory.php
MediaWiki 1.30.2
[autoinstallsdev/mediawiki.git] / vendor / wikimedia / css-sanitizer / src / Grammar / MatcherFactory.php
diff --git a/vendor/wikimedia/css-sanitizer/src/Grammar/MatcherFactory.php b/vendor/wikimedia/css-sanitizer/src/Grammar/MatcherFactory.php
new file mode 100644 (file)
index 0000000..d4a1e58
--- /dev/null
@@ -0,0 +1,1282 @@
+<?php
+/**
+ * @file
+ * @license https://opensource.org/licenses/Apache-2.0 Apache-2.0
+ */
+
+namespace Wikimedia\CSS\Grammar;
+
+use Wikimedia\CSS\Objects\Token;
+
+/**
+ * Factory for predefined Grammar matchers
+ * @note For security, the attr() and var() functions are not supported.
+ */
+class MatcherFactory {
+       /** @var MatcherFactory|null */
+       private static $instance = null;
+
+       /** @var Matcher[] Cache of constructed matchers */
+       protected $cache = [];
+
+       /** @var string[] length units */
+       protected static $lengthUnits = [ 'em', 'ex', 'ch', 'rem', 'vw', 'vh',
+               'vmin', 'vmax', 'cm', 'mm', 'Q', 'in', 'pc', 'pt', 'px' ];
+
+       /** @var string[] angle units */
+       protected static $angleUnits = [ 'deg', 'grad', 'rad', 'turn' ];
+
+       /** @var string[] time units */
+       protected static $timeUnits = [ 's', 'ms' ];
+
+       /** @var string[] frequency units */
+       protected static $frequencyUnits = [ 'Hz', 'kHz' ];
+
+       /**
+        * Return a static instance of the factory
+        * @return MatcherFactory
+        */
+       public static function singleton() {
+               if ( !self::$instance ) {
+                       self::$instance = new self();
+               }
+               return self::$instance;
+       }
+
+       /**
+        * Matcher for optional whitespace
+        * @return Matcher
+        */
+       public function optionalWhitespace() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = new WhitespaceMatcher( [ 'significant' => false ] );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for required whitespace
+        * @return Matcher
+        */
+       public function significantWhitespace() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = new WhitespaceMatcher( [ 'significant' => true ] );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a comma
+        * @return Matcher
+        */
+       public function comma() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = new TokenMatcher( Token::T_COMMA );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for an arbitrary identifier
+        * @return Matcher
+        */
+       public function ident() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = new TokenMatcher( Token::T_IDENT );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a string
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#strings
+        * @warning If the string will be used as a URL, use self::urlstring() instead.
+        * @return Matcher
+        */
+       public function string() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = new TokenMatcher( Token::T_STRING );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a string containing a URL
+        * @param string $type Type of resource referenced, e.g. "image" or "audio".
+        *  Not used here, but might be used by a subclass to validate the URL more strictly.
+        * @return Matcher
+        */
+       public function urlstring( $type ) {
+               return $this->string();
+       }
+
+       /**
+        * Matcher for a URL
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#urls
+        * @param string $type Type of resource referenced, e.g. "image" or "audio".
+        *  Not used here, but might be used by a subclass to validate the URL more strictly.
+        * @return Matcher
+        */
+       public function url( $type ) {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = new UrlMatcher();
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * CSS-wide value keywords
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#common-keywords
+        * @return Matcher
+        */
+       public function cssWideKeywords() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = new KeywordMatcher( [ 'initial', 'inherit', 'unset' ] );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Add calc() support to a basic type matcher
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#calc-notation
+        * @param Matcher $typeMatcher Matcher for the type
+        * @param string $type Type being matched
+        * @return Matcher
+        */
+       public function calc( Matcher $typeMatcher, $type ) {
+               if ( $type === 'integer' ) {
+                       $num = $this->rawInteger();
+               } else {
+                       $num = $this->rawNumber();
+               }
+
+               $ows = $this->optionalWhitespace();
+               $ws = $this->significantWhitespace();
+
+               // Definitions are recursive. This will be used by reference and later
+               // will be replaced.
+               $calcValue = new NothingMatcher();
+
+               if ( $type === 'integer' ) {
+                       // Division will always resolve to a number, making the expression
+                       // invalid, so don't allow it.
+                       $calcProduct = new Juxtaposition( [
+                               &$calcValue,
+                               Quantifier::star( new Juxtaposition( [ $ows, new DelimMatcher( '*' ), $ows, &$calcValue ] ) )
+                       ] );
+               } else {
+                       $calcProduct = new Juxtaposition( [
+                               &$calcValue,
+                               Quantifier::star( new Alternative( [
+                                       new Juxtaposition( [ $ows, new DelimMatcher( '*' ), $ows, &$calcValue ] ),
+                                       new Juxtaposition( [ $ows, new DelimMatcher( '/' ), $ows, $this->rawNumber() ] ),
+                               ] ) ),
+                       ] );
+               }
+
+               $calcSum = new Juxtaposition( [
+                       $ows,
+                       $calcProduct,
+                       Quantifier::star( new Juxtaposition( [
+                               $ws, new DelimMatcher( [ '+', '-' ] ), $ws, $calcProduct
+                       ] ) ),
+                       $ows,
+               ] );
+
+               $calcFunc = new FunctionMatcher( 'calc', $calcSum );
+
+               if ( $num === $typeMatcher ) {
+                       $calcValue = new Alternative( [
+                               $typeMatcher,
+                               new BlockMatcher( Token::T_LEFT_PAREN, $calcSum ),
+                               $calcFunc,
+                       ] );
+               } else {
+                       $calcValue = new Alternative( [
+                               $num,
+                               $typeMatcher,
+                               new BlockMatcher( Token::T_LEFT_PAREN, $calcSum ),
+                               $calcFunc,
+                       ] );
+               }
+
+               return new Alternative( [ $typeMatcher, $calcFunc ] );
+       }
+
+       /**
+        * Matcher for an integer value, without calc()
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#integers
+        * @return Matcher
+        */
+       protected function rawInteger() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = new TokenMatcher( Token::T_NUMBER, function ( Token $t ) {
+                               // The spec says it must match /^[+-]\d+$/, but the tokenizer
+                               // should have marked any other number token as a 'number'
+                               // anyway so let's not bother checking.
+                               return $t->typeFlag() === 'integer';
+                       } );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for an integer value
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#integers
+        * @return Matcher
+        */
+       public function integer() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = $this->calc( $this->rawInteger(), 'integer' );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a real number, without calc()
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#numbers
+        * @return Matcher
+        */
+       public function rawNumber() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = new TokenMatcher( Token::T_NUMBER );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a real number
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#numbers
+        * @return Matcher
+        */
+       public function number() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = $this->calc( $this->rawNumber(), 'number' );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a percentage value, without calc()
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#percentages
+        * @return Matcher
+        */
+       public function rawPercentage() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = new TokenMatcher( Token::T_PERCENTAGE );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a percentage value
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#percentages
+        * @return Matcher
+        */
+       public function percentage() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = $this->calc( $this->rawPercentage(), 'percentage' );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a length-percentage value
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#typedef-length-percentage
+        * @return Matcher
+        */
+       public function lengthPercentage() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = $this->calc(
+                               new Alternative( [ $this->rawLength(), $this->rawPercentage() ] ),
+                               'length'
+                       );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a frequency-percentage value
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#typedef-frequency-percentage
+        * @return Matcher
+        */
+       public function frequencyPercentage() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = $this->calc(
+                               new Alternative( [ $this->rawFrequency(), $this->rawPercentage() ] ),
+                               'frequency'
+                       );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a angle-percentage value
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#typedef-angle-percentage
+        * @return Matcher
+        */
+       public function anglePercentage() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = $this->calc(
+                               new Alternative( [ $this->rawAngle(), $this->rawPercentage() ] ),
+                               'angle'
+                       );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a time-percentage value
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#typedef-time-percentage
+        * @return Matcher
+        */
+       public function timePercentage() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = $this->calc(
+                               new Alternative( [ $this->rawTime(), $this->rawPercentage() ] ),
+                               'time'
+                       );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a number-percentage value
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#typedef-number-percentage
+        * @return Matcher
+        */
+       public function numberPercentage() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = $this->calc(
+                               new Alternative( [ $this->rawNumber(), $this->rawPercentage() ] ),
+                               'number'
+                       );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a dimension value
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#dimensions
+        * @return Matcher
+        */
+       public function dimension() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = new TokenMatcher( Token::T_DIMENSION );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matches the number 0
+        * @return Matcher
+        */
+       protected function zero() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = new TokenMatcher( Token::T_NUMBER, function ( Token $t ) {
+                               return $t->value() === 0 || $t->value() === 0.0;
+                       } );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a length value, without calc()
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#lengths
+        * @return Matcher
+        */
+       protected function rawLength() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $unitsRe = '/^(' . join( '|', self::$lengthUnits ) . ')$/i';
+
+                       $this->cache[__METHOD__] = new Alternative( [
+                               $this->zero(),
+                               new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) use ( $unitsRe ) {
+                                       return preg_match( $unitsRe, $t->unit() );
+                               } ),
+                       ] );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a length value
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#lengths
+        * @return Matcher
+        */
+       public function length() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = $this->calc( $this->rawLength(), 'length' );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for an angle value, without calc()
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#angles
+        * @return Matcher
+        */
+       protected function rawAngle() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $unitsRe = '/^(' . join( '|', self::$angleUnits ) . ')$/i';
+
+                       $this->cache[__METHOD__] = new Alternative( [
+                               $this->zero(),
+                               new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) use ( $unitsRe ) {
+                                       return preg_match( $unitsRe, $t->unit() );
+                               } ),
+                       ] );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for an angle value
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#angles
+        * @return Matcher
+        */
+       public function angle() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = $this->calc( $this->rawAngle(), 'angle' );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a duration (time) value, without calc()
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#time
+        * @return Matcher
+        */
+       protected function rawTime() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $unitsRe = '/^(' . join( '|', self::$timeUnits ) . ')$/i';
+
+                       $this->cache[__METHOD__] = new TokenMatcher( Token::T_DIMENSION,
+                               function ( Token $t ) use ( $unitsRe ) {
+                                       return preg_match( $unitsRe, $t->unit() );
+                               }
+                       );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a duration (time) value
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#time
+        * @return Matcher
+        */
+       public function time() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = $this->calc( $this->rawTime(), 'time' );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a frequency value, without calc()
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#frequency
+        * @return Matcher
+        */
+       protected function rawFrequency() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $unitsRe = '/^(' . join( '|', self::$frequencyUnits ) . ')$/i';
+
+                       $this->cache[__METHOD__] = new TokenMatcher( Token::T_DIMENSION,
+                               function ( Token $t ) use ( $unitsRe ) {
+                                       return preg_match( $unitsRe, $t->unit() );
+                               }
+                       );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a frequency value
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#frequency
+        * @return Matcher
+        */
+       public function frequency() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = $this->calc( $this->rawFrequency(), 'frequency' );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a resolution value
+        * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#resolution
+        * @return Matcher
+        */
+       public function resolution() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) {
+                               return preg_match( '/^(dpi|dpcm|dppx)$/i', $t->unit() );
+                       } );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matchers for color functions
+        * @return Matcher[]
+        */
+       protected function colorFuncs() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $i = $this->integer();
+                       $n = $this->number();
+                       $p = $this->percentage();
+                       $this->cache[__METHOD__] = [
+                               new FunctionMatcher( 'rgb', new Alternative( [
+                                       Quantifier::hash( $i, 3, 3 ),
+                                       Quantifier::hash( $p, 3, 3 ),
+                               ] ) ),
+                               new FunctionMatcher( 'rgba', new Alternative( [
+                                       new Juxtaposition( [ $i, $i, $i, $n ], true ),
+                                       new Juxtaposition( [ $p, $p, $p, $n ], true ),
+                               ] ) ),
+                               new FunctionMatcher( 'hsl', new Juxtaposition( [ $n, $p, $p ], true ) ),
+                               new FunctionMatcher( 'hsla', new Juxtaposition( [ $n, $p, $p, $n ], true ) ),
+                       ];
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a color value
+        * @see https://www.w3.org/TR/2011/REC-css3-color-20110607/#colorunits
+        * @return Matcher
+        */
+       public function color() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = new Alternative( array_merge( [
+                               new KeywordMatcher( [
+                                       // Basic colors
+                                       'aqua', 'black', 'blue', 'fuchsia', 'gray', 'green',
+                                       'lime', 'maroon', 'navy', 'olive', 'purple', 'red',
+                                       'silver', 'teal', 'white', 'yellow',
+                                       // Extended colors
+                                       'aliceblue', 'antiquewhite', 'aquamarine', 'azure',
+                                       'beige', 'bisque', 'blanchedalmond', 'blueviolet', 'brown',
+                                       'burlywood', 'cadetblue', 'chartreuse', 'chocolate',
+                                       'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan',
+                                       'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray',
+                                       'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta',
+                                       'darkolivegreen', 'darkorange', 'darkorchid', 'darkred',
+                                       'darksalmon', 'darkseagreen', 'darkslateblue',
+                                       'darkslategray', 'darkslategrey', 'darkturquoise',
+                                       'darkviolet', 'deeppink', 'deepskyblue', 'dimgray',
+                                       'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite',
+                                       'forestgreen', 'gainsboro', 'ghostwhite', 'gold',
+                                       'goldenrod', 'greenyellow', 'grey', 'honeydew', 'hotpink',
+                                       'indianred', 'indigo', 'ivory', 'khaki', 'lavender',
+                                       'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue',
+                                       'lightcoral', 'lightcyan', 'lightgoldenrodyellow',
+                                       'lightgray', 'lightgreen', 'lightgrey', 'lightpink',
+                                       'lightsalmon', 'lightseagreen', 'lightskyblue',
+                                       'lightslategray', 'lightslategrey', 'lightsteelblue',
+                                       'lightyellow', 'limegreen', 'linen', 'magenta',
+                                       'mediumaquamarine', 'mediumblue', 'mediumorchid',
+                                       'mediumpurple', 'mediumseagreen', 'mediumslateblue',
+                                       'mediumspringgreen', 'mediumturquoise', 'mediumvioletred',
+                                       'midnightblue', 'mintcream', 'mistyrose', 'moccasin',
+                                       'navajowhite', 'oldlace', 'olivedrab', 'orange',
+                                       'orangered', 'orchid', 'palegoldenrod', 'palegreen',
+                                       'paleturquoise', 'palevioletred', 'papayawhip',
+                                       'peachpuff', 'peru', 'pink', 'plum', 'powderblue',
+                                       'rosybrown', 'royalblue', 'saddlebrown', 'salmon',
+                                       'sandybrown', 'seagreen', 'seashell', 'sienna', 'skyblue',
+                                       'slateblue', 'slategray', 'slategrey', 'snow',
+                                       'springgreen', 'steelblue', 'tan', 'thistle', 'tomato',
+                                       'turquoise', 'violet', 'wheat', 'whitesmoke',
+                                       'yellowgreen',
+                                       // Other keywords. Intentionally omitting the deprecated system colors.
+                                       'transparent', 'currentColor',
+                               ] ),
+                               new TokenMatcher( Token::T_HASH, function ( Token $t ) {
+                                       return preg_match( '/^([0-9a-f]{3}|[0-9a-f]{6})$/i', $t->value() );
+                               } ),
+                       ], $this->colorFuncs() ) );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for an image value
+        * @see https://www.w3.org/TR/2012/CR-css3-images-20120417/#image-values
+        * @return Matcher
+        */
+       public function image() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       // https://www.w3.org/TR/2012/CR-css3-images-20120417/#image-list-type
+                       // Note the undefined <element-reference> production has been dropped from the Editor's Draft.
+                       $imageDecl = new Alternative( [
+                               $this->url( 'image' ),
+                               $this->urlstring( 'image' ),
+                       ] );
+
+                       // https://www.w3.org/TR/2012/CR-css3-images-20120417/#gradients
+                       $c = $this->comma();
+                       $colorStops = Quantifier::hash( new Juxtaposition( [
+                               $this->color(),
+                               // Not really <length-percentage>, but grammatically the same
+                               Quantifier::optional( $this->lengthPercentage() ),
+                       ] ), 2, INF );
+                       $atPosition = new Juxtaposition( [ new KeywordMatcher( 'at' ), $this->position() ] );
+
+                       $linearGradient = new Juxtaposition( [
+                               Quantifier::optional( new Juxtaposition( [
+                                       new Alternative( [
+                                               $this->angle(),
+                                               new Juxtaposition( [ new KeywordMatcher( 'to' ), UnorderedGroup::someOf( [
+                                                       new KeywordMatcher( [ 'left', 'right' ] ),
+                                                       new KeywordMatcher( [ 'top', 'bottom' ] ),
+                                               ] ) ] )
+                                       ] ),
+                                       $c
+                               ] ) ),
+                               $colorStops,
+                       ] );
+                       $radialGradient = new Juxtaposition( [
+                               Quantifier::optional( new Juxtaposition( [
+                                       new Alternative( [
+                                               new Juxtaposition( [
+                                                       new Alternative( [
+                                                               UnorderedGroup::someOf( [ new KeywordMatcher( 'circle' ), $this->length() ] ),
+                                                               UnorderedGroup::someOf( [
+                                                                       new KeywordMatcher( 'ellipse' ),
+                                                                       // Not really <length-percentage>, but grammatically the same
+                                                                       Quantifier::count( $this->lengthPercentage(), 2, 2 )
+                                                               ] ),
+                                                               UnorderedGroup::someOf( [
+                                                                       new KeywordMatcher( [ 'circle', 'ellipse' ] ),
+                                                                       new KeywordMatcher( [
+                                                                               'closest-side', 'farthest-side', 'closest-corner', 'farthest-corner'
+                                                                       ] ),
+                                                               ] ),
+                                                       ] ),
+                                                       Quantifier::optional( $atPosition ),
+                                               ] ),
+                                               $atPosition
+                                       ] ),
+                                       $c
+                               ] ) ),
+                               $colorStops,
+                       ] );
+
+                       // Putting it all together
+                       $this->cache[__METHOD__] = new Alternative( [
+                               $this->url( 'image' ),
+                               new FunctionMatcher( 'image', new Juxtaposition( [
+                                       Quantifier::star( new Juxtaposition( [ $imageDecl, $c ] ) ),
+                                       new Alternative( [ $imageDecl, $this->color() ] ),
+                               ] ) ),
+                               new FunctionMatcher( 'linear-gradient', $linearGradient ),
+                               new FunctionMatcher( 'radial-gradient', $radialGradient ),
+                               new FunctionMatcher( 'repeating-linear-gradient', $linearGradient ),
+                               new FunctionMatcher( 'repeating-radial-gradient', $radialGradient ),
+                       ] );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a position value
+        * @see https://www.w3.org/TR/2014/CR-css3-background-20140909/#ltpositiongt
+        * @return Matcher
+        */
+       public function position() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $lp = $this->lengthPercentage();
+                       $olp = Quantifier::optional( $lp );
+                       $center = new KeywordMatcher( 'center' );
+                       $leftRight = new KeywordMatcher( [ 'left', 'right' ] );
+                       $topBottom = new KeywordMatcher( [ 'top', 'bottom' ] );
+
+                       $this->cache[__METHOD__] = new Alternative( [
+                               new Alternative( [ $center, $leftRight, $topBottom, $lp ] ),
+                               new Juxtaposition( [
+                                       new Alternative( [ $center, $leftRight, $lp ] ),
+                                       new Alternative( [ $center, $topBottom, $lp ] ),
+                               ] ),
+                               UnorderedGroup::allOf( [
+                                       new Alternative( [ $center, new Juxtaposition( [ $leftRight, $olp ] ) ] ),
+                                       new Alternative( [ $center, new Juxtaposition( [ $topBottom, $olp ] ) ] ),
+                               ] ),
+                       ] );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * Matcher for a CSS media query
+        * @see https://www.w3.org/TR/2016/WD-mediaqueries-4-20160706/#mq-syntax
+        * @param bool $strict Only allow defined query types
+        * @return Matcher
+        */
+       public function cssMediaQuery( $strict = true ) {
+               $key = __METHOD__ . ':' . ( $strict ? 'strict' : 'unstrict' );
+               if ( !isset( $this->cache[$key] ) ) {
+                       if ( $strict ) {
+                               $generalEnclosed = new NothingMatcher();
+
+                               $mediaType = new KeywordMatcher( [
+                                       'all', 'print', 'screen', 'speech',
+                                       // deprecated
+                                       'tty', 'tv', 'projection', 'handheld', 'braille', 'embossed', 'aural'
+                               ] );
+
+                               $rangeFeatures = [
+                                       'width', 'height', 'aspect-ratio', 'resolution', 'color', 'color-index', 'monochrome',
+                                       // deprecated
+                                       'device-width', 'device-height', 'device-aspect-ratio'
+                               ];
+                               $discreteFeatures = [
+                                       'orientation', 'scan', 'grid', 'update', 'overflow-block', 'overflow-inline', 'color-gamut',
+                                       'pointer', 'hover', 'any-pointer', 'any-hover', 'scripting'
+                               ];
+                               $mfName = new KeywordMatcher( array_merge(
+                                       $rangeFeatures,
+                                       array_map( function ( $f ) {
+                                               return "min-$f";
+                                       }, $rangeFeatures ),
+                                       array_map( function ( $f ) {
+                                               return "max-$f";
+                                       }, $rangeFeatures ),
+                                       $discreteFeatures
+                               ) );
+                       } else {
+                               $anythingPlus = new AnythingMatcher( [ 'quantifier' => '+' ] );
+                               $generalEnclosed = new Alternative( [
+                                       new FunctionMatcher( null, $anythingPlus ),
+                                       new BlockMatcher( Token::T_LEFT_PAREN,
+                                               new Juxtaposition( [ $this->ident(), $anythingPlus ] )
+                                       ),
+                               ] );
+                               $mediaType = $this->ident();
+                               $mfName = $this->ident();
+                       }
+
+                       $posInt = $this->calc(
+                               new TokenMatcher( Token::T_NUMBER, function ( Token $t ) {
+                                       return $t->typeFlag() === 'integer' && preg_match( '/^\+?\d+$/', $t->representation() );
+                               } ),
+                               'integer'
+                       );
+                       $eq = new DelimMatcher( '=' );
+                       $oeq = Quantifier::optional( new Juxtaposition( [ new NoWhitespace, $eq ] ) );
+                       $ltgteq = Quantifier::optional( new Alternative( [
+                               $eq,
+                               new Juxtaposition( [ new DelimMatcher( [ '<', '>' ] ), $oeq ] ),
+                       ] ) );
+                       $lteq = new Juxtaposition( [ new DelimMatcher( '<' ), $oeq ] );
+                       $gteq = new Juxtaposition( [ new DelimMatcher( '>' ), $oeq ] );
+                       $mfValue = new Alternative( [
+                               $this->number(),
+                               $this->dimension(),
+                               $this->ident(),
+                               new Juxtaposition( [ $posInt, new DelimMatcher( '/' ), $posInt ] ),
+                       ] );
+
+                       $mediaInParens = new NothingMatcher(); // temporary
+                       $mediaNot = new Juxtaposition( [ new KeywordMatcher( 'not' ), &$mediaInParens ] );
+                       $mediaAnd = new Juxtaposition( [
+                               &$mediaInParens,
+                               Quantifier::plus( new Juxtaposition( [ new KeywordMatcher( 'and' ), &$mediaInParens ] ) )
+                       ] );
+                       $mediaOr = new Juxtaposition( [
+                               &$mediaInParens,
+                               Quantifier::plus( new Juxtaposition( [ new KeywordMatcher( 'or' ), &$mediaInParens ] ) )
+                       ] );
+                       $mediaCondition = new Alternative( [ $mediaNot, $mediaAnd, $mediaOr, &$mediaInParens ] );
+                       $mediaConditionWithoutOr = new Alternative( [ $mediaNot, $mediaAnd, &$mediaInParens ] );
+                       $mediaFeature = new BlockMatcher( Token::T_LEFT_PAREN, new Alternative( [
+                               new Juxtaposition( [ $mfName, new TokenMatcher( Token::T_COLON ), $mfValue ] ), // <mf-plain>
+                               $mfName, // <mf-boolean>
+                               new Juxtaposition( [ $mfName, $ltgteq, $mfValue ] ), // <mf-range>, 1st alternative
+                               new Juxtaposition( [ $mfValue, $ltgteq, $mfName ] ), // <mf-range>, 2nd alternative
+                               new Juxtaposition( [ $mfValue, $lteq, $mfName, $lteq, $mfValue ] ), // <mf-range>, 3rd alt
+                               new Juxtaposition( [ $mfValue, $gteq, $mfName, $gteq, $mfValue ] ), // <mf-range>, 4th alt
+                       ] ) );
+                       $mediaInParens = new Alternative( [
+                               new BlockMatcher( Token::T_LEFT_PAREN, $mediaCondition ),
+                               $mediaFeature,
+                               $generalEnclosed,
+                       ] );
+
+                       $this->cache[$key] = new Alternative( [
+                               $mediaCondition,
+                               new Juxtaposition( [
+                                       Quantifier::optional( new KeywordMatcher( [ 'not', 'only' ] ) ),
+                                       $mediaType,
+                                       Quantifier::optional( new Juxtaposition( [
+                                               new KeywordMatcher( 'and' ),
+                                               $mediaConditionWithoutOr,
+                                       ] ) )
+                               ] )
+                       ] );
+               }
+
+               return $this->cache[$key];
+       }
+
+       /**
+        * Matcher for a CSS media query list
+        * @see https://www.w3.org/TR/2016/WD-mediaqueries-4-20160706/#mq-syntax
+        * @param bool $strict Only allow defined query types
+        * @return Matcher
+        */
+       public function cssMediaQueryList( $strict = true ) {
+               $key = __METHOD__ . ':' . ( $strict ? 'strict' : 'unstrict' );
+               if ( !isset( $this->cache[$key] ) ) {
+                       $this->cache[$key] = Quantifier::hash( $this->cssMediaQuery( $strict ), 0, INF );
+               }
+
+               return $this->cache[$key];
+       }
+
+       /************************************************************************//**
+        * @name   CSS Selectors Level 3
+        * @{
+        *
+        * https://www.w3.org/TR/2011/REC-css3-selectors-20110929/#w3cselgrammar
+        */
+
+       /**
+        * List of selectors
+        *
+        *     selector [ COMMA S* selector ]*
+        *
+        * Capturing is set up for the `selector`s.
+        *
+        * @return Matcher
+        */
+       public function cssSelectorList() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       // Technically the spec doesn't allow whitespace before the comma,
+                       // but I'd guess every browser does. So just use Quantifier::hash.
+                       $selector = $this->cssSelector()->capture( 'selector' );
+                       $this->cache[__METHOD__] = Quantifier::hash( $selector );
+                       $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * A single selector
+        *
+        *     simple_selector_sequence [ combinator simple_selector_sequence ]*
+        *
+        * Capturing is set up for the `simple_selector_sequence`s (as 'simple') and `combinator`.
+        *
+        * @return Matcher
+        */
+       public function cssSelector() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $simple = $this->cssSimpleSelectorSeq()->capture( 'simple' );
+                       $this->cache[__METHOD__] = new Juxtaposition( [
+                               $simple,
+                               Quantifier::star( new Juxtaposition( [
+                                       $this->cssCombinator()->capture( 'combinator' ),
+                                       $simple,
+                               ] ) )
+                       ] );
+                       $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * A CSS combinator
+        *
+        *     PLUS S* | GREATER S* | TILDE S* | S+
+        *
+        * (combinators can be surrounded by whitespace)
+        *
+        * @return Matcher
+        */
+       public function cssCombinator() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = new Alternative( [
+                               new Juxtaposition( [
+                                       $this->optionalWhitespace(),
+                                       new DelimMatcher( [ '+', '>', '~' ] ),
+                                       $this->optionalWhitespace(),
+                               ] ),
+                               $this->significantWhitespace(),
+                       ] );
+                       $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * A simple selector sequence
+        *
+        *     [ type_selector | universal ]
+        *     [ HASH | class | attrib | pseudo | negation ]*
+        *     | [ HASH | class | attrib | pseudo | negation ]+
+        *
+        * The following captures are set:
+        *  - element: [ type_selector | universal ]
+        *  - id: HASH
+        *  - class: class
+        *  - attrib: attrib
+        *  - pseudo: pseudo
+        *  - negation: negation
+        *
+        * @return Matcher
+        */
+       public function cssSimpleSelectorSeq() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $hashEtc = new Alternative( [
+                               $this->cssID()->capture( 'id' ),
+                               $this->cssClass()->capture( 'class' ),
+                               $this->cssAttrib()->capture( 'attrib' ),
+                               $this->cssPseudo()->capture( 'pseudo' ),
+                               $this->cssNegation()->capture( 'negation' ),
+                       ] );
+
+                       $this->cache[__METHOD__] = new Alternative( [
+                               new Juxtaposition( [
+                                       Alternative::create( [
+                                               $this->cssTypeSelector(),
+                                               $this->cssUniversal(),
+                                       ] )->capture( 'element' ),
+                                       Quantifier::star( $hashEtc )
+                               ] ),
+                               Quantifier::plus( $hashEtc )
+                       ] );
+                       $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * A type selector (i.e. a tag name)
+        *
+        *     [ namespace_prefix ] ? element_name
+        *
+        * where element_name is
+        *
+        *     IDENT
+        *
+        * @return Matcher
+        */
+       public function cssTypeSelector() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = new Juxtaposition( [
+                               $this->cssOptionalNamespacePrefix(),
+                               new TokenMatcher( Token::T_IDENT )
+                       ] );
+                       $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * A namespace prefix
+        *
+        *      [ IDENT | '*' ]? '|'
+        *
+        * @return Matcher
+        */
+       public function cssNamespacePrefix() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = new Juxtaposition( [
+                               Quantifier::optional( new Alternative( [
+                                       $this->ident(),
+                                       new DelimMatcher( [ '*' ] ),
+                               ] ) ),
+                               new DelimMatcher( [ '|' ] ),
+                       ] );
+                       $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * An optional namespace prefix
+        *
+        *     [ namespace_prefix ]?
+        *
+        * @return Matcher
+        */
+       private function cssOptionalNamespacePrefix() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = Quantifier::optional( $this->cssNamespacePrefix() );
+                       $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * The universal selector
+        *
+        *     [ namespace_prefix ]? '*'
+        *
+        * @return Matcher
+        */
+       public function cssUniversal() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = new Juxtaposition( [
+                               $this->cssOptionalNamespacePrefix(),
+                               new DelimMatcher( [ '*' ] )
+                       ] );
+                       $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * An ID selector
+        *
+        *     HASH
+        *
+        * @return Matcher
+        */
+       public function cssID() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = new TokenMatcher( Token::T_HASH, function ( Token $t ) {
+                               return $t->typeFlag() === 'id';
+                       } );
+                       $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * A class selector
+        *
+        *     '.' IDENT
+        *
+        * @return Matcher
+        */
+       public function cssClass() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $this->cache[__METHOD__] = new Juxtaposition( [
+                               new DelimMatcher( [ '.' ] ),
+                               $this->ident()
+                       ] );
+                       $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * An attribute selector
+        *
+        *     '[' S* [ namespace_prefix ]? IDENT S*
+        *         [ [ PREFIXMATCH |
+        *             SUFFIXMATCH |
+        *             SUBSTRINGMATCH |
+        *             '=' |
+        *             INCLUDES |
+        *             DASHMATCH ] S* [ IDENT | STRING ] S*
+        *         ]? ']'
+        *
+        * Captures are set for the attribute, test, and value. Note that these
+        * captures will probably be relative to the contents of the SimpleBlock
+        * that this matcher matches!
+        *
+        * @return Matcher
+        */
+       public function cssAttrib() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       // An attribute is going to be parsed by the parser as a
+                       // SimpleBlock, so that's what we need to look for here.
+
+                       $this->cache[__METHOD__] = new BlockMatcher( Token::T_LEFT_BRACKET,
+                               new Juxtaposition( [
+                                       $this->optionalWhitespace(),
+                                       Juxtaposition::create( [
+                                               $this->cssOptionalNamespacePrefix(),
+                                               $this->ident(),
+                                       ] )->capture( 'attribute' ),
+                                       $this->optionalWhitespace(),
+                                       Quantifier::optional( new Juxtaposition( [
+                                               Alternative::create( [
+                                                       new TokenMatcher( Token::T_PREFIX_MATCH ),
+                                                       new TokenMatcher( Token::T_SUFFIX_MATCH ),
+                                                       new TokenMatcher( Token::T_SUBSTRING_MATCH ),
+                                                       new DelimMatcher( [ '=' ] ),
+                                                       new TokenMatcher( Token::T_INCLUDE_MATCH ),
+                                                       new TokenMatcher( Token::T_DASH_MATCH ),
+                                               ] )->capture( 'test' ),
+                                               $this->optionalWhitespace(),
+                                               Alternative::create( [
+                                                       $this->ident(),
+                                                       $this->string(),
+                                               ] )->capture( 'value' ),
+                                               $this->optionalWhitespace(),
+                                       ] ) ),
+                               ] )
+                       );
+                       $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * A pseudo-class or pseudo-element
+        *
+        *     ':' ':'? [ IDENT | functional_pseudo ]
+        *
+        * Although this actually only matches the pseudo-selectors defined in the
+        * following sources:
+        * - https://www.w3.org/TR/2011/REC-css3-selectors-20110929/#pseudo-classes
+        * - https://www.w3.org/TR/2016/WD-css-pseudo-4-20160607/
+        *
+        * @return Matcher
+        */
+       public function cssPseudo() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       $colon = new TokenMatcher( Token::T_COLON );
+                       $ows = $this->optionalWhitespace();
+                       $anplusb = new Juxtaposition( [ $ows, $this->cssANplusB(), $ows ] );
+                       $this->cache[__METHOD__] = new Alternative( [
+                               new Juxtaposition( [
+                                       $colon,
+                                       new Alternative( [
+                                               new KeywordMatcher( [
+                                                       'link', 'visited', 'hover', 'active', 'focus', 'target', 'enabled', 'disabled', 'checked',
+                                                       'indeterminate', 'root', 'first-child', 'last-child', 'first-of-type',
+                                                       'last-of-type', 'only-child', 'only-of-type', 'empty',
+                                                       // CSS2-compat elements with class syntax
+                                                       'first-line', 'first-letter', 'before', 'after',
+                                               ] ),
+                                               new FunctionMatcher( 'lang', new Juxtaposition( [ $ows, $this->ident(), $ows ] ) ),
+                                               new FunctionMatcher( 'nth-child', $anplusb ),
+                                               new FunctionMatcher( 'nth-last-child', $anplusb ),
+                                               new FunctionMatcher( 'nth-of-type', $anplusb ),
+                                               new FunctionMatcher( 'nth-last-of-type', $anplusb ),
+                                       ] ),
+                               ] ),
+                               new Juxtaposition( [
+                                       $colon,
+                                       $colon,
+                                       new KeywordMatcher( [
+                                               'first-line', 'first-letter', 'before', 'after', 'selection', 'inactive-selection',
+                                               'spelling-error', 'grammar-error', 'placeholder'
+                                       ] ),
+                               ] ),
+                       ] );
+                       $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * An "AN+B" form
+        *
+        * https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#anb
+        *
+        * @return Matcher
+        */
+       public function cssANplusB() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       // Quoth the spec:
+                       //  > The An+B notation was originally defined using a slightly
+                       //  > different tokenizer than the rest of CSS, resulting in a
+                       //  > somewhat odd definition when expressed in terms of CSS tokens.
+                       // That's a bit of an understatement
+
+                       $plus = new DelimMatcher( [ '+' ] );
+                       $plusQ = Quantifier::optional( new DelimMatcher( [ '+' ] ) );
+                       $n = new KeywordMatcher( [ 'n' ] );
+                       $dashN = new KeywordMatcher( [ '-n' ] );
+                       $nDash = new KeywordMatcher( [ 'n-' ] );
+                       $plusQN = new Juxtaposition( [ $plusQ, $n ] );
+                       $plusQNDash = new Juxtaposition( [ $plusQ, $nDash ] );
+                       $nDimension = new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) {
+                               return $t->typeFlag() === 'integer' && !strcasecmp( $t->unit(), 'n' );
+                       } );
+                       $nDashDimension = new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) {
+                               return $t->typeFlag() === 'integer' && !strcasecmp( $t->unit(), 'n-' );
+                       } );
+                       $nDashDigitDimension = new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) {
+                               return $t->typeFlag() === 'integer' && preg_match( '/^n-\d+$/i', $t->unit() );
+                       } );
+                       $nDashDigitIdent = new TokenMatcher( Token::T_IDENT, function ( Token $t ) {
+                               return preg_match( '/^n-\d+$/i', $t->value() );
+                       } );
+                       $dashNDashDigitIdent = new TokenMatcher( Token::T_IDENT, function ( Token $t ) {
+                               return preg_match( '/^-n-\d+$/i', $t->value() );
+                       } );
+                       $signedInt = new TokenMatcher( Token::T_NUMBER, function ( Token $t ) {
+                               return $t->typeFlag() === 'integer' && preg_match( '/^[+-]/', $t->representation() );
+                       } );
+                       $signlessInt = new TokenMatcher( Token::T_NUMBER, function ( Token $t ) {
+                               return $t->typeFlag() === 'integer' && preg_match( '/^\d/', $t->representation() );
+                       } );
+                       $plusOrMinus = new DelimMatcher( [ '+', '-' ] );
+                       $S = $this->optionalWhitespace();
+
+                       $this->cache[__METHOD__] = new Alternative( [
+                               new KeywordMatcher( [ 'odd', 'even' ] ),
+                               new TokenMatcher( Token::T_NUMBER, function ( Token $t ) {
+                                       return $t->typeFlag() === 'integer';
+                               } ),
+                               $nDimension,
+                               $plusQN,
+                               $dashN,
+                               $nDashDigitDimension,
+                               new Juxtaposition( [ $plusQ, $nDashDigitIdent ] ),
+                               $dashNDashDigitIdent,
+                               new Juxtaposition( [ $nDimension, $S, $signedInt ] ),
+                               new Juxtaposition( [ $plusQN, $S, $signedInt ] ),
+                               new Juxtaposition( [ $dashN, $S, $signedInt ] ),
+                               new Juxtaposition( [ $nDashDimension, $S, $signlessInt ] ),
+                               new Juxtaposition( [ $plusQNDash, $S, $signlessInt ] ),
+                               new Juxtaposition( [ new KeywordMatcher( [ '-n-' ] ), $S, $signlessInt ] ),
+                               new Juxtaposition( [ $nDimension, $S, $plusOrMinus, $S, $signlessInt ] ),
+                               new Juxtaposition( [ $plusQN, $S, $plusOrMinus, $S, $signlessInt ] ),
+                               new Juxtaposition( [ $dashN, $S, $plusOrMinus, $S, $signlessInt ] )
+                       ] );
+                       $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**
+        * A negation
+        *
+        *     ':' not( S* [ type_selector | universal | HASH | class | attrib | pseudo ] S* ')'
+        *
+        * @return Matcher
+        */
+       public function cssNegation() {
+               if ( !isset( $this->cache[__METHOD__] ) ) {
+                       // A negation is going to be parsed by the parser as a colon
+                       // followed by a CSSFunction, so that's what we need to look for
+                       // here.
+
+                       $this->cache[__METHOD__] = new Juxtaposition( [
+                               new TokenMatcher( Token::T_COLON ),
+                               new FunctionMatcher( 'not',
+                                       new Juxtaposition( [
+                                               $this->optionalWhitespace(),
+                                               new Alternative( [
+                                                       $this->cssTypeSelector(),
+                                                       $this->cssUniversal(),
+                                                       $this->cssID(),
+                                                       $this->cssClass(),
+                                                       $this->cssAttrib(),
+                                                       $this->cssPseudo(),
+                                               ] ),
+                                               $this->optionalWhitespace(),
+                                       ] )
+                               )
+                       ] );
+                       $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
+               }
+               return $this->cache[__METHOD__];
+       }
+
+       /**@}*/
+
+}
+
+/**
+ * For really cool vim folding this needs to be at the end:
+ * vim: foldmarker=@{,@} foldmethod=marker
+ */