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

MyIntervals / PHP-CSS-Parser / 21410081995

27 Jan 2026 06:56PM UTC coverage: 70.488% (-0.8%) from 71.315%
21410081995

Pull #1484

github

web-flow
Merge 2332f66fa into 96410045c
Pull Request #1484: Remove `thecodingmachine/safe` dependency (2)

21 of 60 new or added lines in 8 files covered. (35.0%)

5 existing lines in 3 files now uncovered.

1445 of 2050 relevant lines covered (70.49%)

30.36 hits per line

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

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

50
    /**
51
     * @var string
52
     */
53
    private $selector;
54

55
    /**
56
     * @internal since V8.8.0
57
     */
58
    public static function isValid(string $selector): bool
96✔
59
    {
60
        // Note: We need to use `static::` here as the constant is overridden in the `KeyframeSelector` class.
61
        /** @phpstan-ignore theCodingMachineSafe.function */
62
        $numberOfMatches = \preg_match(static::SELECTOR_VALIDATION_RX, $selector);
96✔
63
        if ($numberOfMatches === false) {
96✔
NEW
64
            throw new \RuntimeException('Unexpected error');
×
65
        }
66

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

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

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

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

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

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

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

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

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

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

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

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

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

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