Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
54 / 54
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
Quantifier
100.00% covered (success)
100.00%
54 / 54
100.00% covered (success)
100.00%
7 / 7
23
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 optional
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 star
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 plus
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 count
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hash
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 generateMatches
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
1 / 1
17
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 Iterator;
10use UnexpectedValueException;
11use Wikimedia\CSS\Objects\ComponentValueList;
12use Wikimedia\CSS\Objects\Token;
13
14/**
15 * Matcher that matches a sub-Matcher a certain number of times
16 * ("?", "*", "+", "#", "{A,B}" multipliers)
17 * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#component-multipliers
18 */
19class Quantifier extends Matcher {
20    /** @var Matcher */
21    protected $matcher;
22
23    /** @var int */
24    protected $min;
25
26    /** @var int */
27    protected $max;
28
29    /** @var bool Whether matches are comma-separated */
30    protected $commas;
31
32    /**
33     * @param Matcher $matcher
34     * @param int|float $min Minimum number of matches
35     * @param int|float $max Maximum number of matches
36     * @param bool $commas Whether matches are comma-separated
37     */
38    public function __construct( Matcher $matcher, $min, $max, $commas ) {
39        $this->matcher = $matcher;
40        $this->min = $min;
41        $this->max = $max;
42        $this->commas = (bool)$commas;
43    }
44
45    /**
46     * Implements "?": 0 or 1 matches
47     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#mult-opt
48     * @param Matcher $matcher
49     * @return static
50     */
51    public static function optional( Matcher $matcher ) {
52        return new static( $matcher, 0, 1, false );
53    }
54
55    /**
56     * Implements "*": 0 or more matches
57     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#mult-zero-plus
58     * @param Matcher $matcher
59     * @return static
60     */
61    public static function star( Matcher $matcher ) {
62        return new static( $matcher, 0, INF, false );
63    }
64
65    /**
66     * Implements "+": 1 or more matches
67     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#mult-one-plus
68     * @param Matcher $matcher
69     * @return static
70     */
71    public static function plus( Matcher $matcher ) {
72        return new static( $matcher, 1, INF, false );
73    }
74
75    /**
76     * Implements "{A,B}": Between A and B matches
77     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#mult-num-range
78     * @param Matcher $matcher
79     * @param int|float $min Minimum number of matches
80     * @param int|float $max Maximum number of matches
81     * @return static
82     */
83    public static function count( Matcher $matcher, $min, $max ) {
84        return new static( $matcher, $min, $max, false );
85    }
86
87    /**
88     * Implements "#" and "#{A,B}": Between A and B matches, comma-separated
89     * @see https://www.w3.org/TR/2019/CR-css-values-3-20190606/#mult-comma
90     * @param Matcher $matcher
91     * @param int|float $min Minimum number of matches
92     * @param int|float $max Maximum number of matches
93     * @return static
94     */
95    public static function hash( Matcher $matcher, $min = 1, $max = INF ) {
96        return new static( $matcher, $min, $max, true );
97    }
98
99    /** @inheritDoc */
100    protected function generateMatches( ComponentValueList $values, $start, array $options ) {
101        $used = [];
102
103        // Maintain a stack of matches for backtracking purposes.
104        $stack = [
105            [
106                new GrammarMatch( $values, $start, 0 ),
107                $this->matcher->generateMatches( $values, $start, $options )
108            ]
109        ];
110        do {
111            /** @var $lastMatch GrammarMatch */
112            /** @var $iter Iterator<GrammarMatch> */
113            [ $lastMatch, $iter ] = $stack[count( $stack ) - 1];
114
115            // If the top of the stack has no more matches, pop it, maybe
116            // yield the last matched position, and loop.
117            if ( !$iter->valid() ) {
118                array_pop( $stack );
119                $ct = count( $stack );
120                $pos = $lastMatch->getNext();
121                if ( $ct >= $this->min && $ct <= $this->max ) {
122                    $newMatch = $this->makeMatch( $values, $start, $pos, $lastMatch, $stack );
123                    $mid = $newMatch->getUniqueID();
124                    if ( !isset( $used[$mid] ) ) {
125                        $used[$mid] = 1;
126                        yield $newMatch;
127                    }
128                }
129                continue;
130            }
131
132            // Find the next match for the current top of the stack.
133            $match = $iter->current();
134            $iter->next();
135
136            // Quantifiers don't work well when the quantified thing can be empty.
137            if ( $match->getLength() === 0 ) {
138                throw new UnexpectedValueException( 'Empty match in quantifier!' );
139            }
140
141            $nextFrom = $match->getNext();
142
143            // There can only be more matches after this one if we haven't
144            // reached our maximum yet.
145            $canBeMore = count( $stack ) < $this->max;
146
147            // Commas are slightly tricky:
148            // 1. If there is a following comma, start the next Matcher after it.
149            // 2. If not, there can't be any more Matchers following.
150            // And in either case optional whitespace is always allowed.
151            if ( $this->commas ) {
152                $n = $nextFrom;
153                if ( isset( $values[$n] ) && $values[$n] instanceof Token &&
154                    // @phan-suppress-next-line PhanNonClassMethodCall False positive
155                    $values[$n]->type() === Token::T_WHITESPACE
156                ) {
157                    $n = $this->next( $values, $n, [ 'skip-whitespace' => true ] + $options );
158                }
159                if ( isset( $values[$n] ) && $values[$n] instanceof Token &&
160                    // @phan-suppress-next-line PhanNonClassMethodCall False positive
161                    $values[$n]->type() === Token::T_COMMA
162                ) {
163                    $nextFrom = $this->next( $values, $n, [ 'skip-whitespace' => true ] + $options );
164                } else {
165                    $canBeMore = false;
166                }
167            }
168
169            // If there can be more matches, push another one onto the stack
170            // and try it. Otherwise, yield and continue with the current match.
171            if ( $canBeMore ) {
172                $stack[] = [ $match, $this->matcher->generateMatches( $values, $nextFrom, $options ) ];
173            } else {
174                $ct = count( $stack );
175                $pos = $match->getNext();
176                if ( $ct >= $this->min && $ct <= $this->max ) {
177                    $newMatch = $this->makeMatch( $values, $start, $pos, $match, $stack );
178                    $mid = $newMatch->getUniqueID();
179                    if ( !isset( $used[$mid] ) ) {
180                        $used[$mid] = 1;
181                        yield $newMatch;
182                    }
183                }
184            }
185        } while ( $stack );
186    }
187}