Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
757 / 757
100.00% covered (success)
100.00%
61 / 61
CRAP
100.00% covered (success)
100.00%
1 / 1
MatcherFactory
100.00% covered (success)
100.00%
757 / 757
100.00% covered (success)
100.00%
61 / 61
133
100.00% covered (success)
100.00%
1 / 1
 singleton
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 optionalWhitespace
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 significantWhitespace
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 comma
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 ident
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 customIdent
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 string
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 urlstring
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 url
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 cssWideKeywords
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 calcInternal
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
1 / 1
5
 calc
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rawInteger
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 colorHex
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 integer
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 rawNumber
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 number
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 rawPercentage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 percentage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 lengthPercentage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 frequencyPercentage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 anglePercentage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 timePercentage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 numberPercentage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 dimension
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 zero
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 rawLength
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 length
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 rawAngle
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 angle
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 rawTime
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 time
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 rawFrequency
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 frequency
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 resolution
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 colorFuncs
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 safeColor
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 color
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 image
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
1 / 1
2
 position
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 bgPosition
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 cssMediaQuery
100.00% covered (success)
100.00%
99 / 99
100.00% covered (success)
100.00%
1 / 1
5
 cssMediaQueryList
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 cssSupportsCondition
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
2
 cssDeclaration
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 cssSingleEasingFunction
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 cssSelectorList
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 cssSelector
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 cssCombinator
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 cssSimpleSelectorSeq
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 cssTypeSelector
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 cssNamespacePrefix
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 cssOptionalNamespacePrefix
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 cssUniversal
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 cssID
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 cssClass
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 cssAttrib
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
2
 cssPseudo
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
2
 cssANplusB
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
1 / 1
7
 cssNegation
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 colorWords
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * @file
4 * @license https://opensource.org/licenses/Apache-2.0 Apache-2.0
5 */
6
7namespace Wikimedia\CSS\Grammar;
8
9use Wikimedia\CSS\Objects\ComponentValueList;
10use Wikimedia\CSS\Objects\Token;
11use Wikimedia\CSS\Parser\Parser;
12use Wikimedia\CSS\Sanitizer\PropertySanitizer;
13
14/**
15 * Factory for predefined Grammar matchers
16 * @note For security, the attr() and var() functions are not supported,
17 * although as a limited exception var() is allowed for color attributes
18 * in `::colorFuncs()`.
19 */
20class MatcherFactory {
21    /** @var MatcherFactory|null */
22    private static $instance = null;
23
24    /** @var (Matcher|Matcher[])[] Cache of constructed matchers */
25    protected $cache = [];
26
27    /** @var string[] length units */
28    protected static $lengthUnits = [
29        'em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax',
30        'cm', 'mm', 'Q', 'in', 'pc', 'pt', 'px'
31    ];
32
33    /** @var string[] angle units */
34    protected static $angleUnits = [ 'deg', 'grad', 'rad', 'turn' ];
35
36    /** @var string[] time units */
37    protected static $timeUnits = [ 's', 'ms' ];
38
39    /** @var string[] frequency units */
40    protected static $frequencyUnits = [ 'Hz', 'kHz' ];
41
42    /**
43     * Return a static instance of the factory
44     * @return MatcherFactory
45     */
46    public static function singleton() {
47        if ( !self::$instance ) {
48            self::$instance = new self();
49        }
50        return self::$instance;
51    }
52
53    /**
54     * Matcher for optional whitespace
55     * @return Matcher
56     */
57    public function optionalWhitespace() {
58        if ( !isset( $this->cache[__METHOD__] ) ) {
59            $this->cache[__METHOD__] = new WhitespaceMatcher( [ 'significant' => false ] );
60        }
61        return $this->cache[__METHOD__];
62    }
63
64    /**
65     * Matcher for required whitespace
66     * @return Matcher
67     */
68    public function significantWhitespace() {
69        if ( !isset( $this->cache[__METHOD__] ) ) {
70            $this->cache[__METHOD__] = new WhitespaceMatcher( [ 'significant' => true ] );
71        }
72        return $this->cache[__METHOD__];
73    }
74
75    /**
76     * Matcher for a comma
77     * @return Matcher
78     */
79    public function comma() {
80        if ( !isset( $this->cache[__METHOD__] ) ) {
81            $this->cache[__METHOD__] = new TokenMatcher( Token::T_COMMA );
82        }
83        return $this->cache[__METHOD__];
84    }
85
86    /**
87     * Matcher for an arbitrary identifier
88     * @return Matcher
89     */
90    public function ident() {
91        if ( !isset( $this->cache[__METHOD__] ) ) {
92            $this->cache[__METHOD__] = new TokenMatcher( Token::T_IDENT );
93        }
94        return $this->cache[__METHOD__];
95    }
96
97    /**
98     * Matcher for a <custom-ident>
99     *
100     * Note this doesn't implement the semantic restriction about assigning
101     * meaning to various idents in a complex value, as CSS Sanitizer doesn't
102     * deal with semantics on that level.
103     *
104     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#identifier-value
105     * @param string[] $exclude Additional values to exclude, all-lowercase.
106     * @return Matcher
107     */
108    public function customIdent( array $exclude = [] ) {
109        $exclude = array_merge( [
110            // https://www.w3.org/TR/2019/CR-css-values-3-20190606/#common-keywords
111            'initial', 'inherit', 'unset', 'default',
112            // https://www.w3.org/TR/2018/CR-css-cascade-4-20180828/#all-shorthand
113            'revert'
114        ], $exclude );
115        return new TokenMatcher( Token::T_IDENT, static function ( Token $t ) use ( $exclude ) {
116            return !in_array( strtolower( $t->value() ), $exclude, true );
117        } );
118    }
119
120    /**
121     * Matcher for a string
122     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#strings
123     * @warning If the string will be used as a URL, use self::urlstring() instead.
124     * @return Matcher
125     */
126    public function string() {
127        if ( !isset( $this->cache[__METHOD__] ) ) {
128            $this->cache[__METHOD__] = new TokenMatcher( Token::T_STRING );
129        }
130        return $this->cache[__METHOD__];
131    }
132
133    /**
134     * Matcher for a string containing a URL
135     * @param string $type Type of resource referenced, e.g. "image" or "audio".
136     *  Not used here, but might be used by a subclass to validate the URL more strictly.
137     * @return Matcher
138     */
139    public function urlstring( $type ) {
140        return $this->string();
141    }
142
143    /**
144     * Matcher for a URL
145     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#urls
146     * @param string $type Type of resource referenced, e.g. "image" or "audio".
147     *  Not used here, but might be used by a subclass to validate the URL more strictly.
148     * @return Matcher
149     */
150    public function url( $type ) {
151        if ( !isset( $this->cache[__METHOD__] ) ) {
152            $this->cache[__METHOD__] = new UrlMatcher();
153        }
154        return $this->cache[__METHOD__];
155    }
156
157    /**
158     * CSS-wide value keywords
159     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#common-keywords
160     * @return Matcher
161     */
162    public function cssWideKeywords() {
163        if ( !isset( $this->cache[__METHOD__] ) ) {
164            $this->cache[__METHOD__] = new KeywordMatcher( [
165                // https://www.w3.org/TR/2019/CR-css-values-3-20190606/#common-keywords
166                'initial', 'inherit', 'unset',
167                // added by https://www.w3.org/TR/2018/CR-css-cascade-4-20180828/#all-shorthand
168                'revert'
169            ] );
170        }
171        return $this->cache[__METHOD__];
172    }
173
174    /**
175     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#calc-notation
176     * @param Matcher $typeMatcher Matcher for the type
177     * @param string $type Type being matched
178     * @return Matcher[]
179     */
180    protected function calcInternal( Matcher $typeMatcher, $type ) {
181        if ( $type === 'integer' ) {
182            $num = $this->rawInteger();
183        } else {
184            $num = $this->rawNumber();
185        }
186
187        $ows = $this->optionalWhitespace();
188        $ws = $this->significantWhitespace();
189
190        // Definitions are recursive. This will be used by reference and later
191        // will be replaced.
192        $calcValue = new NothingMatcher();
193
194        if ( $type === 'integer' ) {
195            // Division will always resolve to a number, making the expression
196            // invalid, so don't allow it.
197            $calcProduct = new Juxtaposition( [
198                &$calcValue,
199                Quantifier::star( new Juxtaposition( [ $ows, new DelimMatcher( '*' ), $ows, &$calcValue ] ) )
200            ] );
201        } elseif ( $typeMatcher === $this->rawNumber() ) {
202            $calcProduct = new Juxtaposition( [
203                &$calcValue,
204                Quantifier::star(
205                    new Juxtaposition( [ $ows, new DelimMatcher( [ '*', '/' ] ), $ows, &$calcValue ] )
206                ),
207            ] );
208        } else {
209            $calcNumValue = $this->calcInternal( $this->rawNumber(), 'number' )[1];
210            $calcProduct = new Juxtaposition( [
211                &$calcValue,
212                Quantifier::star(
213                    new Alternative( [
214                        new Juxtaposition( [ $ows, new DelimMatcher( '*' ), $ows, &$calcValue ] ),
215                        new Juxtaposition( [ $ows, new DelimMatcher( '/' ), $ows, $calcNumValue, ] ),
216                    ] )
217                ),
218            ] );
219        }
220
221        $calcSum = new Juxtaposition( [
222            $ows,
223            $calcProduct,
224            Quantifier::star( new Juxtaposition( [
225                $ws, new DelimMatcher( [ '+', '-' ] ), $ws, $calcProduct
226            ] ) ),
227            $ows,
228        ] );
229
230        $calcFunc = new FunctionMatcher( 'calc', $calcSum );
231
232        if ( $num === $typeMatcher ) {
233            $calcValue = new Alternative( [
234                $typeMatcher,
235                new BlockMatcher( Token::T_LEFT_PAREN, $calcSum ),
236                $calcFunc,
237            ] );
238        } else {
239            $calcValue = new Alternative( [
240                $num,
241                $typeMatcher,
242                new BlockMatcher( Token::T_LEFT_PAREN, $calcSum ),
243                $calcFunc,
244            ] );
245        }
246
247        return [
248            new Alternative( [ $typeMatcher, $calcFunc ] ),
249            $calcValue,
250        ];
251    }
252
253    /**
254     * Add calc() support to a basic type matcher
255     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#calc-notation
256     * @param Matcher $typeMatcher Matcher for the type
257     * @param string $type Type being matched
258     * @return Matcher
259     */
260    public function calc( Matcher $typeMatcher, $type ) {
261        return $this->calcInternal( $typeMatcher, $type )[0];
262    }
263
264    /**
265     * Matcher for an integer value, without calc()
266     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#integers
267     * @return Matcher
268     */
269    protected function rawInteger() {
270        if ( !isset( $this->cache[__METHOD__] ) ) {
271            $this->cache[__METHOD__] = new TokenMatcher( Token::T_NUMBER, static function ( Token $t ) {
272                // The spec says it must match /^[+-]\d+$/, but the tokenizer
273                // should have marked any other number token as a 'number'
274                // anyway so let's not bother checking.
275                return $t->typeFlag() === 'integer';
276            } );
277        }
278        return $this->cache[__METHOD__];
279    }
280
281    /**
282     * @return TokenMatcher
283     */
284    public function colorHex(): TokenMatcher {
285        return new TokenMatcher( Token::T_HASH, static function ( Token $t ) {
286            return preg_match( '/^([0-9a-f]{3}|[0-9a-f]{6})$/i', $t->value() );
287        } );
288    }
289
290    /**
291     * Matcher for an integer value
292     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#integers
293     * @return Matcher
294     */
295    public function integer() {
296        if ( !isset( $this->cache[__METHOD__] ) ) {
297            $this->cache[__METHOD__] = $this->calc( $this->rawInteger(), 'integer' );
298        }
299        return $this->cache[__METHOD__];
300    }
301
302    /**
303     * Matcher for a real number, without calc()
304     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#numbers
305     * @return Matcher
306     */
307    public function rawNumber() {
308        if ( !isset( $this->cache[__METHOD__] ) ) {
309            $this->cache[__METHOD__] = new TokenMatcher( Token::T_NUMBER );
310        }
311        return $this->cache[__METHOD__];
312    }
313
314    /**
315     * Matcher for a real number
316     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#numbers
317     * @return Matcher
318     */
319    public function number() {
320        if ( !isset( $this->cache[__METHOD__] ) ) {
321            $this->cache[__METHOD__] = $this->calc( $this->rawNumber(), 'number' );
322        }
323        return $this->cache[__METHOD__];
324    }
325
326    /**
327     * Matcher for a percentage value, without calc()
328     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#percentages
329     * @return Matcher
330     */
331    public function rawPercentage() {
332        if ( !isset( $this->cache[__METHOD__] ) ) {
333            $this->cache[__METHOD__] = new TokenMatcher( Token::T_PERCENTAGE );
334        }
335        return $this->cache[__METHOD__];
336    }
337
338    /**
339     * Matcher for a percentage value
340     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#percentages
341     * @return Matcher
342     */
343    public function percentage() {
344        if ( !isset( $this->cache[__METHOD__] ) ) {
345            $this->cache[__METHOD__] = $this->calc( $this->rawPercentage(), 'percentage' );
346        }
347        return $this->cache[__METHOD__];
348    }
349
350    /**
351     * Matcher for a length-percentage value
352     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#typedef-length-percentage
353     * @return Matcher
354     */
355    public function lengthPercentage() {
356        if ( !isset( $this->cache[__METHOD__] ) ) {
357            $this->cache[__METHOD__] = $this->calc(
358                new Alternative( [ $this->rawLength(), $this->rawPercentage() ] ),
359                'length'
360            );
361        }
362        return $this->cache[__METHOD__];
363    }
364
365    /**
366     * Matcher for a frequency-percentage value
367     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#typedef-frequency-percentage
368     * @return Matcher
369     */
370    public function frequencyPercentage() {
371        if ( !isset( $this->cache[__METHOD__] ) ) {
372            $this->cache[__METHOD__] = $this->calc(
373                new Alternative( [ $this->rawFrequency(), $this->rawPercentage() ] ),
374                'frequency'
375            );
376        }
377        return $this->cache[__METHOD__];
378    }
379
380    /**
381     * Matcher for an angle-percentage value
382     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#typedef-angle-percentage
383     * @return Matcher
384     */
385    public function anglePercentage() {
386        if ( !isset( $this->cache[__METHOD__] ) ) {
387            $this->cache[__METHOD__] = $this->calc(
388                new Alternative( [ $this->rawAngle(), $this->rawPercentage() ] ),
389                'angle'
390            );
391        }
392        return $this->cache[__METHOD__];
393    }
394
395    /**
396     * Matcher for a time-percentage value
397     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#typedef-time-percentage
398     * @return Matcher
399     */
400    public function timePercentage() {
401        if ( !isset( $this->cache[__METHOD__] ) ) {
402            $this->cache[__METHOD__] = $this->calc(
403                new Alternative( [ $this->rawTime(), $this->rawPercentage() ] ),
404                'time'
405            );
406        }
407        return $this->cache[__METHOD__];
408    }
409
410    /**
411     * Matcher for a number-percentage value
412     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#typedef-number-percentage
413     * @return Matcher
414     */
415    public function numberPercentage() {
416        if ( !isset( $this->cache[__METHOD__] ) ) {
417            $this->cache[__METHOD__] = $this->calc(
418                new Alternative( [ $this->rawNumber(), $this->rawPercentage() ] ),
419                'number'
420            );
421        }
422        return $this->cache[__METHOD__];
423    }
424
425    /**
426     * Matcher for a dimension value
427     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#dimensions
428     * @return Matcher
429     */
430    public function dimension() {
431        if ( !isset( $this->cache[__METHOD__] ) ) {
432            $this->cache[__METHOD__] = new TokenMatcher( Token::T_DIMENSION );
433        }
434        return $this->cache[__METHOD__];
435    }
436
437    /**
438     * Matches the number 0
439     * @return Matcher
440     */
441    public function zero() {
442        if ( !isset( $this->cache[__METHOD__] ) ) {
443            $this->cache[__METHOD__] = new TokenMatcher( Token::T_NUMBER, static function ( Token $t ) {
444                return $t->value() === 0 || $t->value() === 0.0;
445            } );
446        }
447        return $this->cache[__METHOD__];
448    }
449
450    /**
451     * Matcher for a length value, without calc()
452     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#lengths
453     * @return Matcher
454     */
455    protected function rawLength() {
456        if ( !isset( $this->cache[__METHOD__] ) ) {
457            $unitsRe = '/^(' . implode( '|', self::$lengthUnits ) . ')$/i';
458
459            $this->cache[__METHOD__] = new Alternative( [
460                $this->zero(),
461                new TokenMatcher( Token::T_DIMENSION, static function ( Token $t ) use ( $unitsRe ) {
462                    return preg_match( $unitsRe, $t->unit() );
463                } ),
464            ] );
465        }
466        return $this->cache[__METHOD__];
467    }
468
469    /**
470     * Matcher for a length value
471     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#lengths
472     * @return Matcher
473     */
474    public function length() {
475        if ( !isset( $this->cache[__METHOD__] ) ) {
476            $this->cache[__METHOD__] = $this->calc( $this->rawLength(), 'length' );
477        }
478        return $this->cache[__METHOD__];
479    }
480
481    /**
482     * Matcher for an angle value, without calc()
483     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#angles
484     * @return Matcher
485     */
486    protected function rawAngle() {
487        if ( !isset( $this->cache[__METHOD__] ) ) {
488            $unitsRe = '/^(' . implode( '|', self::$angleUnits ) . ')$/i';
489
490            $this->cache[__METHOD__] = new TokenMatcher( Token::T_DIMENSION,
491                static function ( Token $t ) use ( $unitsRe ) {
492                    return preg_match( $unitsRe, $t->unit() );
493                }
494            );
495        }
496        return $this->cache[__METHOD__];
497    }
498
499    /**
500     * Matcher for an angle value
501     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#angles
502     * @return Matcher
503     */
504    public function angle() {
505        if ( !isset( $this->cache[__METHOD__] ) ) {
506            $this->cache[__METHOD__] = $this->calc( $this->rawAngle(), 'angle' );
507        }
508        return $this->cache[__METHOD__];
509    }
510
511    /**
512     * Matcher for a duration (time) value, without calc()
513     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#time
514     * @return Matcher
515     */
516    protected function rawTime() {
517        if ( !isset( $this->cache[__METHOD__] ) ) {
518            $unitsRe = '/^(' . implode( '|', self::$timeUnits ) . ')$/i';
519
520            $this->cache[__METHOD__] = new TokenMatcher( Token::T_DIMENSION,
521                static function ( Token $t ) use ( $unitsRe ) {
522                    return preg_match( $unitsRe, $t->unit() );
523                }
524            );
525        }
526        return $this->cache[__METHOD__];
527    }
528
529    /**
530     * Matcher for a duration (time) value
531     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#time
532     * @return Matcher
533     */
534    public function time() {
535        if ( !isset( $this->cache[__METHOD__] ) ) {
536            $this->cache[__METHOD__] = $this->calc( $this->rawTime(), 'time' );
537        }
538        return $this->cache[__METHOD__];
539    }
540
541    /**
542     * Matcher for a frequency value, without calc()
543     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#frequency
544     * @return Matcher
545     */
546    protected function rawFrequency() {
547        if ( !isset( $this->cache[__METHOD__] ) ) {
548            $unitsRe = '/^(' . implode( '|', self::$frequencyUnits ) . ')$/i';
549
550            $this->cache[__METHOD__] = new TokenMatcher( Token::T_DIMENSION,
551                static function ( Token $t ) use ( $unitsRe ) {
552                    return preg_match( $unitsRe, $t->unit() );
553                }
554            );
555        }
556        return $this->cache[__METHOD__];
557    }
558
559    /**
560     * Matcher for a frequency value
561     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#frequency
562     * @return Matcher
563     */
564    public function frequency() {
565        if ( !isset( $this->cache[__METHOD__] ) ) {
566            $this->cache[__METHOD__] = $this->calc( $this->rawFrequency(), 'frequency' );
567        }
568        return $this->cache[__METHOD__];
569    }
570
571    /**
572     * Matcher for a resolution value
573     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#resolution
574     * @return Matcher
575     */
576    public function resolution() {
577        if ( !isset( $this->cache[__METHOD__] ) ) {
578            $this->cache[__METHOD__] = new TokenMatcher( Token::T_DIMENSION, static function ( Token $t ) {
579                return preg_match( '/^(dpi|dpcm|dppx)$/i', $t->unit() );
580            } );
581        }
582        return $this->cache[__METHOD__];
583    }
584
585    /**
586     * Matchers for color functions
587     * @return Matcher[]
588     */
589    protected function colorFuncs() {
590        if ( !isset( $this->cache[__METHOD__] ) ) {
591            $i = $this->integer();
592            $n = $this->number();
593            $p = $this->percentage();
594            $this->cache[__METHOD__] = [
595                new FunctionMatcher( 'rgb', new Alternative( [
596                    Quantifier::hash( $i, 3, 3 ),
597                    Quantifier::hash( $p, 3, 3 ),
598                ] ) ),
599                new FunctionMatcher( 'rgba', new Alternative( [
600                    new Juxtaposition( [ $i, $i, $i, $n ], true ),
601                    new Juxtaposition( [ $p, $p, $p, $n ], true ),
602                ] ) ),
603                new FunctionMatcher( 'hsl', new Juxtaposition( [ $n, $p, $p ], true ) ),
604                new FunctionMatcher( 'hsla', new Juxtaposition( [ $n, $p, $p, $n ], true ) ),
605            ];
606        }
607        return $this->cache[__METHOD__];
608    }
609
610    /**
611     * Matcher for a color value, *not* including a custom property reference.
612     *
613     * Because custom properties can lead to unexpected behavior (generally
614     * a bad thing for security) when concatenated together, this matcher
615     * should be used for CSS rules which allow value concatenation.
616     * For example, `border-color` allows up to 4 `var(...)` expressions to
617     * potentially be concatenated.
618     *
619     * @see https://www.w3.org/TR/css-variables-1/#custom-property
620     * @return Matcher
621     */
622    public function safeColor() {
623        if ( !isset( $this->cache[__METHOD__] ) ) {
624            $this->cache[__METHOD__] = new Alternative( array_merge( [
625                $this->colorWords(),
626                $this->colorHex(),
627            ], $this->colorFuncs() ) );
628        }
629        return $this->cache[__METHOD__];
630    }
631
632    /**
633     * Matcher for a color value, including a possible custom property
634     * reference.
635     *
636     * @see https://www.w3.org/TR/2018/REC-css-color-3-20180619/#colorunits
637     * @return Matcher
638     */
639    public function color() {
640        if ( !isset( $this->cache[__METHOD__] ) ) {
641            $this->cache[__METHOD__] = new Alternative( [
642                $this->safeColor(),
643                new FunctionMatcher( 'var', new Juxtaposition( [
644                        new CustomPropertyMatcher(),
645                        Quantifier::optional( new Alternative( [
646                            $this->colorWords(),
647                            $this->colorHex(),
648                        ] ) ),
649                ], true ) ),
650            ] );
651        }
652        return $this->cache[__METHOD__];
653    }
654
655    /**
656     * Matcher for an image value
657     * @see https://www.w3.org/TR/2019/CR-css-images-3-20191010/#image-values
658     * @return Matcher
659     */
660    public function image() {
661        if ( !isset( $this->cache[__METHOD__] ) ) {
662            // https://www.w3.org/TR/2019/CR-css-images-3-20191010/#gradients
663            $c = $this->comma();
664            $colorStop = UnorderedGroup::allOf( [
665                $this->color(),
666                Quantifier::optional( $this->lengthPercentage() ),
667            ] );
668            $colorStopList = new Juxtaposition( [
669                $colorStop,
670                Quantifier::hash( new Juxtaposition( [
671                    Quantifier::optional( $this->lengthPercentage() ),
672                    $colorStop
673                ], true ) ),
674            ], true );
675            $atPosition = new Juxtaposition( [ new KeywordMatcher( 'at' ), $this->position() ] );
676
677            $linearGradient = new Juxtaposition( [
678                Quantifier::optional( new Juxtaposition( [
679                    new Alternative( [
680                        new Alternative( [
681                            $this->zero(),
682                            $this->angle(),
683                        ] ),
684                        new Juxtaposition( [ new KeywordMatcher( 'to' ), UnorderedGroup::someOf( [
685                            new KeywordMatcher( [ 'left', 'right' ] ),
686                            new KeywordMatcher( [ 'top', 'bottom' ] ),
687                        ] ) ] )
688                    ] ),
689                    $c
690                ] ) ),
691                $colorStopList,
692            ] );
693            $radialGradient = new Juxtaposition( [
694                Quantifier::optional( new Juxtaposition( [
695                    new Alternative( [
696                        new Juxtaposition( [
697                            new Alternative( [
698                                UnorderedGroup::someOf( [ new KeywordMatcher( 'circle' ), $this->length() ] ),
699                                UnorderedGroup::someOf( [
700                                    new KeywordMatcher( 'ellipse' ),
701                                    Quantifier::count( $this->lengthPercentage(), 2, 2 )
702                                ] ),
703                                UnorderedGroup::someOf( [
704                                    new KeywordMatcher( [ 'circle', 'ellipse' ] ),
705                                    new KeywordMatcher( [
706                                        'closest-corner', 'closest-side', 'farthest-corner', 'farthest-side',
707                                    ] ),
708                                ] ),
709                            ] ),
710                            Quantifier::optional( $atPosition ),
711                        ] ),
712                        $atPosition
713                    ] ),
714                    $c
715                ] ) ),
716                $colorStopList,
717            ] );
718
719            // Putting it all together
720            $this->cache[__METHOD__] = new Alternative( [
721                $this->url( 'image' ),
722                new FunctionMatcher( 'linear-gradient', $linearGradient ),
723                new FunctionMatcher( 'radial-gradient', $radialGradient ),
724                new FunctionMatcher( 'repeating-linear-gradient', $linearGradient ),
725                new FunctionMatcher( 'repeating-radial-gradient', $radialGradient ),
726            ] );
727        }
728        return $this->cache[__METHOD__];
729    }
730
731    /**
732     * Matcher for a position value
733     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#typedef-position
734     * @return Matcher
735     */
736    public function position() {
737        if ( !isset( $this->cache[__METHOD__] ) ) {
738            $lp = $this->lengthPercentage();
739            $center = new KeywordMatcher( 'center' );
740            $leftRight = new KeywordMatcher( [ 'left', 'right' ] );
741            $topBottom = new KeywordMatcher( [ 'top', 'bottom' ] );
742
743            $this->cache[__METHOD__] = new Alternative( [
744                UnorderedGroup::someOf( [
745                    new Alternative( [ $center, $leftRight ] ),
746                    new Alternative( [ $center, $topBottom ] ),
747                ] ),
748                new Juxtaposition( [
749                    new Alternative( [ $center, $leftRight, $lp ] ),
750                    Quantifier::optional( new Alternative( [ $center, $topBottom, $lp ] ) ),
751                ] ),
752
753                UnorderedGroup::allOf( [
754                    new Juxtaposition( [ $leftRight, $lp ] ),
755                    new Juxtaposition( [ $topBottom, $lp ] ),
756                ] ),
757            ] );
758        }
759        return $this->cache[__METHOD__];
760    }
761
762    /**
763     * Matcher for a bg-position value
764     * @see https://www.w3.org/TR/2017/CR-css-backgrounds-3-20171017/#typedef-bg-position
765     * @return Matcher
766     */
767    public function bgPosition() {
768        if ( !isset( $this->cache[__METHOD__] ) ) {
769            $lp = $this->lengthPercentage();
770            $olp = Quantifier::optional( $lp );
771            $center = new KeywordMatcher( 'center' );
772            $leftRight = new KeywordMatcher( [ 'left', 'right' ] );
773            $topBottom = new KeywordMatcher( [ 'top', 'bottom' ] );
774
775            $this->cache[__METHOD__] = new Alternative( [
776                new Alternative( [ $center, $leftRight, $topBottom, $lp ] ),
777                new Juxtaposition( [
778                    new Alternative( [ $center, $leftRight, $lp ] ),
779                    new Alternative( [ $center, $topBottom, $lp ] ),
780                ] ),
781                UnorderedGroup::allOf( [
782                    new Alternative( [ $center, new Juxtaposition( [ $leftRight, $olp ] ) ] ),
783                    new Alternative( [ $center, new Juxtaposition( [ $topBottom, $olp ] ) ] ),
784                ] ),
785            ] );
786        }
787        return $this->cache[__METHOD__];
788    }
789
790    /**
791     * Matcher for a CSS media query
792     * @see https://www.w3.org/TR/2017/CR-mediaqueries-4-20170905/#mq-syntax
793     * @param bool $strict Only allow defined query types
794     * @return Matcher
795     */
796    public function cssMediaQuery( $strict = true ) {
797        $key = __METHOD__ . ':' . ( $strict ? 'strict' : 'unstrict' );
798        if ( !isset( $this->cache[$key] ) ) {
799            if ( $strict ) {
800                $generalEnclosed = new NothingMatcher();
801
802                $mediaType = new KeywordMatcher( [
803                    'all', 'print', 'screen', 'speech',
804                    // deprecated
805                    'tty', 'tv', 'projection', 'handheld', 'braille', 'embossed', 'aural'
806                ] );
807
808                $rangeFeatures = [
809                    'width', 'height', 'aspect-ratio', 'resolution', 'color', 'color-index', 'monochrome',
810                    // deprecated
811                    'device-width', 'device-height', 'device-aspect-ratio'
812                ];
813                $discreteFeatures = [
814                    'orientation', 'scan', 'grid', 'update', 'overflow-block', 'overflow-inline', 'color-gamut',
815                    'pointer', 'hover', 'any-pointer', 'any-hover', 'scripting', 'prefers-color-scheme'
816                ];
817                $mfName = new KeywordMatcher( array_merge(
818                    $rangeFeatures,
819                    array_map( static function ( $f ) {
820                        return "min-$f";
821                    }, $rangeFeatures ),
822                    array_map( static function ( $f ) {
823                        return "max-$f";
824                    }, $rangeFeatures ),
825                    $discreteFeatures
826                ) );
827            } else {
828                $anythingPlus = new AnythingMatcher( [ 'quantifier' => '+' ] );
829                $generalEnclosed = new Alternative( [
830                    new FunctionMatcher( null, $anythingPlus ),
831                    new BlockMatcher( Token::T_LEFT_PAREN,
832                        new Juxtaposition( [ $this->ident(), $anythingPlus ] )
833                    ),
834                ] );
835                $mediaType = $this->ident();
836                $mfName = $this->ident();
837            }
838
839            $posInt = $this->calc(
840                new TokenMatcher( Token::T_NUMBER, static function ( Token $t ) {
841                    return $t->typeFlag() === 'integer' && preg_match( '/^\+?\d+$/', $t->representation() );
842                } ),
843                'integer'
844            );
845            $eq = new DelimMatcher( '=' );
846            $oeq = Quantifier::optional( new Juxtaposition( [ new NoWhitespace, $eq ] ) );
847            $ltgteq = Quantifier::optional( new Alternative( [
848                $eq,
849                new Juxtaposition( [ new DelimMatcher( [ '<', '>' ] ), $oeq ] ),
850            ] ) );
851            $lteq = new Juxtaposition( [ new DelimMatcher( '<' ), $oeq ] );
852            $gteq = new Juxtaposition( [ new DelimMatcher( '>' ), $oeq ] );
853            $mfValue = new Alternative( [
854                $this->number(),
855                $this->dimension(),
856                $this->ident(),
857                new KeywordMatcher( [ 'light', 'dark' ] ),
858                new Juxtaposition( [ $posInt, new DelimMatcher( '/' ), $posInt ] ),
859            ] );
860
861            // temporary
862            $mediaInParens = new NothingMatcher();
863            $mediaNot = new Juxtaposition( [ new KeywordMatcher( 'not' ), &$mediaInParens ] );
864            $mediaAnd = new Juxtaposition( [ new KeywordMatcher( 'and' ), &$mediaInParens ] );
865            $mediaOr = new Juxtaposition( [ new KeywordMatcher( 'or' ), &$mediaInParens ] );
866            $mediaCondition = new Alternative( [
867                $mediaNot,
868                new Juxtaposition( [
869                    &$mediaInParens,
870                    new Alternative( [
871                        Quantifier::star( $mediaAnd ),
872                        Quantifier::star( $mediaOr ),
873                    ] )
874                ] ),
875            ] );
876            $mediaConditionWithoutOr = new Alternative( [
877                $mediaNot,
878                new Juxtaposition( [ &$mediaInParens, Quantifier::star( $mediaAnd ) ] ),
879            ] );
880            $mediaFeature = new BlockMatcher( Token::T_LEFT_PAREN, new Alternative( [
881                // <mf-plain>
882                new Juxtaposition( [ $mfName, new TokenMatcher( Token::T_COLON ), $mfValue ] ),
883                // <mf-boolean>
884                $mfName,
885                // <mf-range>, 1st alternative
886                new Juxtaposition( [ $mfName, $ltgteq, $mfValue ] ),
887                // <mf-range>, 2nd alternative
888                new Juxtaposition( [ $mfValue, $ltgteq, $mfName ] ),
889                // <mf-range>, 3rd alt
890                new Juxtaposition( [ $mfValue, $lteq, $mfName, $lteq, $mfValue ] ),
891                // <mf-range>, 4th alt
892                new Juxtaposition( [ $mfValue, $gteq, $mfName, $gteq, $mfValue ] ),
893            ] ) );
894            $mediaInParens = new Alternative( [
895                new BlockMatcher( Token::T_LEFT_PAREN, $mediaCondition ),
896                $mediaFeature,
897                $generalEnclosed,
898            ] );
899
900            $this->cache[$key] = new Alternative( [
901                $mediaCondition,
902                new Juxtaposition( [
903                    Quantifier::optional( new KeywordMatcher( [ 'not', 'only' ] ) ),
904                    $mediaType,
905                    Quantifier::optional( new Juxtaposition( [
906                        new KeywordMatcher( 'and' ),
907                        $mediaConditionWithoutOr,
908                    ] ) )
909                ] )
910            ] );
911        }
912
913        return $this->cache[$key];
914    }
915
916    /**
917     * Matcher for a CSS media query list
918     * @see https://www.w3.org/TR/2017/CR-mediaqueries-4-20170905/#mq-syntax
919     * @param bool $strict Only allow defined query types
920     * @return Matcher
921     */
922    public function cssMediaQueryList( $strict = true ) {
923        $key = __METHOD__ . ':' . ( $strict ? 'strict' : 'unstrict' );
924        if ( !isset( $this->cache[$key] ) ) {
925            $this->cache[$key] = Quantifier::hash( $this->cssMediaQuery( $strict ), 0, INF );
926        }
927
928        return $this->cache[$key];
929    }
930
931    /**
932     * Matcher for a "supports-condition"
933     * @see https://www.w3.org/TR/2013/CR-css3-conditional-20130404/#supports_condition
934     * @param PropertySanitizer|null $declarationSanitizer Check declarations against this Sanitizer
935     * @param bool $strict Only accept defined syntax. Default true.
936     * @return Matcher
937     */
938    public function cssSupportsCondition(
939        PropertySanitizer $declarationSanitizer = null, $strict = true
940    ) {
941        $ws = $this->significantWhitespace();
942        $anythingPlus = new AnythingMatcher( [ 'quantifier' => '+' ] );
943
944        if ( $strict ) {
945            $generalEnclosed = new NothingMatcher();
946        } else {
947            $generalEnclosed = new Alternative( [
948                new FunctionMatcher( null, $anythingPlus ),
949                new BlockMatcher( Token::T_LEFT_PAREN, new Juxtaposition( [ $this->ident(), $anythingPlus ] ) ),
950            ] );
951        }
952
953        // temp
954        $supportsConditionBlock = new NothingMatcher();
955        $supportsConditionInParens = new Alternative( [
956            &$supportsConditionBlock,
957            new BlockMatcher( Token::T_LEFT_PAREN, $this->cssDeclaration( $declarationSanitizer ) ),
958            $generalEnclosed,
959        ] );
960        $supportsCondition = new Alternative( [
961            new Juxtaposition( [ new KeywordMatcher( 'not' ), $ws, $supportsConditionInParens ] ),
962            new Juxtaposition( [ $supportsConditionInParens, Quantifier::plus( new Juxtaposition( [
963                $ws, new KeywordMatcher( 'and' ), $ws, $supportsConditionInParens
964            ] ) ) ] ),
965            new Juxtaposition( [ $supportsConditionInParens, Quantifier::plus( new Juxtaposition( [
966                $ws, new KeywordMatcher( 'or' ), $ws, $supportsConditionInParens
967            ] ) ) ] ),
968            $supportsConditionInParens,
969        ] );
970        $supportsConditionBlock = new BlockMatcher( Token::T_LEFT_PAREN, $supportsCondition );
971
972        return $supportsCondition;
973    }
974
975    /**
976     * Matcher for a declaration
977     * @param PropertySanitizer|null $declarationSanitizer Check declarations against this Sanitizer
978     * @return Matcher
979     */
980    public function cssDeclaration( PropertySanitizer $declarationSanitizer = null ) {
981        $anythingPlus = new AnythingMatcher( [ 'quantifier' => '+' ] );
982
983        return new CheckedMatcher(
984            $anythingPlus,
985            static function ( ComponentValueList $list, GrammarMatch $match, array $options )
986                use ( $declarationSanitizer )
987            {
988                $cvlist = new ComponentValueList( $match->getValues() );
989                $parser = Parser::newFromTokens( $cvlist->toTokenArray() );
990                $declaration = $parser->parseDeclaration();
991                if ( !$declaration || $parser->getParseErrors() ) {
992                    return false;
993                }
994                if ( !$declarationSanitizer ) {
995                    return true;
996                }
997                $reset = $declarationSanitizer->stashSanitizationErrors();
998                $ret = $declarationSanitizer->sanitize( $declaration );
999                $errors = $declarationSanitizer->getSanitizationErrors();
1000                unset( $reset );
1001                return $ret === $declaration && !$errors;
1002            }
1003        );
1004    }
1005
1006    /**
1007     * Matcher for single easing functions from CSS Easing Functions Level 1
1008     * @see https://www.w3.org/TR/2019/CR-css-easing-1-20190430/#typedef-easing-function
1009     * @return Matcher
1010     */
1011    public function cssSingleEasingFunction() {
1012        if ( !isset( $this->cache[__METHOD__] ) ) {
1013            $this->cache[__METHOD__] = new Alternative( [
1014                new KeywordMatcher( [
1015                    'ease', 'linear', 'ease-in', 'ease-out', 'ease-in-out', 'step-start', 'step-end'
1016                ] ),
1017                new FunctionMatcher( 'steps', new Juxtaposition( [
1018                    $this->integer(),
1019                    Quantifier::optional( new KeywordMatcher( [
1020                        'jump-start', 'jump-end', 'jump-none', 'jump-both', 'start', 'end'
1021                    ] ) ),
1022                ], true ) ),
1023                new FunctionMatcher( 'cubic-bezier', Quantifier::hash( $this->number(), 4, 4 ) ),
1024            ] );
1025        }
1026
1027        return $this->cache[__METHOD__];
1028    }
1029
1030    /**
1031     * @name   CSS Selectors Level 3
1032     * @{
1033     *
1034     * https://www.w3.org/TR/2018/REC-selectors-3-20181106/#w3cselgrammar
1035     */
1036
1037    /**
1038     * List of selectors (selectors_group)
1039     *
1040     *     selector [ COMMA S* selector ]*
1041     *
1042     * Capturing is set up for the `selector`s.
1043     *
1044     * @return Matcher
1045     */
1046    public function cssSelectorList() {
1047        if ( !isset( $this->cache[__METHOD__] ) ) {
1048            // Technically the spec doesn't allow whitespace before the comma,
1049            // but I'd guess every browser does. So just use Quantifier::hash.
1050            $selector = $this->cssSelector()->capture( 'selector' );
1051            $this->cache[__METHOD__] = Quantifier::hash( $selector );
1052            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1053        }
1054        return $this->cache[__METHOD__];
1055    }
1056
1057    /**
1058     * A single selector (selector)
1059     *
1060     *     simple_selector_sequence [ combinator simple_selector_sequence ]*
1061     *
1062     * Capturing is set up for the `simple_selector_sequence`s (as 'simple') and `combinator`.
1063     *
1064     * @return Matcher
1065     */
1066    public function cssSelector() {
1067        if ( !isset( $this->cache[__METHOD__] ) ) {
1068            $simple = $this->cssSimpleSelectorSeq()->capture( 'simple' );
1069            $this->cache[__METHOD__] = new Juxtaposition( [
1070                $simple,
1071                Quantifier::star( new Juxtaposition( [
1072                    $this->cssCombinator()->capture( 'combinator' ),
1073                    $simple,
1074                ] ) )
1075            ] );
1076            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1077        }
1078        return $this->cache[__METHOD__];
1079    }
1080
1081    /**
1082     * A CSS combinator (combinator)
1083     *
1084     *     PLUS S* | GREATER S* | TILDE S* | S+
1085     *
1086     * (combinators can be surrounded by whitespace)
1087     *
1088     * @return Matcher
1089     */
1090    public function cssCombinator() {
1091        if ( !isset( $this->cache[__METHOD__] ) ) {
1092            $this->cache[__METHOD__] = new Alternative( [
1093                new Juxtaposition( [
1094                    $this->optionalWhitespace(),
1095                    new DelimMatcher( [ '+', '>', '~' ] ),
1096                    $this->optionalWhitespace(),
1097                ] ),
1098                $this->significantWhitespace(),
1099            ] );
1100            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1101        }
1102        return $this->cache[__METHOD__];
1103    }
1104
1105    /**
1106     * A simple selector sequence (simple_selector_sequence)
1107     *
1108     *     [ type_selector | universal ]
1109     *     [ HASH | class | attrib | pseudo | negation ]*
1110     *     | [ HASH | class | attrib | pseudo | negation ]+
1111     *
1112     * The following captures are set:
1113     *  - element: [ type_selector | universal ]
1114     *  - id: HASH
1115     *  - class: class
1116     *  - attrib: attrib
1117     *  - pseudo: pseudo
1118     *  - negation: negation
1119     *
1120     * @return Matcher
1121     */
1122    public function cssSimpleSelectorSeq() {
1123        if ( !isset( $this->cache[__METHOD__] ) ) {
1124            $hashEtc = new Alternative( [
1125                $this->cssID()->capture( 'id' ),
1126                $this->cssClass()->capture( 'class' ),
1127                $this->cssAttrib()->capture( 'attrib' ),
1128                $this->cssPseudo()->capture( 'pseudo' ),
1129                $this->cssNegation()->capture( 'negation' ),
1130            ] );
1131
1132            $this->cache[__METHOD__] = new Alternative( [
1133                new Juxtaposition( [
1134                    Alternative::create( [
1135                        $this->cssTypeSelector(),
1136                        $this->cssUniversal(),
1137                    ] )->capture( 'element' ),
1138                    Quantifier::star( $hashEtc )
1139                ] ),
1140                Quantifier::plus( $hashEtc )
1141            ] );
1142            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1143        }
1144        return $this->cache[__METHOD__];
1145    }
1146
1147    /**
1148     * A type selector, i.e. a tag name (type_selector)
1149     *
1150     *     [ namespace_prefix ] ? element_name
1151     *
1152     * where element_name is
1153     *
1154     *     IDENT
1155     *
1156     * @return Matcher
1157     */
1158    public function cssTypeSelector() {
1159        if ( !isset( $this->cache[__METHOD__] ) ) {
1160            $this->cache[__METHOD__] = new Juxtaposition( [
1161                $this->cssOptionalNamespacePrefix(),
1162                new TokenMatcher( Token::T_IDENT )
1163            ] );
1164            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1165        }
1166        return $this->cache[__METHOD__];
1167    }
1168
1169    /**
1170     * A namespace prefix (namespace_prefix)
1171     *
1172     *      [ IDENT | '*' ]? '|'
1173     *
1174     * @return Matcher
1175     */
1176    public function cssNamespacePrefix() {
1177        if ( !isset( $this->cache[__METHOD__] ) ) {
1178            $this->cache[__METHOD__] = new Juxtaposition( [
1179                Quantifier::optional( new Alternative( [
1180                    $this->ident(),
1181                    new DelimMatcher( [ '*' ] ),
1182                ] ) ),
1183                new DelimMatcher( [ '|' ] ),
1184            ] );
1185            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1186        }
1187        return $this->cache[__METHOD__];
1188    }
1189
1190    /**
1191     * An optional namespace prefix
1192     *
1193     *     [ namespace_prefix ]?
1194     *
1195     * @return Matcher
1196     */
1197    private function cssOptionalNamespacePrefix() {
1198        if ( !isset( $this->cache[__METHOD__] ) ) {
1199            $this->cache[__METHOD__] = Quantifier::optional( $this->cssNamespacePrefix() );
1200            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1201        }
1202        return $this->cache[__METHOD__];
1203    }
1204
1205    /**
1206     * The universal selector (universal)
1207     *
1208     *     [ namespace_prefix ]? '*'
1209     *
1210     * @return Matcher
1211     */
1212    public function cssUniversal() {
1213        if ( !isset( $this->cache[__METHOD__] ) ) {
1214            $this->cache[__METHOD__] = new Juxtaposition( [
1215                $this->cssOptionalNamespacePrefix(),
1216                new DelimMatcher( [ '*' ] )
1217            ] );
1218            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1219        }
1220        return $this->cache[__METHOD__];
1221    }
1222
1223    /**
1224     * An ID selector
1225     *
1226     *     HASH
1227     *
1228     * @return Matcher
1229     */
1230    public function cssID() {
1231        if ( !isset( $this->cache[__METHOD__] ) ) {
1232            $this->cache[__METHOD__] = new TokenMatcher( Token::T_HASH, static function ( Token $t ) {
1233                return $t->typeFlag() === 'id';
1234            } );
1235            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1236        }
1237        return $this->cache[__METHOD__];
1238    }
1239
1240    /**
1241     * A class selector (class)
1242     *
1243     *     '.' IDENT
1244     *
1245     * @return Matcher
1246     */
1247    public function cssClass() {
1248        if ( !isset( $this->cache[__METHOD__] ) ) {
1249            $this->cache[__METHOD__] = new Juxtaposition( [
1250                new DelimMatcher( [ '.' ] ),
1251                $this->ident()
1252            ] );
1253            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1254        }
1255        return $this->cache[__METHOD__];
1256    }
1257
1258    /**
1259     * An attribute selector (attrib)
1260     *
1261     *     '[' S* [ namespace_prefix ]? IDENT S*
1262     *         [ [ PREFIXMATCH |
1263     *             SUFFIXMATCH |
1264     *             SUBSTRINGMATCH |
1265     *             '=' |
1266     *             INCLUDES |
1267     *             DASHMATCH ] S* [ IDENT | STRING ] S*
1268     *         ]? ']'
1269     *
1270     * Captures are set for the attribute, test, and value. Note that these
1271     * captures will probably be relative to the contents of the SimpleBlock
1272     * that this matcher matches!
1273     *
1274     * @return Matcher
1275     */
1276    public function cssAttrib() {
1277        if ( !isset( $this->cache[__METHOD__] ) ) {
1278            // An attribute is going to be parsed by the parser as a
1279            // SimpleBlock, so that's what we need to look for here.
1280
1281            $this->cache[__METHOD__] = new BlockMatcher( Token::T_LEFT_BRACKET,
1282                new Juxtaposition( [
1283                    $this->optionalWhitespace(),
1284                    Juxtaposition::create( [
1285                        $this->cssOptionalNamespacePrefix(),
1286                        $this->ident(),
1287                    ] )->capture( 'attribute' ),
1288                    $this->optionalWhitespace(),
1289                    Quantifier::optional( new Juxtaposition( [
1290                        // Sigh. They removed various tokens from CSS Syntax 3, but didn't update the grammar
1291                        // in CSS Selectors 3. Wing it with a hint from CSS Selectors 4's <attr-matcher>
1292                        ( new Juxtaposition( [
1293                            Quantifier::optional( new DelimMatcher( [ '^', '$', '*', '~', '|' ] ) ),
1294                            new DelimMatcher( [ '=' ] ),
1295                        ] ) )->capture( 'test' ),
1296                        $this->optionalWhitespace(),
1297                        Alternative::create( [
1298                            $this->ident(),
1299                            $this->string(),
1300                        ] )->capture( 'value' ),
1301                        $this->optionalWhitespace(),
1302                    ] ) ),
1303                ] )
1304            );
1305            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1306        }
1307        return $this->cache[__METHOD__];
1308    }
1309
1310    /**
1311     * A pseudo-class or pseudo-element (pseudo)
1312     *
1313     *     ':' ':'? [ IDENT | functional_pseudo ]
1314     *
1315     * Where functional_pseudo is
1316     *
1317     *     FUNCTION S* expression ')'
1318     *
1319     * Although this actually only matches the pseudo-selectors defined in the
1320     * following sources:
1321     * - https://www.w3.org/TR/2018/REC-selectors-3-20181106/#pseudo-classes
1322     * - https://www.w3.org/TR/2019/WD-css-pseudo-4-20190225/
1323     *
1324     * @return Matcher
1325     */
1326    public function cssPseudo() {
1327        if ( !isset( $this->cache[__METHOD__] ) ) {
1328            $colon = new TokenMatcher( Token::T_COLON );
1329            $ows = $this->optionalWhitespace();
1330            $anplusb = new Juxtaposition( [ $ows, $this->cssANplusB(), $ows ] );
1331            $this->cache[__METHOD__] = new Alternative( [
1332                new Juxtaposition( [
1333                    $colon,
1334                    new Alternative( [
1335                        new KeywordMatcher( [
1336                            'link', 'visited', 'hover', 'active', 'focus', 'target', 'enabled', 'disabled', 'checked',
1337                            'indeterminate', 'root', 'first-child', 'last-child', 'first-of-type',
1338                            'last-of-type', 'only-child', 'only-of-type', 'empty',
1339                            // CSS2-compat elements with class syntax
1340                            'first-line', 'first-letter', 'before', 'after',
1341                        ] ),
1342                        new FunctionMatcher( 'lang', new Juxtaposition( [ $ows, $this->ident(), $ows ] ) ),
1343                        new FunctionMatcher( 'nth-child', $anplusb ),
1344                        new FunctionMatcher( 'nth-last-child', $anplusb ),
1345                        new FunctionMatcher( 'nth-of-type', $anplusb ),
1346                        new FunctionMatcher( 'nth-last-of-type', $anplusb ),
1347                    ] ),
1348                ] ),
1349                new Juxtaposition( [
1350                    $colon,
1351                    $colon,
1352                    new KeywordMatcher( [
1353                        'first-line', 'first-letter', 'before', 'after', 'selection', 'inactive-selection',
1354                        'spelling-error', 'grammar-error', 'marker', 'placeholder'
1355                    ] ),
1356                ] ),
1357            ] );
1358            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1359        }
1360        return $this->cache[__METHOD__];
1361    }
1362
1363    /**
1364     * An "AN+B" form
1365     *
1366     * https://www.w3.org/TR/2019/CR-css-syntax-3-20190716/#anb-microsyntax
1367     *
1368     * @return Matcher
1369     */
1370    public function cssANplusB() {
1371        if ( !isset( $this->cache[__METHOD__] ) ) {
1372            // Quoth the spec:
1373            // > The An+B notation was originally defined using a slightly
1374            // > different tokenizer than the rest of CSS, resulting in a
1375            // > somewhat odd definition when expressed in terms of CSS tokens.
1376            // That's a bit of an understatement
1377
1378            $plusQ = Quantifier::optional( new DelimMatcher( [ '+' ] ) );
1379            $n = new KeywordMatcher( [ 'n' ] );
1380            $dashN = new KeywordMatcher( [ '-n' ] );
1381            $nDash = new KeywordMatcher( [ 'n-' ] );
1382            $plusQN = new Juxtaposition( [ $plusQ, $n ] );
1383            $plusQNDash = new Juxtaposition( [ $plusQ, $nDash ] );
1384            $nDimension = new TokenMatcher( Token::T_DIMENSION, static function ( Token $t ) {
1385                return $t->typeFlag() === 'integer' && !strcasecmp( $t->unit(), 'n' );
1386            } );
1387            $nDashDimension = new TokenMatcher( Token::T_DIMENSION, static function ( Token $t ) {
1388                return $t->typeFlag() === 'integer' && !strcasecmp( $t->unit(), 'n-' );
1389            } );
1390            $nDashDigitDimension = new TokenMatcher( Token::T_DIMENSION, static function ( Token $t ) {
1391                return $t->typeFlag() === 'integer' && preg_match( '/^n-\d+$/i', $t->unit() );
1392            } );
1393            $nDashDigitIdent = new TokenMatcher( Token::T_IDENT, static function ( Token $t ) {
1394                return preg_match( '/^n-\d+$/i', $t->value() );
1395            } );
1396            $dashNDashDigitIdent = new TokenMatcher( Token::T_IDENT, static function ( Token $t ) {
1397                return preg_match( '/^-n-\d+$/i', $t->value() );
1398            } );
1399            $signedInt = new TokenMatcher( Token::T_NUMBER, static function ( Token $t ) {
1400                return $t->typeFlag() === 'integer' && preg_match( '/^[+-]/', $t->representation() );
1401            } );
1402            $signlessInt = new TokenMatcher( Token::T_NUMBER, static function ( Token $t ) {
1403                return $t->typeFlag() === 'integer' && preg_match( '/^\d/', $t->representation() );
1404            } );
1405            $plusOrMinus = new DelimMatcher( [ '+', '-' ] );
1406            $S = $this->optionalWhitespace();
1407
1408            $this->cache[__METHOD__] = new Alternative( [
1409                new KeywordMatcher( [ 'odd', 'even' ] ),
1410                new TokenMatcher( Token::T_NUMBER, static function ( Token $t ) {
1411                    return $t->typeFlag() === 'integer';
1412                } ),
1413                $nDimension,
1414                $plusQN,
1415                $dashN,
1416                $nDashDigitDimension,
1417                new Juxtaposition( [ $plusQ, $nDashDigitIdent ] ),
1418                $dashNDashDigitIdent,
1419                new Juxtaposition( [ $nDimension, $S, $signedInt ] ),
1420                new Juxtaposition( [ $plusQN, $S, $signedInt ] ),
1421                new Juxtaposition( [ $dashN, $S, $signedInt ] ),
1422                new Juxtaposition( [ $nDashDimension, $S, $signlessInt ] ),
1423                new Juxtaposition( [ $plusQNDash, $S, $signlessInt ] ),
1424                new Juxtaposition( [ new KeywordMatcher( [ '-n-' ] ), $S, $signlessInt ] ),
1425                new Juxtaposition( [ $nDimension, $S, $plusOrMinus, $S, $signlessInt ] ),
1426                new Juxtaposition( [ $plusQN, $S, $plusOrMinus, $S, $signlessInt ] ),
1427                new Juxtaposition( [ $dashN, $S, $plusOrMinus, $S, $signlessInt ] )
1428            ] );
1429            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1430        }
1431        return $this->cache[__METHOD__];
1432    }
1433
1434    /**
1435     * A negation (negation)
1436     *
1437     *     ':' not( S* [ type_selector | universal | HASH | class | attrib | pseudo ] S* ')'
1438     *
1439     * @return Matcher
1440     */
1441    public function cssNegation() {
1442        if ( !isset( $this->cache[__METHOD__] ) ) {
1443            // A negation is going to be parsed by the parser as a colon
1444            // followed by a CSSFunction, so that's what we need to look for
1445            // here.
1446
1447            $this->cache[__METHOD__] = new Juxtaposition( [
1448                new TokenMatcher( Token::T_COLON ),
1449                new FunctionMatcher( 'not',
1450                    new Juxtaposition( [
1451                        $this->optionalWhitespace(),
1452                        new Alternative( [
1453                            $this->cssTypeSelector(),
1454                            $this->cssUniversal(),
1455                            $this->cssID(),
1456                            $this->cssClass(),
1457                            $this->cssAttrib(),
1458                            $this->cssPseudo(),
1459                        ] ),
1460                        $this->optionalWhitespace(),
1461                    ] )
1462                )
1463            ] );
1464            $this->cache[__METHOD__]->setDefaultOptions( [ 'skip-whitespace' => false ] );
1465        }
1466        return $this->cache[__METHOD__];
1467    }
1468
1469    /**
1470     * @return KeywordMatcher
1471     */
1472    public function colorWords(): KeywordMatcher {
1473        return new KeywordMatcher( [
1474            // Basic colors
1475            'aqua', 'black', 'blue', 'fuchsia', 'gray', 'green',
1476            'lime', 'maroon', 'navy', 'olive', 'purple', 'red',
1477            'silver', 'teal', 'white', 'yellow',
1478            // Extended colors
1479            'aliceblue', 'antiquewhite', 'aquamarine', 'azure',
1480            'beige', 'bisque', 'blanchedalmond', 'blueviolet', 'brown',
1481            'burlywood', 'cadetblue', 'chartreuse', 'chocolate',
1482            'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan',
1483            'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray',
1484            'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta',
1485            'darkolivegreen', 'darkorange', 'darkorchid', 'darkred',
1486            'darksalmon', 'darkseagreen', 'darkslateblue',
1487            'darkslategray', 'darkslategrey', 'darkturquoise',
1488            'darkviolet', 'deeppink', 'deepskyblue', 'dimgray',
1489            'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite',
1490            'forestgreen', 'gainsboro', 'ghostwhite', 'gold',
1491            'goldenrod', 'greenyellow', 'grey', 'honeydew', 'hotpink',
1492            'indianred', 'indigo', 'ivory', 'khaki', 'lavender',
1493            'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue',
1494            'lightcoral', 'lightcyan', 'lightgoldenrodyellow',
1495            'lightgray', 'lightgreen', 'lightgrey', 'lightpink',
1496            'lightsalmon', 'lightseagreen', 'lightskyblue',
1497            'lightslategray', 'lightslategrey', 'lightsteelblue',
1498            'lightyellow', 'limegreen', 'linen', 'magenta',
1499            'mediumaquamarine', 'mediumblue', 'mediumorchid',
1500            'mediumpurple', 'mediumseagreen', 'mediumslateblue',
1501            'mediumspringgreen', 'mediumturquoise', 'mediumvioletred',
1502            'midnightblue', 'mintcream', 'mistyrose', 'moccasin',
1503            'navajowhite', 'oldlace', 'olivedrab', 'orange',
1504            'orangered', 'orchid', 'palegoldenrod', 'palegreen',
1505            'paleturquoise', 'palevioletred', 'papayawhip',
1506            'peachpuff', 'peru', 'pink', 'plum', 'powderblue',
1507            'rosybrown', 'royalblue', 'saddlebrown', 'salmon',
1508            'sandybrown', 'seagreen', 'seashell', 'sienna', 'skyblue',
1509            'slateblue', 'slategray', 'slategrey', 'snow',
1510            'springgreen', 'steelblue', 'tan', 'thistle', 'tomato',
1511            'turquoise', 'violet', 'wheat', 'whitesmoke',
1512            'yellowgreen',
1513            // Other keywords. Intentionally omitting the deprecated system colors.
1514            'transparent', 'currentColor',
1515        ] );
1516    }
1517
1518    /** @} */
1519
1520}
1521
1522/**
1523 * For really cool vim folding this needs to be at the end:
1524 * vim: foldmarker=@{,@} foldmethod=marker
1525 */