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

MyIntervals / PHP-CSS-Parser / 21233754432

22 Jan 2026 02:28AM UTC coverage: 68.186% (-2.6%) from 70.819%
21233754432

Pull #1470

github

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

6 of 57 new or added lines in 2 files covered. (10.53%)

3 existing lines in 1 file now uncovered.

1361 of 1996 relevant lines covered (68.19%)

26.26 hits per line

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

27.14
/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
12✔
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);
12✔
65

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

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

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

NEW
88
        while (true) {
×
NEW
89
            $selectorParts[] = $parserState->consumeUntil($stopCharacters, false, false, $comments);
×
NEW
90
            $nextCharacter = $parserState->peek();
×
NEW
91
            switch ($nextCharacter) {
×
NEW
92
                case '\'':
×
93
                    // The fallthrough is intentional.
NEW
94
                case '"':
×
NEW
95
                    if (!\is_string($stringWrapperCharacter)) {
×
NEW
96
                        $stringWrapperCharacter = $nextCharacter;
×
NEW
97
                    } elseif ($stringWrapperCharacter === $nextCharacter) {
×
NEW
98
                        if (\substr(\end($selectorParts), -1) !== '\\') {
×
NEW
99
                            $stringWrapperCharacter = null;
×
100
                        }
101
                    }
NEW
102
                    break;
×
NEW
103
                case '(':
×
NEW
104
                    if (!\is_string($stringWrapperCharacter)) {
×
NEW
105
                        ++$functionNestingLevel;
×
106
                    }
NEW
107
                    break;
×
NEW
108
                case ')':
×
NEW
109
                    if (!\is_string($stringWrapperCharacter)) {
×
NEW
110
                        if ($functionNestingLevel <= 0) {
×
NEW
111
                            throw new UnexpectedTokenException(
×
NEW
112
                                'anything but',
×
NEW
113
                                ')',
×
NEW
114
                                'literal',
×
NEW
115
                                $parserState->currentLine()
×
116
                            );
117
                        }
NEW
118
                        --$functionNestingLevel;
×
119
                    }
NEW
120
                    break;
×
NEW
121
                case '{':
×
122
                    // The fallthrough is intentional.
NEW
123
                case '}':
×
NEW
124
                    if (!\is_string($stringWrapperCharacter)) {
×
NEW
125
                        break 2;
×
126
                    }
NEW
127
                    break;
×
NEW
128
                case ',':
×
NEW
129
                    if (!\is_string($stringWrapperCharacter) && $functionNestingLevel === 0) {
×
NEW
130
                        break 2;
×
131
                    }
NEW
132
                    break;
×
133
            }
NEW
134
            $selectorParts[] = $parserState->consume(1);
×
135
        }
136

NEW
137
        if ($functionNestingLevel !== 0) {
×
NEW
138
            throw new UnexpectedTokenException(')', $nextCharacter, 'literal', $parserState->currentLine());
×
139
        }
140

NEW
141
        $selector = \trim(\implode('', $selectorParts));
×
NEW
142
        if ($selector === '') {
×
NEW
143
            throw new UnexpectedTokenException('selector', $nextCharacter, 'literal', $parserState->currentLine());
×
144
        }
NEW
145
        if (!self::isValid($selector)) {
×
NEW
146
            throw new UnexpectedTokenException(
×
NEW
147
                "Selector did not match '" . static::SELECTOR_VALIDATION_RX . "'.",
×
148
                $selector,
NEW
149
                'custom'
×
150
            );
151
        }
152

NEW
153
        return new static($selector);
×
154
    }
155

156
    public function getSelector(): string
10✔
157
    {
158
        return $this->selector;
10✔
159
    }
160

161
    public function setSelector(string $selector): void
31✔
162
    {
163
        $selector = \trim($selector);
31✔
164

165
        $hasAttribute = \strpos($selector, '[') !== false;
31✔
166

167
        // Whitespace can't be adjusted within an attribute selector, as it would change its meaning
168
        $this->selector = !$hasAttribute ? preg_replace('/\\s++/', ' ', $selector) : $selector;
31✔
169
    }
31✔
170

171
    /**
172
     * @return int<0, max>
173
     */
174
    public function getSpecificity(): int
18✔
175
    {
176
        return SpecificityCalculator::calculate($this->selector);
18✔
177
    }
178

179
    public function render(OutputFormat $outputFormat): string
4✔
180
    {
181
        return $this->getSelector();
4✔
182
    }
183

184
    /**
185
     * @return array<string, bool|int|float|string|array<mixed>|null>
186
     *
187
     * @internal
188
     */
189
    public function getArrayRepresentation(): array
2✔
190
    {
191
        throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`');
2✔
192
    }
193
}
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