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

MyIntervals / PHP-CSS-Parser / 21593497458

02 Feb 2026 02:14PM UTC coverage: 71.343% (+0.03%) from 71.315%
21593497458

Pull #1485

github

web-flow
Merge 834f7ae32 into ba1dd905f
Pull Request #1485: Fix Tailwind escaped quotes selector in Selector.php

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

2 existing lines in 1 file now uncovered.

1434 of 2010 relevant lines covered (71.34%)

30.88 hits per line

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

96.39
/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
117✔
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);
117✔
65

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

69
    final public function __construct(string $selector)
131✔
70
    {
71
        $this->setSelector($selector);
131✔
72
    }
131✔
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
100✔
82
    {
83
        $selectorParts = [];
100✔
84
        $stringWrapperCharacter = null;
100✔
85
        $functionNestingLevel = 0;
100✔
86
        static $stopCharacters = ['{', '}', '\'', '"', '(', ')', ',', ParserState::EOF, ''];
100✔
87

88
        while (true) {
100✔
89
            $selectorParts[] = $parserState->consumeUntil($stopCharacters, false, false, $comments);
100✔
90
            $nextCharacter = $parserState->peek();
100✔
91
            switch ($nextCharacter) {
100✔
92
                case '':
100✔
93
                    // EOF
94
                    break 2;
51✔
95
                case '\'':
72✔
96
                    // The fallthrough is intentional.
97
                case '"':
69✔
98
                    $lastPart = \end($selectorParts);
24✔
99
                    $backslashCount = \strspn(\strrev($lastPart), '\\');
24✔
100
                    $quoteIsEscaped = ($backslashCount % 2 === 1);
24✔
101
                    if (!\is_string($stringWrapperCharacter) && !$quoteIsEscaped) {
24✔
102
                        $stringWrapperCharacter = $nextCharacter;
24✔
103
                    } elseif ($stringWrapperCharacter === $nextCharacter && !$quoteIsEscaped) {
22✔
104
                        $stringWrapperCharacter = null;
20✔
105
                    }
106
                    break;
24✔
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`.
UNCOV
139
                    throw new \UnexpectedValueException(
×
UNCOV
140
                        'Unexpected character \'' . $nextCharacter
×
141
                        . '\' returned from `ParserState::peek()` in `Selector::parse()`'
×
142
                    );
143
            }
144
            $selectorParts[] = $parserState->consume(1);
35✔
145
        }
146

147
        if ($functionNestingLevel !== 0) {
99✔
148
            throw new UnexpectedTokenException(')', $nextCharacter, 'literal', $parserState->currentLine());
1✔
149
        }
150
        if (\is_string($stringWrapperCharacter)) {
98✔
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));
94✔
160
        if ($selector === '') {
94✔
161
            throw new UnexpectedTokenException('selector', $nextCharacter, 'literal', $parserState->currentLine());
5✔
162
        }
163
        if (!self::isValid($selector)) {
89✔
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);
86✔
173
    }
174

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

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

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

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

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

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

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