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

MyIntervals / PHP-CSS-Parser / 21259387344

22 Jan 2026 06:04PM UTC coverage: 70.794% (-0.03%) from 70.819%
21259387344

Pull #1470

github

web-flow
Merge ddff5b97c into bea90126e
Pull Request #1470: [TASK] Add `Selector::parse`

65 of 66 new or added lines in 2 files covered. (98.48%)

3 existing lines in 1 file now uncovered.

1418 of 2003 relevant lines covered (70.79%)

27.6 hits per line

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

98.72
/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
83✔
82
    {
83
        $selectorParts = [];
83✔
84
        $stringWrapperCharacter = null;
83✔
85
        $functionNestingLevel = 0;
83✔
86
        static $stopCharacters = ['{', '}', '\'', '"', '(', ')', ',', ParserState::EOF, ''];
83✔
87

88
        while (true) {
83✔
89
            $selectorParts[] = $parserState->consumeUntil($stopCharacters, false, false, $comments);
83✔
90
            $nextCharacter = $parserState->peek();
83✔
91
            switch ($nextCharacter) {
83✔
92
                case '':
83✔
93
                    // EOF
94
                    break 2;
34✔
95
                case '\'':
58✔
96
                    // The fallthrough is intentional.
97
                case '"':
57✔
98
                    if (!\is_string($stringWrapperCharacter)) {
10✔
99
                        $stringWrapperCharacter = $nextCharacter;
10✔
100
                    } elseif ($stringWrapperCharacter === $nextCharacter) {
8✔
101
                        if (\substr(\end($selectorParts), -1) !== '\\') {
8✔
102
                            $stringWrapperCharacter = null;
8✔
103
                        }
104
                    }
105
                    break;
10✔
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
                    // The fallthrough is intentional.
126
                case '}':
27✔
127
                    if (!\is_string($stringWrapperCharacter)) {
50✔
128
                        break 2;
48✔
129
                    }
130
                    break;
8✔
131
                case ',':
14✔
132
                    if (!\is_string($stringWrapperCharacter) && $functionNestingLevel === 0) {
14✔
NEW
133
                        break 2;
×
134
                    }
135
                    break;
14✔
136
            }
137
            $selectorParts[] = $parserState->consume(1);
21✔
138
        }
139

140
        if ($functionNestingLevel !== 0) {
82✔
141
            throw new UnexpectedTokenException(')', $nextCharacter, 'literal', $parserState->currentLine());
1✔
142
        }
143
        if (\is_string($stringWrapperCharacter)) {
81✔
144
            throw new UnexpectedTokenException(
2✔
145
                $stringWrapperCharacter,
2✔
146
                $nextCharacter,
147
                'literal',
2✔
148
                $parserState->currentLine()
2✔
149
            );
150
        }
151

152
        $selector = \trim(\implode('', $selectorParts));
79✔
153
        if ($selector === '') {
79✔
154
            throw new UnexpectedTokenException('selector', $nextCharacter, 'literal', $parserState->currentLine());
2✔
155
        }
156
        if (!self::isValid($selector)) {
77✔
157
            throw new UnexpectedTokenException(
3✔
158
                "Selector did not match '" . static::SELECTOR_VALIDATION_RX . "'.",
3✔
159
                $selector,
160
                'custom',
3✔
161
                $parserState->currentLine()
3✔
162
            );
163
        }
164

165
        return new static($selector);
74✔
166
    }
167

168
    public function getSelector(): string
31✔
169
    {
170
        return $this->selector;
31✔
171
    }
172

173
    public function setSelector(string $selector): void
119✔
174
    {
175
        $selector = \trim($selector);
119✔
176

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

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

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

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

196
    /**
197
     * @return array<string, bool|int|float|string|array<mixed>|null>
198
     *
199
     * @internal
200
     */
201
    public function getArrayRepresentation(): array
2✔
202
    {
203
        throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`');
2✔
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