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

MyIntervals / PHP-CSS-Parser / 21298285830

23 Jan 2026 07:19PM UTC coverage: 71.554% (+0.7%) from 70.819%
21298285830

push

github

web-flow
[TASK] Add `Selector::parse()` (#1475)

The implementation is based on `DeclarationBlock::parseSelector()`, which will
be updated to use this instead in #1470.

Part of #1325.

60 of 63 new or added lines in 1 file covered. (95.24%)

1469 of 2053 relevant lines covered (71.55%)

32.85 hits per line

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

96.3
/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\SpecificityCalculator;
12
use Sabberworm\CSS\Renderable;
13

14
use function Safe\preg_match;
15
use function Safe\preg_replace;
16

17
/**
18
 * Class representing a single CSS selector. Selectors have to be split by the comma prior to being passed into this
19
 * class.
20
 */
21
class Selector implements Renderable
22
{
23
    /**
24
     * @internal since 8.5.2
25
     */
26
    public const SELECTOR_VALIDATION_RX = '/
27
        ^(
28
            (?:
29
                # any sequence of valid unescaped characters, except quotes
30
                [a-zA-Z0-9\\x{00A0}-\\x{FFFF}_^$|*=~\\[\\]()\\-\\s\\.:#+>,]++
31
                |
32
                # one or more escaped characters
33
                (?:\\\\.)++
34
                |
35
                # quoted text, like in `[id="example"]`
36
                (?:
37
                    # opening quote
38
                    ([\'"])
39
                    (?:
40
                        # sequence of characters except closing quote or backslash
41
                        (?:(?!\\g{-1}|\\\\).)++
42
                        |
43
                        # one or more escaped characters
44
                        (?:\\\\.)++
45
                    )*+ # zero or more times
46
                    # closing quote or end (unmatched quote is currently allowed)
47
                    (?:\\g{-1}|$)
48
                )
49
            )*+ # zero or more times
50
        )$
51
        /ux';
52

53
    /**
54
     * @var string
55
     */
56
    private $selector;
57

58
    /**
59
     * @internal since V8.8.0
60
     */
61
    public static function isValid(string $selector): bool
96✔
62
    {
63
        // Note: We need to use `static::` here as the constant is overridden in the `KeyframeSelector` class.
64
        $numberOfMatches = preg_match(static::SELECTOR_VALIDATION_RX, $selector);
96✔
65

66
        return $numberOfMatches === 1;
96✔
67
    }
68

69
    final public function __construct(string $selector)
119✔
70
    {
71
        $this->setSelector($selector);
119✔
72
    }
119✔
73

74
    /**
75
     * @param list<Comment> $comments
76
     *
77
     * @throws UnexpectedTokenException
78
     *
79
     * @internal
80
     */
81
    public static function parse(ParserState $parserState, array &$comments = []): self
88✔
82
    {
83
        $selectorParts = [];
88✔
84
        $stringWrapperCharacter = null;
88✔
85
        $functionNestingLevel = 0;
88✔
86
        static $stopCharacters = ['{', '}', '\'', '"', '(', ')', ',', ParserState::EOF, ''];
88✔
87

88
        while (true) {
88✔
89
            $selectorParts[] = $parserState->consumeUntil($stopCharacters, false, false, $comments);
88✔
90
            $nextCharacter = $parserState->peek();
88✔
91
            switch ($nextCharacter) {
88✔
92
                case '':
88✔
93
                    // EOF
94
                    break 2;
39✔
95
                case '\'':
60✔
96
                    // The fallthrough is intentional.
97
                case '"':
59✔
98
                    if (!\is_string($stringWrapperCharacter)) {
12✔
99
                        $stringWrapperCharacter = $nextCharacter;
12✔
100
                    } elseif ($stringWrapperCharacter === $nextCharacter) {
10✔
101
                        if (\substr(\end($selectorParts), -1) !== '\\') {
8✔
102
                            $stringWrapperCharacter = null;
8✔
103
                        }
104
                    }
105
                    break;
12✔
106
                case '(':
56✔
107
                    if (!\is_string($stringWrapperCharacter)) {
19✔
108
                        ++$functionNestingLevel;
11✔
109
                    }
110
                    break;
19✔
111
                case ')':
55✔
112
                    if (!\is_string($stringWrapperCharacter)) {
19✔
113
                        if ($functionNestingLevel <= 0) {
11✔
114
                            throw new UnexpectedTokenException(
1✔
115
                                'anything but',
1✔
116
                                ')',
1✔
117
                                'literal',
1✔
118
                                $parserState->currentLine()
1✔
119
                            );
120
                        }
121
                        --$functionNestingLevel;
10✔
122
                    }
123
                    break;
18✔
124
                case ',':
53✔
125
                    if (!\is_string($stringWrapperCharacter) && $functionNestingLevel === 0) {
27✔
126
                        break 2;
16✔
127
                    }
128
                    break;
14✔
129
                case '{':
36✔
130
                    // The fallthrough is intentional.
131
                case '}':
22✔
132
                    if (!\is_string($stringWrapperCharacter)) {
36✔
133
                        break 2;
32✔
134
                    }
135
                    break;
8✔
136
                default:
137
                    // This will never happen unless something gets broken in `ParserState`.
NEW
138
                    throw new \UnexpectedValueException(
×
NEW
139
                        'Unexpected character \'' . $nextCharacter
×
NEW
140
                        . '\' returned from `ParserState::peek()` in `Selector::parse()`'
×
141
                    );
142
            }
143
            $selectorParts[] = $parserState->consume(1);
23✔
144
        }
145

146
        if ($functionNestingLevel !== 0) {
87✔
147
            throw new UnexpectedTokenException(')', $nextCharacter, 'literal', $parserState->currentLine());
1✔
148
        }
149
        if (\is_string($stringWrapperCharacter)) {
86✔
150
            throw new UnexpectedTokenException(
4✔
151
                $stringWrapperCharacter,
4✔
152
                $nextCharacter,
153
                'literal',
4✔
154
                $parserState->currentLine()
4✔
155
            );
156
        }
157

158
        $selector = \trim(\implode('', $selectorParts));
82✔
159
        if ($selector === '') {
82✔
160
            throw new UnexpectedTokenException('selector', $nextCharacter, 'literal', $parserState->currentLine());
5✔
161
        }
162
        if (!self::isValid($selector)) {
77✔
163
            throw new UnexpectedTokenException(
3✔
164
                "Selector did not match '" . static::SELECTOR_VALIDATION_RX . "'.",
3✔
165
                $selector,
166
                'custom',
3✔
167
                $parserState->currentLine()
3✔
168
            );
169
        }
170

171
        return new static($selector);
74✔
172
    }
173

174
    public function getSelector(): string
31✔
175
    {
176
        return $this->selector;
31✔
177
    }
178

179
    public function setSelector(string $selector): void
119✔
180
    {
181
        $selector = \trim($selector);
119✔
182

183
        $hasAttribute = \strpos($selector, '[') !== false;
119✔
184

185
        // Whitespace can't be adjusted within an attribute selector, as it would change its meaning
186
        $this->selector = !$hasAttribute ? preg_replace('/\\s++/', ' ', $selector) : $selector;
119✔
187
    }
119✔
188

189
    /**
190
     * @return int<0, max>
191
     */
192
    public function getSpecificity(): int
32✔
193
    {
194
        return SpecificityCalculator::calculate($this->selector);
32✔
195
    }
196

197
    public function render(OutputFormat $outputFormat): string
4✔
198
    {
199
        return $this->getSelector();
4✔
200
    }
201

202
    /**
203
     * @return array<string, bool|int|float|string|array<mixed>|null>
204
     *
205
     * @internal
206
     */
207
    public function getArrayRepresentation(): array
2✔
208
    {
209
        throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`');
2✔
210
    }
211
}
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