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