• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

MyIntervals / PHP-CSS-Parser / 21793375014

08 Feb 2026 06:04AM UTC coverage: 72.641% (+0.01%) from 72.628%
21793375014

Pull #1500

github

web-flow
Merge b61bd5041 into e4c7f2ceb
Pull Request #1500: [TASK] Allow construction of 'empty' `Selector`

1 of 1 new or added line in 1 file covered. (100.0%)

1 existing line in 1 file now uncovered.

1524 of 2098 relevant lines covered (72.64%)

34.16 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.04
/src/Property/Selector.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Sabberworm\CSS\Property;
6

7
use Sabberworm\CSS\Comment\Comment;
8
use Sabberworm\CSS\OutputFormat;
9
use Sabberworm\CSS\Parsing\ParserState;
10
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
11
use Sabberworm\CSS\Property\Selector\Combinator;
12
use Sabberworm\CSS\Property\Selector\Component;
13
use Sabberworm\CSS\Property\Selector\CompoundSelector;
14
use Sabberworm\CSS\Property\Selector\SpecificityCalculator;
15
use Sabberworm\CSS\Renderable;
16
use Sabberworm\CSS\ShortClassNameProvider;
17

18
use function Safe\preg_match;
19
use function Safe\preg_replace;
20

21
/**
22
 * Class representing a single CSS selector. Selectors have to be split by the comma prior to being passed into this
23
 * class.
24
 */
25
class Selector implements Renderable
26
{
27
    use ShortClassNameProvider;
28

29
    /**
30
     * @internal since 8.5.2
31
     */
32
    public const SELECTOR_VALIDATION_RX = '/
33
        ^(
34
            (?:
35
                # any sequence of valid unescaped characters, except quotes
36
                [a-zA-Z0-9\\x{00A0}-\\x{FFFF}_^$|*=~\\[\\]()\\-\\s\\.:#+>,]++
37
                |
38
                # one or more escaped characters
39
                (?:\\\\.)++
40
                |
41
                # quoted text, like in `[id="example"]`
42
                (?:
43
                    # opening quote
44
                    ([\'"])
45
                    (?:
46
                        # sequence of characters except closing quote or backslash
47
                        (?:(?!\\g{-1}|\\\\).)++
48
                        |
49
                        # one or more escaped characters
50
                        (?:\\\\.)++
51
                    )*+ # zero or more times
52
                    # closing quote or end (unmatched quote is currently allowed)
53
                    (?:\\g{-1}|$)
54
                )
55
            )*+ # zero or more times
56
        )$
57
        /ux';
58

59
    /**
60
     * @var string
61
     */
62
    private $selector = '';
63

64
    /**
65
     * @internal since V8.8.0
66
     */
67
    public static function isValid(string $selector): bool
169✔
68
    {
69
        // Note: We need to use `static::` here as the constant is overridden in the `KeyframeSelector` class.
70
        $numberOfMatches = preg_match(static::SELECTOR_VALIDATION_RX, $selector);
169✔
71

72
        return $numberOfMatches === 1;
169✔
73
    }
74

75
    /**
76
     * @throws \UnexpectedValueException if the selector is not valid
77
     */
78
    final public function __construct(string $selector = '')
141✔
79
    {
80
        // Allow construction of empty object for content to be set later via a setter method.
81
        if ($selector !== '') {
141✔
82
            $this->setSelector($selector);
139✔
83
        }
84
    }
138✔
85

86
    /**
87
     * @param list<Comment> $comments
88
     *
89
     * @return list<Component>
90
     *
91
     * @throws UnexpectedTokenException
92
     */
93
    private static function parseComponents(ParserState $parserState, array &$comments = []): array
102✔
94
    {
95
        // Whitespace is a descendent combinator, not allowed around a compound selector.
96
        // (It is allowed within, e.g. as part of a string or within a function like `:not()`.)
97
        // Gobble any up now to get a clean start.
98
        $parserState->consumeWhiteSpace($comments);
102✔
99

100
        $selectorParts = [];
102✔
101
        while (true) {
102✔
102
            try {
103
                $selectorParts[] = CompoundSelector::parse($parserState, $comments);
102✔
104
            } catch (UnexpectedTokenException $e) {
14✔
105
                if ($selectorParts !== [] && \end($selectorParts)->getValue() === ' ') {
14✔
106
                    // The whitespace was not a descendent combinator, and was, in fact, arbitrary,
107
                    // after the end of the selector.  Discard it.
108
                    \array_pop($selectorParts);
1✔
109
                    break;
1✔
110
                } else {
111
                    throw $e;
13✔
112
                }
113
            }
114
            try {
115
                $selectorParts[] = Combinator::parse($parserState, $comments);
89✔
116
            } catch (UnexpectedTokenException $e) {
88✔
117
                // End of selector has been reached.
118
                break;
88✔
119
            }
120
        }
121

122
        return $selectorParts;
89✔
123
    }
124

125
    /**
126
     * @param list<Comment> $comments
127
     *
128
     * @throws UnexpectedTokenException
129
     *
130
     * @internal
131
     */
132
    public static function parse(ParserState $parserState, array &$comments = []): self
102✔
133
    {
134
        $selectorParts = self::parseComponents($parserState, $comments);
102✔
135

136
        // Check that the selector has been fully parsed:
137
        if (!\in_array($parserState->peek(), ['{', '}', ',', ''], true)) {
89✔
138
            throw new UnexpectedTokenException(
1✔
139
                '`,`, `{`, `}` or EOF',
1✔
140
                $parserState->peek(5),
1✔
141
                'literal',
1✔
142
                $parserState->currentLine()
1✔
143
            );
144
        }
145

146
        $selectorString = '';
88✔
147
        foreach ($selectorParts as $selectorPart) {
88✔
148
            $selectorPartValue = $selectorPart->getValue();
88✔
149
            if (\in_array($selectorPartValue, ['>', '+', '~'], true)) {
88✔
UNCOV
150
                $selectorString .= ' ' . $selectorPartValue . ' ';
×
151
            } else {
152
                $selectorString .= $selectorPartValue;
88✔
153
            }
154
        }
155

156
        return new static($selectorString);
88✔
157
    }
158

159
    public function getSelector(): string
47✔
160
    {
161
        return $this->selector;
47✔
162
    }
163

164
    /**
165
     * @throws \UnexpectedValueException if the selector is not valid
166
     */
167
    public function setSelector(string $selector): void
139✔
168
    {
169
        if (!self::isValid($selector)) {
139✔
170
            throw new \UnexpectedValueException("Selector `$selector` is not valid.");
6✔
171
        }
172

173
        $selector = \trim($selector);
136✔
174

175
        $hasAttribute = \strpos($selector, '[') !== false;
136✔
176

177
        // Whitespace can't be adjusted within an attribute selector, as it would change its meaning
178
        $this->selector = !$hasAttribute ? preg_replace('/\\s++/', ' ', $selector) : $selector;
136✔
179
    }
136✔
180

181
    /**
182
     * @return int<0, max>
183
     */
184
    public function getSpecificity(): int
32✔
185
    {
186
        return SpecificityCalculator::calculate($this->selector);
32✔
187
    }
188

189
    public function render(OutputFormat $outputFormat): string
4✔
190
    {
191
        return $this->getSelector();
4✔
192
    }
193

194
    /**
195
     * @return array<string, bool|int|float|string|array<mixed>|null>
196
     *
197
     * @internal
198
     */
199
    public function getArrayRepresentation(): array
2✔
200
    {
201
        return [
202
            'class' => $this->getShortClassName(),
2✔
203
        ];
204
    }
205
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc