Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
Matcher
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
9 / 9
28
100.00% covered (success)
100.00%
1 / 1
 create
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 capture
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 matchAgainst
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 collectSignificantWhitespace
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 markSignificantWhitespace
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 getDefaultOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setDefaultOptions
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 next
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 makeMatch
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 generateMatches
n/a
0 / 0
n/a
0 / 0
0
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 Wikimedia\CSS\Objects\ComponentValueList;
13use Wikimedia\CSS\Objects\CSSFunction;
14use Wikimedia\CSS\Objects\SimpleBlock;
15use Wikimedia\CSS\Objects\Token;
16
17/**
18 * Base class for grammar matchers.
19 *
20 * The [CSS Syntax Level 3][SYN3] and [Values Level 4][VAL4] specifications use
21 * a mostly context-free grammar to define what things like selectors and
22 * property values look like. The Matcher classes allow for constructing an
23 * object that will determine whether a ComponentValueList actually matches
24 * this grammar.
25 *
26 * [SYN3]: https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/
27 * [VAL4]: https://www.w3.org/TR/2024/WD-css-values-4-20240312/
28 */
29abstract class Matcher {
30
31    /** @var string|null Name to set on GrammarMatch objects */
32    protected $captureName = null;
33
34    /**
35     * @var array Default options for self::matchAgainst()
36     *  - skip-whitespace: (bool) Allow whitespace in between any two tokens
37     *  - nonterminal: (bool) Don't require the whole of $values is matched
38     *  - mark-significance: (bool) On a successful match, replace T_WHITESPACE
39     *    tokens as necessary to indicate significant whitespace.
40     */
41    protected $defaultOptions = [
42        'skip-whitespace' => true,
43        'nonterminal' => false,
44        'mark-significance' => false,
45    ];
46
47    /**
48     * Create an instance.
49     * @param mixed ...$args See static::__construct()
50     * @return static
51     */
52    public static function create( ...$args ) {
53        // @phan-suppress-next-line PhanParamTooManyUnpack,PhanTypeInstantiateAbstractStatic
54        return new static( ...$args );
55    }
56
57    /**
58     * Return a copy of this matcher that will capture its matches
59     *
60     * A "capturing" Matcher will produce GrammarMatches that return a value from
61     * the GrammarMatch::getName() method. The GrammarMatch::getCapturedMatches()
62     * method may be used to retrieve them from the top-level GrammarMatch.
63     *
64     * The concept is similar to capturing groups in PCRE and other regex
65     * languages.
66     *
67     * @param string|null $captureName Name to apply to captured GrammarMatch objects
68     * @return static
69     */
70    public function capture( $captureName ) {
71        $ret = clone $this;
72        $ret->captureName = $captureName;
73        return $ret;
74    }
75
76    /**
77     * Match against a list of ComponentValues
78     * @param ComponentValueList $values
79     * @param array $options Matching options, see self::$defaultOptions
80     * @return GrammarMatch|null
81     */
82    public function matchAgainst( ComponentValueList $values, array $options = [] ) {
83        $options += $this->getDefaultOptions();
84        $start = $this->next( $values, -1, $options );
85        $l = count( $values );
86        foreach ( $this->generateMatches( $values, $start, $options ) as $match ) {
87            if ( $options['nonterminal'] || $match->getNext() === $l ) {
88                if ( $options['mark-significance'] ) {
89                    $significantWS = self::collectSignificantWhitespace( $match );
90                    self::markSignificantWhitespace( $values, $match, $significantWS, $match->getNext() );
91                }
92                return $match;
93            }
94        }
95        return null;
96    }
97
98    /**
99     * Collect any 'significantWhitespace' matches
100     * @param GrammarMatch $match
101     * @param Token[] &$ret
102     * @return Token[]
103     */
104    private static function collectSignificantWhitespace( GrammarMatch $match, &$ret = [] ) {
105        if ( $match->getName() === 'significantWhitespace' ) {
106            $ret = array_merge( $ret, $match->getValues() );
107        }
108        foreach ( $match->getCapturedMatches() as $m ) {
109            self::collectSignificantWhitespace( $m, $ret );
110        }
111        return $ret;
112    }
113
114    /**
115     * Mark whitespace as significant or not
116     * @param ComponentValueList $list
117     * @param GrammarMatch $match
118     * @param Token[] $significantWS
119     * @param int $end
120     */
121    private static function markSignificantWhitespace( $list, $match, $significantWS, $end ) {
122        for ( $i = 0; $i < $end; $i++ ) {
123            $cv = $list[$i];
124            if ( $cv instanceof Token && $cv->type() === Token::T_WHITESPACE ) {
125                $significant = in_array( $cv, $significantWS, true );
126                if ( $significant !== $cv->significant() ) {
127                    $newCv = $cv->copyWithSignificance( $significant );
128                    $match->fixWhitespace( $cv, $newCv );
129                    $list[$i] = $newCv;
130                }
131            } elseif ( $cv instanceof CSSFunction || $cv instanceof SimpleBlock ) {
132                self::markSignificantWhitespace(
133                    $cv->getValue(), $match, $significantWS, count( $cv->getValue() )
134                );
135            }
136        }
137    }
138
139    /**
140     * Fetch the default options for this Matcher
141     * @return array See self::$defaultOptions
142     */
143    public function getDefaultOptions() {
144        return $this->defaultOptions;
145    }
146
147    /**
148     * Set the default options for this Matcher
149     * @param array $options See self::$defaultOptions
150     * @return static $this
151     */
152    public function setDefaultOptions( array $options ) {
153        $this->defaultOptions = $options + $this->defaultOptions;
154        return $this;
155    }
156
157    /**
158     * Find the next ComponentValue in the input, possibly skipping whitespace
159     * @param ComponentValueList $values Input values
160     * @param int $start Current position in the input. May be -1, in which
161     *  case the first position in the input should be returned.
162     * @param array $options See self::$defaultOptions
163     * @return int Next token index
164     */
165    protected function next( ComponentValueList $values, $start, array $options ) {
166        $skipWS = $options['skip-whitespace'];
167
168        $i = $start;
169        $l = count( $values );
170        do {
171            $i++;
172        } while ( $skipWS && $i < $l &&
173            // @phan-suppress-next-line PhanUndeclaredMethod False positive
174            $values[$i] instanceof Token && $values[$i]->type() === Token::T_WHITESPACE
175        );
176        return $i;
177    }
178
179    /**
180     * Create a GrammarMatch
181     * @param ComponentValueList $list
182     * @param int $start
183     * @param int $end First position after the match
184     * @param GrammarMatch|null $submatch Sub-match, for capturing. If $submatch
185     *  itself named it will be kept as a capture in the returned GrammarMatch,
186     *  otherwise its captured matches (if any) as returned by getCapturedMatches()
187     *  will be kept as captures in the returned GrammarMatch.
188     * @param array $stack Stack from which to fetch more submatches for
189     *  capturing (see $submatch). The stack is expected to be an array of
190     *  arrays, with the first element of each subarray being a GrammarMatch.
191     * @return GrammarMatch
192     */
193    protected function makeMatch(
194        ComponentValueList $list, $start, $end, ?GrammarMatch $submatch = null, array $stack = []
195    ) {
196        $matches = array_column( $stack, 0 );
197        $matches[] = $submatch;
198
199        $keptMatches = [];
200        while ( $matches ) {
201            $m = array_shift( $matches );
202            if ( !$m instanceof GrammarMatch ) {
203                // skip it, probably null
204            } elseif ( $m->getName() !== null ) {
205                $keptMatches[] = $m;
206            } elseif ( $m->getCapturedMatches() ) {
207                $matches = array_merge( $m->getCapturedMatches(), $matches );
208            }
209        }
210
211        return new GrammarMatch( $list, $start, $end - $start, $this->captureName, $keptMatches );
212    }
213
214    /**
215     * Match against a list of ComponentValues
216     *
217     * The job of a Matcher is to determine all the ways its particular grammar
218     * fragment can consume ComponentValues starting at a particular location
219     * in the ComponentValueList, represented by returning GrammarMatch objects.
220     * For example, a matcher implementing `IDENT*` at a starting position where
221     * there are three IDENT tokens in a row would be able to match 0, 1, 2, or
222     * all 3 of those IDENT tokens, and therefore should return an iterator
223     * over that set of GrammarMatch objects.
224     *
225     * Some matchers take other matchers as input, for example `IDENT*` is
226     * probably going to be implemented as a matcher for `*` that repeatedly
227     * applies a matcher for `IDENT`. The `*` matcher would call the `IDENT`
228     * matcher's generateMatches() method directly.
229     *
230     * Most Matchers implement this method as a generator to not build up
231     * the full set of results when it's reasonably likely the caller is going
232     * to terminate early.
233     *
234     * @param ComponentValueList $values
235     * @param int $start Starting position in $values
236     * @param array $options See self::$defaultOptions.
237     *  Always use the options passed in, don't use $this->defaultOptions yourself.
238     * @return Iterator<GrammarMatch> Iterates over the set of GrammarMatch
239     *  objects defining all the ways this matcher can match.
240     */
241    abstract protected function generateMatches( ComponentValueList $values, $start, array $options );
242}