Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
2 / 2
CRAP
100.00% covered (success)
100.00%
1 / 1
Juxtaposition
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
2 / 2
12
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 generateMatches
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
11
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\Token;
14use Wikimedia\CSS\Util;
15
16/**
17 * Matcher that groups other matchers (juxtaposition)
18 * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#component-combinators
19 * @see https://www.w3.org/TR/2024/WD-css-values-4-20240312/#comb-comma
20 */
21class Juxtaposition extends Matcher {
22    /** @var Matcher[] */
23    protected $matchers;
24
25    /** @var bool Whether non-empty matches are comma-separated */
26    protected $commas;
27
28    /**
29     * @param Matcher[] $matchers
30     * @param bool $commas Whether matches are comma-separated
31     */
32    public function __construct( array $matchers, $commas = false ) {
33        Util::assertAllInstanceOf( $matchers, Matcher::class, '$matchers' );
34        $this->matchers = $matchers;
35        $this->commas = (bool)$commas;
36    }
37
38    /** @inheritDoc */
39    protected function generateMatches( ComponentValueList $values, $start, array $options ) {
40        $used = [];
41
42        // Match each of our matchers in turn, pushing each one onto a stack as
43        // we process it and popping a match once it's exhausted.
44        $stack = [
45            [
46                new GrammarMatch( $values, $start, 0 ),
47                $start,
48                $this->matchers[0]->generateMatches( $values, $start, $options ),
49                false
50            ]
51        ];
52        do {
53            /** @var $lastEnd int */
54            /** @var $iter Iterator<GrammarMatch> */
55            /** @var $needEmpty bool */
56            [ , $lastEnd, $iter, $needEmpty ] = $stack[count( $stack ) - 1];
57
58            // If the top of the stack has no more matches, pop it and loop.
59            if ( !$iter->valid() ) {
60                array_pop( $stack );
61                continue;
62            }
63
64            // Find the next match for the current top of the stack.
65            $match = $iter->current();
66            $iter->next();
67
68            // In some cases, we can only match if the rest of the pattern
69            // is empty. If we're in that situation, ignore all non-empty
70            // matches.
71            if ( $needEmpty && $match->getLength() !== 0 ) {
72                continue;
73            }
74
75            $thisEnd = $nextFrom = $match->getNext();
76
77            // Dealing with commas is a bit tricky. There are three cases:
78            // 1. If the current match is empty, don't look for a following
79            // comma now and reset $thisEnd to $lastEnd.
80            // 2. If there is a comma following, update $nextFrom to be after
81            // the comma.
82            // 3. If there's no comma following, every subsequent Matcher must
83            // be empty in order for the group as a whole to match, so set
84            // the flag.
85            // Unlike '#', this doesn't specify skipping whitespace around the
86            // commas if the production isn't already skipping whitespace.
87            if ( $this->commas ) {
88                if ( $match->getLength() === 0 ) {
89                    $thisEnd = $lastEnd;
90                } elseif ( isset( $values[$nextFrom] ) && $values[$nextFrom] instanceof Token &&
91                    // @phan-suppress-next-line PhanUndeclaredMethod False positive
92                    $values[$nextFrom]->type() === Token::T_COMMA
93                ) {
94                    $nextFrom = $this->next( $values, $nextFrom, $options );
95                } else {
96                    $needEmpty = true;
97                }
98            }
99
100            // If we ran out of Matchers, yield the final position. Otherwise,
101            // push the next matcher onto the stack.
102            if ( count( $stack ) >= count( $this->matchers ) ) {
103                $newMatch = $this->makeMatch( $values, $start, $thisEnd, $match, $stack );
104                $mid = $newMatch->getUniqueID();
105                if ( !isset( $used[$mid] ) ) {
106                    $used[$mid] = 1;
107                    yield $newMatch;
108                }
109            } else {
110                $stack[] = [
111                    $match,
112                    $thisEnd,
113                    $this->matchers[count( $stack )]->generateMatches( $values, $nextFrom, $options ),
114                    $needEmpty
115                ];
116            }
117        } while ( $stack );
118    }
119}