4 * @license https://opensource.org/licenses/Apache-2.0 Apache-2.0
7 namespace Wikimedia\CSS\Grammar;
9 use Wikimedia\CSS\Objects\Token;
12 * Factory for predefined Grammar matchers
13 * @note For security, the attr() and var() functions are not supported.
15 class MatcherFactory {
16 /** @var MatcherFactory|null */
17 private static $instance = null;
19 /** @var Matcher[] Cache of constructed matchers */
20 protected $cache = [];
22 /** @var string[] length units */
23 protected static $lengthUnits = [ 'em', 'ex', 'ch', 'rem', 'vw', 'vh',
24 'vmin', 'vmax', 'cm', 'mm', 'Q', 'in', 'pc', 'pt', 'px' ];
26 /** @var string[] angle units */
27 protected static $angleUnits = [ 'deg', 'grad', 'rad', 'turn' ];
29 /** @var string[] time units */
30 protected static $timeUnits = [ 's', 'ms' ];
32 /** @var string[] frequency units */
33 protected static $frequencyUnits = [ 'Hz', 'kHz' ];
36 * Return a static instance of the factory
37 * @return MatcherFactory
39 public static function singleton() {
40 if ( !self::$instance ) {
41 self::$instance = new self();
43 return self::$instance;
47 * Matcher for optional whitespace
50 public function optionalWhitespace() {
51 if ( !isset( $this->cache[__METHOD__] ) ) {
52 $this->cache[__METHOD__] = new WhitespaceMatcher( [ 'significant' => false ] );
54 return $this->cache[__METHOD__];
58 * Matcher for required whitespace
61 public function significantWhitespace() {
62 if ( !isset( $this->cache[__METHOD__] ) ) {
63 $this->cache[__METHOD__] = new WhitespaceMatcher( [ 'significant' => true ] );
65 return $this->cache[__METHOD__];
72 public function comma() {
73 if ( !isset( $this->cache[__METHOD__] ) ) {
74 $this->cache[__METHOD__] = new TokenMatcher( Token::T_COMMA );
76 return $this->cache[__METHOD__];
80 * Matcher for an arbitrary identifier
83 public function ident() {
84 if ( !isset( $this->cache[__METHOD__] ) ) {
85 $this->cache[__METHOD__] = new TokenMatcher( Token::T_IDENT );
87 return $this->cache[__METHOD__];
91 * Matcher for a string
92 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#strings
93 * @warning If the string will be used as a URL, use self::urlstring() instead.
96 public function string() {
97 if ( !isset( $this->cache[__METHOD__] ) ) {
98 $this->cache[__METHOD__] = new TokenMatcher( Token::T_STRING );
100 return $this->cache[__METHOD__];
104 * Matcher for a string containing a URL
105 * @param string $type Type of resource referenced, e.g. "image" or "audio".
106 * Not used here, but might be used by a subclass to validate the URL more strictly.
109 public function urlstring( $type ) {
110 return $this->string();
115 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#urls
116 * @param string $type Type of resource referenced, e.g. "image" or "audio".
117 * Not used here, but might be used by a subclass to validate the URL more strictly.
120 public function url( $type ) {
121 if ( !isset( $this->cache[__METHOD__] ) ) {
122 $this->cache[__METHOD__] = new UrlMatcher();
124 return $this->cache[__METHOD__];
128 * CSS-wide value keywords
129 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#common-keywords
132 public function cssWideKeywords() {
133 if ( !isset( $this->cache[__METHOD__] ) ) {
134 $this->cache[__METHOD__] = new KeywordMatcher( [ 'initial', 'inherit', 'unset' ] );
136 return $this->cache[__METHOD__];
140 * Add calc() support to a basic type matcher
141 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#calc-notation
142 * @param Matcher $typeMatcher Matcher for the type
143 * @param string $type Type being matched
146 public function calc( Matcher $typeMatcher, $type ) {
147 if ( $type === 'integer' ) {
148 $num = $this->rawInteger();
150 $num = $this->rawNumber();
153 $ows = $this->optionalWhitespace();
154 $ws = $this->significantWhitespace();
156 // Definitions are recursive. This will be used by reference and later
158 $calcValue = new NothingMatcher();
160 if ( $type === 'integer' ) {
161 // Division will always resolve to a number, making the expression
162 // invalid, so don't allow it.
163 $calcProduct = new Juxtaposition( [
165 Quantifier::star( new Juxtaposition( [ $ows, new DelimMatcher( '*' ), $ows, &$calcValue ] ) )
168 $calcProduct = new Juxtaposition( [
170 Quantifier::star( new Alternative( [
171 new Juxtaposition( [ $ows, new DelimMatcher( '*' ), $ows, &$calcValue ] ),
172 new Juxtaposition( [ $ows, new DelimMatcher( '/' ), $ows, $this->rawNumber() ] ),
177 $calcSum = new Juxtaposition( [
180 Quantifier::star( new Juxtaposition( [
181 $ws, new DelimMatcher( [ '+', '-' ] ), $ws, $calcProduct
186 $calcFunc = new FunctionMatcher( 'calc', $calcSum );
188 if ( $num === $typeMatcher ) {
189 $calcValue = new Alternative( [
191 new BlockMatcher( Token::T_LEFT_PAREN, $calcSum ),
195 $calcValue = new Alternative( [
198 new BlockMatcher( Token::T_LEFT_PAREN, $calcSum ),
203 return new Alternative( [ $typeMatcher, $calcFunc ] );
207 * Matcher for an integer value, without calc()
208 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#integers
211 protected function rawInteger() {
212 if ( !isset( $this->cache[__METHOD__] ) ) {
213 $this->cache[__METHOD__] = new TokenMatcher( Token::T_NUMBER, function ( Token $t ) {
214 // The spec says it must match /^[+-]\d+$/, but the tokenizer
215 // should have marked any other number token as a 'number'
216 // anyway so let's not bother checking.
217 return $t->typeFlag() === 'integer';
220 return $this->cache[__METHOD__];
224 * Matcher for an integer value
225 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#integers
228 public function integer() {
229 if ( !isset( $this->cache[__METHOD__] ) ) {
230 $this->cache[__METHOD__] = $this->calc( $this->rawInteger(), 'integer' );
232 return $this->cache[__METHOD__];
236 * Matcher for a real number, without calc()
237 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#numbers
240 public function rawNumber() {
241 if ( !isset( $this->cache[__METHOD__] ) ) {
242 $this->cache[__METHOD__] = new TokenMatcher( Token::T_NUMBER );
244 return $this->cache[__METHOD__];
248 * Matcher for a real number
249 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#numbers
252 public function number() {
253 if ( !isset( $this->cache[__METHOD__] ) ) {
254 $this->cache[__METHOD__] = $this->calc( $this->rawNumber(), 'number' );
256 return $this->cache[__METHOD__];
260 * Matcher for a percentage value, without calc()
261 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#percentages
264 public function rawPercentage() {
265 if ( !isset( $this->cache[__METHOD__] ) ) {
266 $this->cache[__METHOD__] = new TokenMatcher( Token::T_PERCENTAGE );
268 return $this->cache[__METHOD__];
272 * Matcher for a percentage value
273 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#percentages
276 public function percentage() {
277 if ( !isset( $this->cache[__METHOD__] ) ) {
278 $this->cache[__METHOD__] = $this->calc( $this->rawPercentage(), 'percentage' );
280 return $this->cache[__METHOD__];
284 * Matcher for a length-percentage value
285 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#typedef-length-percentage
288 public function lengthPercentage() {
289 if ( !isset( $this->cache[__METHOD__] ) ) {
290 $this->cache[__METHOD__] = $this->calc(
291 new Alternative( [ $this->rawLength(), $this->rawPercentage() ] ),
295 return $this->cache[__METHOD__];
299 * Matcher for a frequency-percentage value
300 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#typedef-frequency-percentage
303 public function frequencyPercentage() {
304 if ( !isset( $this->cache[__METHOD__] ) ) {
305 $this->cache[__METHOD__] = $this->calc(
306 new Alternative( [ $this->rawFrequency(), $this->rawPercentage() ] ),
310 return $this->cache[__METHOD__];
314 * Matcher for a angle-percentage value
315 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#typedef-angle-percentage
318 public function anglePercentage() {
319 if ( !isset( $this->cache[__METHOD__] ) ) {
320 $this->cache[__METHOD__] = $this->calc(
321 new Alternative( [ $this->rawAngle(), $this->rawPercentage() ] ),
325 return $this->cache[__METHOD__];
329 * Matcher for a time-percentage value
330 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#typedef-time-percentage
333 public function timePercentage() {
334 if ( !isset( $this->cache[__METHOD__] ) ) {
335 $this->cache[__METHOD__] = $this->calc(
336 new Alternative( [ $this->rawTime(), $this->rawPercentage() ] ),
340 return $this->cache[__METHOD__];
344 * Matcher for a number-percentage value
345 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#typedef-number-percentage
348 public function numberPercentage() {
349 if ( !isset( $this->cache[__METHOD__] ) ) {
350 $this->cache[__METHOD__] = $this->calc(
351 new Alternative( [ $this->rawNumber(), $this->rawPercentage() ] ),
355 return $this->cache[__METHOD__];
359 * Matcher for a dimension value
360 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#dimensions
363 public function dimension() {
364 if ( !isset( $this->cache[__METHOD__] ) ) {
365 $this->cache[__METHOD__] = new TokenMatcher( Token::T_DIMENSION );
367 return $this->cache[__METHOD__];
371 * Matches the number 0
374 protected function zero() {
375 if ( !isset( $this->cache[__METHOD__] ) ) {
376 $this->cache[__METHOD__] = new TokenMatcher( Token::T_NUMBER, function ( Token $t ) {
377 return $t->value() === 0 || $t->value() === 0.0;
380 return $this->cache[__METHOD__];
384 * Matcher for a length value, without calc()
385 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#lengths
388 protected function rawLength() {
389 if ( !isset( $this->cache[__METHOD__] ) ) {
390 $unitsRe = '/^(' . join( '|', self::$lengthUnits ) . ')$/i';
392 $this->cache[__METHOD__] = new Alternative( [
394 new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) use ( $unitsRe ) {
395 return preg_match( $unitsRe, $t->unit() );
399 return $this->cache[__METHOD__];
403 * Matcher for a length value
404 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#lengths
407 public function length() {
408 if ( !isset( $this->cache[__METHOD__] ) ) {
409 $this->cache[__METHOD__] = $this->calc( $this->rawLength(), 'length' );
411 return $this->cache[__METHOD__];
415 * Matcher for an angle value, without calc()
416 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#angles
419 protected function rawAngle() {
420 if ( !isset( $this->cache[__METHOD__] ) ) {
421 $unitsRe = '/^(' . join( '|', self::$angleUnits ) . ')$/i';
423 $this->cache[__METHOD__] = new Alternative( [
425 new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) use ( $unitsRe ) {
426 return preg_match( $unitsRe, $t->unit() );
430 return $this->cache[__METHOD__];
434 * Matcher for an angle value
435 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#angles
438 public function angle() {
439 if ( !isset( $this->cache[__METHOD__] ) ) {
440 $this->cache[__METHOD__] = $this->calc( $this->rawAngle(), 'angle' );
442 return $this->cache[__METHOD__];
446 * Matcher for a duration (time) value, without calc()
447 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#time
450 protected function rawTime() {
451 if ( !isset( $this->cache[__METHOD__] ) ) {
452 $unitsRe = '/^(' . join( '|', self::$timeUnits ) . ')$/i';
454 $this->cache[__METHOD__] = new TokenMatcher( Token::T_DIMENSION,
455 function ( Token $t ) use ( $unitsRe ) {
456 return preg_match( $unitsRe, $t->unit() );
460 return $this->cache[__METHOD__];
464 * Matcher for a duration (time) value
465 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#time
468 public function time() {
469 if ( !isset( $this->cache[__METHOD__] ) ) {
470 $this->cache[__METHOD__] = $this->calc( $this->rawTime(), 'time' );
472 return $this->cache[__METHOD__];
476 * Matcher for a frequency value, without calc()
477 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#frequency
480 protected function rawFrequency() {
481 if ( !isset( $this->cache[__METHOD__] ) ) {
482 $unitsRe = '/^(' . join( '|', self::$frequencyUnits ) . ')$/i';
484 $this->cache[__METHOD__] = new TokenMatcher( Token::T_DIMENSION,
485 function ( Token $t ) use ( $unitsRe ) {
486 return preg_match( $unitsRe, $t->unit() );
490 return $this->cache[__METHOD__];
494 * Matcher for a frequency value
495 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#frequency
498 public function frequency() {
499 if ( !isset( $this->cache[__METHOD__] ) ) {
500 $this->cache[__METHOD__] = $this->calc( $this->rawFrequency(), 'frequency' );
502 return $this->cache[__METHOD__];
506 * Matcher for a resolution value
507 * @see https://www.w3.org/TR/2016/CR-css-values-3-20160929/#resolution
510 public function resolution() {
511 if ( !isset( $this->cache[__METHOD__] ) ) {
512 $this->cache[__METHOD__] = new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) {
513 return preg_match( '/^(dpi|dpcm|dppx)$/i', $t->unit() );
516 return $this->cache[__METHOD__];
520 * Matchers for color functions
523 protected function colorFuncs() {
524 if ( !isset( $this->cache[__METHOD__] ) ) {
525 $i = $this->integer();
526 $n = $this->number();
527 $p = $this->percentage();
528 $this->cache[__METHOD__] = [
529 new FunctionMatcher( 'rgb', new Alternative( [
530 Quantifier::hash( $i, 3, 3 ),
531 Quantifier::hash( $p, 3, 3 ),
533 new FunctionMatcher( 'rgba', new Alternative( [
534 new Juxtaposition( [ $i, $i, $i, $n ], true ),
535 new Juxtaposition( [ $p, $p, $p, $n ], true ),
537 new FunctionMatcher( 'hsl', new Juxtaposition( [ $n, $p, $p ], true ) ),
538 new FunctionMatcher( 'hsla', new Juxtaposition( [ $n, $p, $p, $n ], true ) ),
541 return $this->cache[__METHOD__];
545 * Matcher for a color value
546 * @see https://www.w3.org/TR/2011/REC-css3-color-20110607/#colorunits
549 public function color() {
550 if ( !isset( $this->cache[__METHOD__] ) ) {
551 $this->cache[__METHOD__] = new Alternative( array_merge( [
552 new KeywordMatcher( [
554 'aqua', 'black', 'blue', 'fuchsia', 'gray', 'green',
555 'lime', 'maroon', 'navy', 'olive', 'purple', 'red',
556 'silver', 'teal', 'white', 'yellow',
558 'aliceblue', 'antiquewhite', 'aquamarine', 'azure',
559 'beige', 'bisque', 'blanchedalmond', 'blueviolet', 'brown',
560 'burlywood', 'cadetblue', 'chartreuse', 'chocolate',
561 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan',
562 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray',
563 'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta',
564 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred',
565 'darksalmon', 'darkseagreen', 'darkslateblue',
566 'darkslategray', 'darkslategrey', 'darkturquoise',
567 'darkviolet', 'deeppink', 'deepskyblue', 'dimgray',
568 'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite',
569 'forestgreen', 'gainsboro', 'ghostwhite', 'gold',
570 'goldenrod', 'greenyellow', 'grey', 'honeydew', 'hotpink',
571 'indianred', 'indigo', 'ivory', 'khaki', 'lavender',
572 'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue',
573 'lightcoral', 'lightcyan', 'lightgoldenrodyellow',
574 'lightgray', 'lightgreen', 'lightgrey', 'lightpink',
575 'lightsalmon', 'lightseagreen', 'lightskyblue',
576 'lightslategray', 'lightslategrey', 'lightsteelblue',
577 'lightyellow', 'limegreen', 'linen', 'magenta',
578 'mediumaquamarine', 'mediumblue', 'mediumorchid',
579 'mediumpurple', 'mediumseagreen', 'mediumslateblue',
580 'mediumspringgreen', 'mediumturquoise', 'mediumvioletred',
581 'midnightblue', 'mintcream', 'mistyrose', 'moccasin',
582 'navajowhite', 'oldlace', 'olivedrab', 'orange',
583 'orangered', 'orchid', 'palegoldenrod', 'palegreen',
584 'paleturquoise', 'palevioletred', 'papayawhip',
585 'peachpuff', 'peru', 'pink', 'plum', 'powderblue',
586 'rosybrown', 'royalblue', 'saddlebrown', 'salmon',
587 'sandybrown', 'seagreen', 'seashell', 'sienna', 'skyblue',
588 'slateblue', 'slategray', 'slategrey', 'snow',
589 'springgreen', 'steelblue', 'tan', 'thistle', 'tomato',
590 'turquoise', 'violet', 'wheat', 'whitesmoke',
592 // Other keywords. Intentionally omitting the deprecated system colors.
593 'transparent', 'currentColor',
595 new TokenMatcher( Token::T_HASH, function ( Token $t ) {
596 return preg_match( '/^([0-9a-f]{3}|[0-9a-f]{6})$/i', $t->value() );
598 ], $this->colorFuncs() ) );
600 return $this->cache[__METHOD__];
604 * Matcher for an image value
605 * @see https://www.w3.org/TR/2012/CR-css3-images-20120417/#image-values
608 public function image() {
609 if ( !isset( $this->cache[__METHOD__] ) ) {
610 // https://www.w3.org/TR/2012/CR-css3-images-20120417/#image-list-type
611 // Note the undefined <element-reference> production has been dropped from the Editor's Draft.
612 $imageDecl = new Alternative( [
613 $this->url( 'image' ),
614 $this->urlstring( 'image' ),
617 // https://www.w3.org/TR/2012/CR-css3-images-20120417/#gradients
619 $colorStops = Quantifier::hash( new Juxtaposition( [
621 // Not really <length-percentage>, but grammatically the same
622 Quantifier::optional( $this->lengthPercentage() ),
624 $atPosition = new Juxtaposition( [ new KeywordMatcher( 'at' ), $this->position() ] );
626 $linearGradient = new Juxtaposition( [
627 Quantifier::optional( new Juxtaposition( [
630 new Juxtaposition( [ new KeywordMatcher( 'to' ), UnorderedGroup::someOf( [
631 new KeywordMatcher( [ 'left', 'right' ] ),
632 new KeywordMatcher( [ 'top', 'bottom' ] ),
639 $radialGradient = new Juxtaposition( [
640 Quantifier::optional( new Juxtaposition( [
644 UnorderedGroup::someOf( [ new KeywordMatcher( 'circle' ), $this->length() ] ),
645 UnorderedGroup::someOf( [
646 new KeywordMatcher( 'ellipse' ),
647 // Not really <length-percentage>, but grammatically the same
648 Quantifier::count( $this->lengthPercentage(), 2, 2 )
650 UnorderedGroup::someOf( [
651 new KeywordMatcher( [ 'circle', 'ellipse' ] ),
652 new KeywordMatcher( [
653 'closest-side', 'farthest-side', 'closest-corner', 'farthest-corner'
657 Quantifier::optional( $atPosition ),
666 // Putting it all together
667 $this->cache[__METHOD__] = new Alternative( [
668 $this->url( 'image' ),
669 new FunctionMatcher( 'image', new Juxtaposition( [
670 Quantifier::star( new Juxtaposition( [ $imageDecl, $c ] ) ),
671 new Alternative( [ $imageDecl, $this->color() ] ),
673 new FunctionMatcher( 'linear-gradient', $linearGradient ),
674 new FunctionMatcher( 'radial-gradient', $radialGradient ),
675 new FunctionMatcher( 'repeating-linear-gradient', $linearGradient ),
676 new FunctionMatcher( 'repeating-radial-gradient', $radialGradient ),
679 return $this->cache[__METHOD__];
683 * Matcher for a position value
684 * @see https://www.w3.org/TR/2014/CR-css3-background-20140909/#ltpositiongt
687 public function position() {
688 if ( !isset( $this->cache[__METHOD__] ) ) {
689 $lp = $this->lengthPercentage();
690 $olp = Quantifier::optional( $lp );
691 $center = new KeywordMatcher( 'center' );
692 $leftRight = new KeywordMatcher( [ 'left', 'right' ] );
693 $topBottom = new KeywordMatcher( [ 'top', 'bottom' ] );
695 $this->cache[__METHOD__] = new Alternative( [
696 new Alternative( [ $center, $leftRight, $topBottom, $lp ] ),
698 new Alternative( [ $center, $leftRight, $lp ] ),
699 new Alternative( [ $center, $topBottom, $lp ] ),
701 UnorderedGroup::allOf( [
702 new Alternative( [ $center, new Juxtaposition( [ $leftRight, $olp ] ) ] ),
703 new Alternative( [ $center, new Juxtaposition( [ $topBottom, $olp ] ) ] ),
707 return $this->cache[__METHOD__];
711 * Matcher for a CSS media query
712 * @see https://www.w3.org/TR/2016/WD-mediaqueries-4-20160706/#mq-syntax
713 * @param bool $strict Only allow defined query types
716 public function cssMediaQuery( $strict = true ) {
717 $key = __METHOD__ . ':' . ( $strict ? 'strict' : 'unstrict' );
718 if ( !isset( $this->cache[$key] ) ) {
720 $generalEnclosed = new NothingMatcher();
722 $mediaType = new KeywordMatcher( [
723 'all', 'print', 'screen', 'speech',
725 'tty', 'tv', 'projection', 'handheld', 'braille', 'embossed', 'aural'
729 'width', 'height', 'aspect-ratio', 'resolution', 'color', 'color-index', 'monochrome',
731 'device-width', 'device-height', 'device-aspect-ratio'
733 $discreteFeatures = [
734 'orientation', 'scan', 'grid', 'update', 'overflow-block', 'overflow-inline', 'color-gamut',
735 'pointer', 'hover', 'any-pointer', 'any-hover', 'scripting'
737 $mfName = new KeywordMatcher( array_merge(
739 array_map( function ( $f ) {
742 array_map( function ( $f ) {
748 $anythingPlus = new AnythingMatcher( [ 'quantifier' => '+' ] );
749 $generalEnclosed = new Alternative( [
750 new FunctionMatcher( null, $anythingPlus ),
751 new BlockMatcher( Token::T_LEFT_PAREN,
752 new Juxtaposition( [ $this->ident(), $anythingPlus ] )
755 $mediaType = $this->ident();
756 $mfName = $this->ident();
759 $posInt = $this->calc(
760 new TokenMatcher( Token::T_NUMBER, function ( Token $t ) {
761 return $t->typeFlag() === 'integer' && preg_match( '/^\+?\d+$/', $t->representation() );
765 $eq = new DelimMatcher( '=' );
766 $oeq = Quantifier::optional( new Juxtaposition( [ new NoWhitespace, $eq ] ) );
767 $ltgteq = Quantifier::optional( new Alternative( [
769 new Juxtaposition( [ new DelimMatcher( [ '<', '>' ] ), $oeq ] ),
771 $lteq = new Juxtaposition( [ new DelimMatcher( '<' ), $oeq ] );
772 $gteq = new Juxtaposition( [ new DelimMatcher( '>' ), $oeq ] );
773 $mfValue = new Alternative( [
777 new Juxtaposition( [ $posInt, new DelimMatcher( '/' ), $posInt ] ),
780 $mediaInParens = new NothingMatcher(); // temporary
781 $mediaNot = new Juxtaposition( [ new KeywordMatcher( 'not' ), &$mediaInParens ] );
782 $mediaAnd = new Juxtaposition( [
784 Quantifier::plus( new Juxtaposition( [ new KeywordMatcher( 'and' ), &$mediaInParens ] ) )
786 $mediaOr = new Juxtaposition( [
788 Quantifier::plus( new Juxtaposition( [ new KeywordMatcher( 'or' ), &$mediaInParens ] ) )
790 $mediaCondition = new Alternative( [ $mediaNot, $mediaAnd, $mediaOr, &$mediaInParens ] );
791 $mediaConditionWithoutOr = new Alternative( [ $mediaNot, $mediaAnd, &$mediaInParens ] );
792 $mediaFeature = new BlockMatcher( Token::T_LEFT_PAREN, new Alternative( [
793 new Juxtaposition( [ $mfName, new TokenMatcher( Token::T_COLON ), $mfValue ] ), // <mf-plain>
794 $mfName, // <mf-boolean>
795 new Juxtaposition( [ $mfName, $ltgteq, $mfValue ] ), // <mf-range>, 1st alternative
796 new Juxtaposition( [ $mfValue, $ltgteq, $mfName ] ), // <mf-range>, 2nd alternative
797 new Juxtaposition( [ $mfValue, $lteq, $mfName, $lteq, $mfValue ] ), // <mf-range>, 3rd alt
798 new Juxtaposition( [ $mfValue, $gteq, $mfName, $gteq, $mfValue ] ), // <mf-range>, 4th alt
800 $mediaInParens = new Alternative( [
801 new BlockMatcher( Token::T_LEFT_PAREN, $mediaCondition ),
806 $this->cache[$key] = new Alternative( [
809 Quantifier::optional( new KeywordMatcher( [ 'not', 'only' ] ) ),
811 Quantifier::optional( new Juxtaposition( [
812 new KeywordMatcher( 'and' ),
813 $mediaConditionWithoutOr,
819 return $this->cache[$key];
823 * Matcher for a CSS media query list
824 * @see https://www.w3.org/TR/2016/WD-mediaqueries-4-20160706/#mq-syntax
825 * @param bool $strict Only allow defined query types
828 public function cssMediaQueryList( $strict = true ) {
829 $key = __METHOD__ . ':' . ( $strict ? 'strict' : 'unstrict' );
830 if ( !isset( $this->cache[$key] ) ) {
831 $this->cache[$key] = Quantifier::hash( $this->cssMediaQuery( $strict ), 0, INF );
834 return $this->cache[$key];
837 /************************************************************************//**
838 * @name CSS Selectors Level 3
841 * https://www.w3.org/TR/2011/REC-css3-selectors-20110929/#w3cselgrammar
847 * selector [ COMMA S* selector ]*
849 * Capturing is set up for the `selector`s.
853 public function cssSelectorList() {
854 if ( !isset( $this->cache[__METHOD__] ) ) {
855 // Technically the spec doesn't allow whitespace before the comma,
856 // but I'd guess every browser does. So just use Quantifier::hash.
857 $selector = $this->cssSelector()->capture( 'selector' );
858 $this->cache[__METHOD__] = Quantifier::hash( $selector );
859 $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
861 return $this->cache[__METHOD__];
867 * simple_selector_sequence [ combinator simple_selector_sequence ]*
869 * Capturing is set up for the `simple_selector_sequence`s (as 'simple') and `combinator`.
873 public function cssSelector() {
874 if ( !isset( $this->cache[__METHOD__] ) ) {
875 $simple = $this->cssSimpleSelectorSeq()->capture( 'simple' );
876 $this->cache[__METHOD__] = new Juxtaposition( [
878 Quantifier::star( new Juxtaposition( [
879 $this->cssCombinator()->capture( 'combinator' ),
883 $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
885 return $this->cache[__METHOD__];
891 * PLUS S* | GREATER S* | TILDE S* | S+
893 * (combinators can be surrounded by whitespace)
897 public function cssCombinator() {
898 if ( !isset( $this->cache[__METHOD__] ) ) {
899 $this->cache[__METHOD__] = new Alternative( [
901 $this->optionalWhitespace(),
902 new DelimMatcher( [ '+', '>', '~' ] ),
903 $this->optionalWhitespace(),
905 $this->significantWhitespace(),
907 $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
909 return $this->cache[__METHOD__];
913 * A simple selector sequence
915 * [ type_selector | universal ]
916 * [ HASH | class | attrib | pseudo | negation ]*
917 * | [ HASH | class | attrib | pseudo | negation ]+
919 * The following captures are set:
920 * - element: [ type_selector | universal ]
925 * - negation: negation
929 public function cssSimpleSelectorSeq() {
930 if ( !isset( $this->cache[__METHOD__] ) ) {
931 $hashEtc = new Alternative( [
932 $this->cssID()->capture( 'id' ),
933 $this->cssClass()->capture( 'class' ),
934 $this->cssAttrib()->capture( 'attrib' ),
935 $this->cssPseudo()->capture( 'pseudo' ),
936 $this->cssNegation()->capture( 'negation' ),
939 $this->cache[__METHOD__] = new Alternative( [
941 Alternative::create( [
942 $this->cssTypeSelector(),
943 $this->cssUniversal(),
944 ] )->capture( 'element' ),
945 Quantifier::star( $hashEtc )
947 Quantifier::plus( $hashEtc )
949 $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
951 return $this->cache[__METHOD__];
955 * A type selector (i.e. a tag name)
957 * [ namespace_prefix ] ? element_name
959 * where element_name is
965 public function cssTypeSelector() {
966 if ( !isset( $this->cache[__METHOD__] ) ) {
967 $this->cache[__METHOD__] = new Juxtaposition( [
968 $this->cssOptionalNamespacePrefix(),
969 new TokenMatcher( Token::T_IDENT )
971 $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
973 return $this->cache[__METHOD__];
979 * [ IDENT | '*' ]? '|'
983 public function cssNamespacePrefix() {
984 if ( !isset( $this->cache[__METHOD__] ) ) {
985 $this->cache[__METHOD__] = new Juxtaposition( [
986 Quantifier::optional( new Alternative( [
988 new DelimMatcher( [ '*' ] ),
990 new DelimMatcher( [ '|' ] ),
992 $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
994 return $this->cache[__METHOD__];
998 * An optional namespace prefix
1000 * [ namespace_prefix ]?
1004 private function cssOptionalNamespacePrefix() {
1005 if ( !isset( $this->cache[__METHOD__] ) ) {
1006 $this->cache[__METHOD__] = Quantifier::optional( $this->cssNamespacePrefix() );
1007 $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1009 return $this->cache[__METHOD__];
1013 * The universal selector
1015 * [ namespace_prefix ]? '*'
1019 public function cssUniversal() {
1020 if ( !isset( $this->cache[__METHOD__] ) ) {
1021 $this->cache[__METHOD__] = new Juxtaposition( [
1022 $this->cssOptionalNamespacePrefix(),
1023 new DelimMatcher( [ '*' ] )
1025 $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1027 return $this->cache[__METHOD__];
1037 public function cssID() {
1038 if ( !isset( $this->cache[__METHOD__] ) ) {
1039 $this->cache[__METHOD__] = new TokenMatcher( Token::T_HASH, function ( Token $t ) {
1040 return $t->typeFlag() === 'id';
1042 $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1044 return $this->cache[__METHOD__];
1054 public function cssClass() {
1055 if ( !isset( $this->cache[__METHOD__] ) ) {
1056 $this->cache[__METHOD__] = new Juxtaposition( [
1057 new DelimMatcher( [ '.' ] ),
1060 $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1062 return $this->cache[__METHOD__];
1066 * An attribute selector
1068 * '[' S* [ namespace_prefix ]? IDENT S*
1074 * DASHMATCH ] S* [ IDENT | STRING ] S*
1077 * Captures are set for the attribute, test, and value. Note that these
1078 * captures will probably be relative to the contents of the SimpleBlock
1079 * that this matcher matches!
1083 public function cssAttrib() {
1084 if ( !isset( $this->cache[__METHOD__] ) ) {
1085 // An attribute is going to be parsed by the parser as a
1086 // SimpleBlock, so that's what we need to look for here.
1088 $this->cache[__METHOD__] = new BlockMatcher( Token::T_LEFT_BRACKET,
1089 new Juxtaposition( [
1090 $this->optionalWhitespace(),
1091 Juxtaposition::create( [
1092 $this->cssOptionalNamespacePrefix(),
1094 ] )->capture( 'attribute' ),
1095 $this->optionalWhitespace(),
1096 Quantifier::optional( new Juxtaposition( [
1097 Alternative::create( [
1098 new TokenMatcher( Token::T_PREFIX_MATCH ),
1099 new TokenMatcher( Token::T_SUFFIX_MATCH ),
1100 new TokenMatcher( Token::T_SUBSTRING_MATCH ),
1101 new DelimMatcher( [ '=' ] ),
1102 new TokenMatcher( Token::T_INCLUDE_MATCH ),
1103 new TokenMatcher( Token::T_DASH_MATCH ),
1104 ] )->capture( 'test' ),
1105 $this->optionalWhitespace(),
1106 Alternative::create( [
1109 ] )->capture( 'value' ),
1110 $this->optionalWhitespace(),
1114 $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1116 return $this->cache[__METHOD__];
1120 * A pseudo-class or pseudo-element
1122 * ':' ':'? [ IDENT | functional_pseudo ]
1124 * Although this actually only matches the pseudo-selectors defined in the
1125 * following sources:
1126 * - https://www.w3.org/TR/2011/REC-css3-selectors-20110929/#pseudo-classes
1127 * - https://www.w3.org/TR/2016/WD-css-pseudo-4-20160607/
1131 public function cssPseudo() {
1132 if ( !isset( $this->cache[__METHOD__] ) ) {
1133 $colon = new TokenMatcher( Token::T_COLON );
1134 $ows = $this->optionalWhitespace();
1135 $anplusb = new Juxtaposition( [ $ows, $this->cssANplusB(), $ows ] );
1136 $this->cache[__METHOD__] = new Alternative( [
1137 new Juxtaposition( [
1140 new KeywordMatcher( [
1141 'link', 'visited', 'hover', 'active', 'focus', 'target', 'enabled', 'disabled', 'checked',
1142 'indeterminate', 'root', 'first-child', 'last-child', 'first-of-type',
1143 'last-of-type', 'only-child', 'only-of-type', 'empty',
1144 // CSS2-compat elements with class syntax
1145 'first-line', 'first-letter', 'before', 'after',
1147 new FunctionMatcher( 'lang', new Juxtaposition( [ $ows, $this->ident(), $ows ] ) ),
1148 new FunctionMatcher( 'nth-child', $anplusb ),
1149 new FunctionMatcher( 'nth-last-child', $anplusb ),
1150 new FunctionMatcher( 'nth-of-type', $anplusb ),
1151 new FunctionMatcher( 'nth-last-of-type', $anplusb ),
1154 new Juxtaposition( [
1157 new KeywordMatcher( [
1158 'first-line', 'first-letter', 'before', 'after', 'selection', 'inactive-selection',
1159 'spelling-error', 'grammar-error', 'placeholder'
1163 $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1165 return $this->cache[__METHOD__];
1171 * https://www.w3.org/TR/2014/CR-css-syntax-3-20140220/#anb
1175 public function cssANplusB() {
1176 if ( !isset( $this->cache[__METHOD__] ) ) {
1178 // > The An+B notation was originally defined using a slightly
1179 // > different tokenizer than the rest of CSS, resulting in a
1180 // > somewhat odd definition when expressed in terms of CSS tokens.
1181 // That's a bit of an understatement
1183 $plus = new DelimMatcher( [ '+' ] );
1184 $plusQ = Quantifier::optional( new DelimMatcher( [ '+' ] ) );
1185 $n = new KeywordMatcher( [ 'n' ] );
1186 $dashN = new KeywordMatcher( [ '-n' ] );
1187 $nDash = new KeywordMatcher( [ 'n-' ] );
1188 $plusQN = new Juxtaposition( [ $plusQ, $n ] );
1189 $plusQNDash = new Juxtaposition( [ $plusQ, $nDash ] );
1190 $nDimension = new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) {
1191 return $t->typeFlag() === 'integer' && !strcasecmp( $t->unit(), 'n' );
1193 $nDashDimension = new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) {
1194 return $t->typeFlag() === 'integer' && !strcasecmp( $t->unit(), 'n-' );
1196 $nDashDigitDimension = new TokenMatcher( Token::T_DIMENSION, function ( Token $t ) {
1197 return $t->typeFlag() === 'integer' && preg_match( '/^n-\d+$/i', $t->unit() );
1199 $nDashDigitIdent = new TokenMatcher( Token::T_IDENT, function ( Token $t ) {
1200 return preg_match( '/^n-\d+$/i', $t->value() );
1202 $dashNDashDigitIdent = new TokenMatcher( Token::T_IDENT, function ( Token $t ) {
1203 return preg_match( '/^-n-\d+$/i', $t->value() );
1205 $signedInt = new TokenMatcher( Token::T_NUMBER, function ( Token $t ) {
1206 return $t->typeFlag() === 'integer' && preg_match( '/^[+-]/', $t->representation() );
1208 $signlessInt = new TokenMatcher( Token::T_NUMBER, function ( Token $t ) {
1209 return $t->typeFlag() === 'integer' && preg_match( '/^\d/', $t->representation() );
1211 $plusOrMinus = new DelimMatcher( [ '+', '-' ] );
1212 $S = $this->optionalWhitespace();
1214 $this->cache[__METHOD__] = new Alternative( [
1215 new KeywordMatcher( [ 'odd', 'even' ] ),
1216 new TokenMatcher( Token::T_NUMBER, function ( Token $t ) {
1217 return $t->typeFlag() === 'integer';
1222 $nDashDigitDimension,
1223 new Juxtaposition( [ $plusQ, $nDashDigitIdent ] ),
1224 $dashNDashDigitIdent,
1225 new Juxtaposition( [ $nDimension, $S, $signedInt ] ),
1226 new Juxtaposition( [ $plusQN, $S, $signedInt ] ),
1227 new Juxtaposition( [ $dashN, $S, $signedInt ] ),
1228 new Juxtaposition( [ $nDashDimension, $S, $signlessInt ] ),
1229 new Juxtaposition( [ $plusQNDash, $S, $signlessInt ] ),
1230 new Juxtaposition( [ new KeywordMatcher( [ '-n-' ] ), $S, $signlessInt ] ),
1231 new Juxtaposition( [ $nDimension, $S, $plusOrMinus, $S, $signlessInt ] ),
1232 new Juxtaposition( [ $plusQN, $S, $plusOrMinus, $S, $signlessInt ] ),
1233 new Juxtaposition( [ $dashN, $S, $plusOrMinus, $S, $signlessInt ] )
1235 $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1237 return $this->cache[__METHOD__];
1243 * ':' not( S* [ type_selector | universal | HASH | class | attrib | pseudo ] S* ')'
1247 public function cssNegation() {
1248 if ( !isset( $this->cache[__METHOD__] ) ) {
1249 // A negation is going to be parsed by the parser as a colon
1250 // followed by a CSSFunction, so that's what we need to look for
1253 $this->cache[__METHOD__] = new Juxtaposition( [
1254 new TokenMatcher( Token::T_COLON ),
1255 new FunctionMatcher( 'not',
1256 new Juxtaposition( [
1257 $this->optionalWhitespace(),
1259 $this->cssTypeSelector(),
1260 $this->cssUniversal(),
1266 $this->optionalWhitespace(),
1270 $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1272 return $this->cache[__METHOD__];
1280 * For really cool vim folding this needs to be at the end:
1281 * vim: foldmarker=@{,@} foldmethod=marker