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

MyIntervals / PHP-CSS-Parser / 29003399430

09 Jul 2026 08:02AM UTC coverage: 71.517% (-1.1%) from 72.604%
29003399430

Pull #1484

github

web-flow
Merge 3a5543ec6 into 3316f7374
Pull Request #1484: Remove `thecodingmachine/safe` dependency

25 of 78 new or added lines in 12 files covered. (32.05%)

7 existing lines in 5 files now uncovered.

1622 of 2268 relevant lines covered (71.52%)

37.55 hits per line

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

98.55
/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\Combinator;
12
use Sabberworm\CSS\Property\Selector\Component;
13
use Sabberworm\CSS\Property\Selector\CompoundSelector;
14
use Sabberworm\CSS\Renderable;
15
use Sabberworm\CSS\Settings;
16
use Sabberworm\CSS\ShortClassNameProvider;
17

18
/**
19
 * Class representing a single CSS selector. Selectors have to be split by the comma prior to being passed into this
20
 * class.
21
 */
22
class Selector implements Renderable
23
{
24
    use ShortClassNameProvider;
25

26
    /**
27
     * @internal since 8.5.2
28
     */
29
    public const SELECTOR_VALIDATION_RX = '/
30
        ^(
31
            # not whitespace only
32
            (?!\\s*+$)
33
            (?:
34
                # any sequence of valid unescaped characters, except quotes
35
                [a-zA-Z0-9\\x{00A0}-\\x{FFFF}_^$|*=~\\[\\]()\\-\\s\\.:#+>,]++
36
                |
37
                # one or more escaped characters
38
                (?:\\\\.)++
39
                |
40
                # quoted text, like in `[id="example"]`
41
                (?:
42
                    # opening quote
43
                    ([\'"])
44
                    (?:
45
                        # sequence of characters except closing quote or backslash
46
                        (?:(?!\\g{-1}|\\\\).)++
47
                        |
48
                        # one or more escaped characters
49
                        (?:\\\\.)++
50
                    )*+ # zero or more times
51
                    # closing quote or end (unmatched quote is currently allowed)
52
                    (?:\\g{-1}|$)
53
                )
54
            )*+ # zero or more times
55
        )$
56
        /ux';
57

58
    /**
59
     * @var non-empty-list<Component>
60
     */
61
    private $components;
62

63
    /**
64
     * @internal since V8.8.0
65
     */
66
    public static function isValid(string $selector): bool
35✔
67
    {
68
        // Note: We need to use `static::` here as the constant is overridden in the `KeyframeSelector` class.
69
        /** @phpstan-ignore theCodingMachineSafe.function */
70
        $numberOfMatches = \preg_match(static::SELECTOR_VALIDATION_RX, $selector);
35✔
71
        if ($numberOfMatches === false) {
35✔
NEW
72
            throw new \RuntimeException('Unexpected error');
×
73
        }
74

75
        return $numberOfMatches === 1;
35✔
76
    }
77

78
    /**
79
     * @param non-empty-string|non-empty-list<Component> $selector
80
     *        Providing a string is deprecated in version 9.2 and will not work from v10.0
81
     *
82
     * @throws UnexpectedTokenException if the selector is not valid
83
     */
84
    final public function __construct($selector)
172✔
85
    {
86
        if (\is_string($selector)) {
172✔
87
            $this->setSelector($selector);
72✔
88
        } else {
89
            $this->setComponents($selector);
100✔
90
        }
91
    }
158✔
92

93
    /**
94
     * @param list<Comment> $comments
95
     *
96
     * @return non-empty-list<Component>
97
     *
98
     * @throws UnexpectedTokenException
99
     */
100
    private static function parseComponents(ParserState $parserState, array &$comments = []): array
174✔
101
    {
102
        // Whitespace is a descendent combinator, not allowed around a compound selector.
103
        // (It is allowed within, e.g. as part of a string or within a function like `:not()`.)
104
        // Gobble any up now to get a clean start.
105
        $parserState->consumeWhiteSpace($comments);
174✔
106

107
        $selectorParts = [];
174✔
108
        while (true) {
174✔
109
            try {
110
                $selectorParts[] = CompoundSelector::parse($parserState, $comments);
174✔
111
            } catch (UnexpectedTokenException $e) {
42✔
112
                if ($selectorParts !== [] && \end($selectorParts)->getValue() === ' ') {
42✔
113
                    // The whitespace was not a descendent combinator, and was, in fact, arbitrary,
114
                    // after the end of the selector.  Discard it.
115
                    \array_pop($selectorParts);
3✔
116
                    break;
3✔
117
                } else {
118
                    throw $e;
39✔
119
                }
120
            }
121
            try {
122
                $selectorParts[] = Combinator::parse($parserState, $comments);
148✔
123
            } catch (UnexpectedTokenException $e) {
146✔
124
                // End of selector has been reached.
125
                break;
146✔
126
            }
127
        }
128

129
        return $selectorParts;
148✔
130
    }
131

132
    /**
133
     * @param list<Comment> $comments
134
     *
135
     * @throws UnexpectedTokenException
136
     *
137
     * @internal
138
     */
139
    public static function parse(ParserState $parserState, array &$comments = []): self
102✔
140
    {
141
        $selectorParts = self::parseComponents($parserState, $comments);
102✔
142

143
        // Check that the selector has been fully parsed:
144
        if (!\in_array($parserState->peek(), ['{', '}', ',', ''], true)) {
89✔
145
            throw new UnexpectedTokenException(
1✔
146
                '`,`, `{`, `}` or EOF',
1✔
147
                $parserState->peek(5),
1✔
148
                'literal',
1✔
149
                $parserState->currentLine()
1✔
150
            );
151
        }
152

153
        return new static($selectorParts);
88✔
154
    }
155

156
    /**
157
     * @return non-empty-list<Component>
158
     */
159
    public function getComponents(): array
4✔
160
    {
161
        return $this->components;
4✔
162
    }
163

164
    /**
165
     * @param non-empty-list<Component> $components
166
     *        This should be an alternating sequence of `CompoundSelector` and `Combinator`, starting and ending with a
167
     *        `CompoundSelector`, and may be a single `CompoundSelector`.
168
     */
169
    public function setComponents(array $components): self
100✔
170
    {
171
        $this->components = $components;
100✔
172

173
        return $this;
100✔
174
    }
175

176
    /**
177
     * @return non-empty-string
178
     *
179
     * @deprecated in version 9.2, will be removed in v10.0.  Use either `getComponents()` or `render()` instead.
180
     */
181
    public function getSelector(): string
41✔
182
    {
183
        return $this->render(new OutputFormat());
41✔
184
    }
185

186
    /**
187
     * @param non-empty-string $selector
188
     *
189
     * @throws UnexpectedTokenException if the selector is not valid
190
     *
191
     * @deprecated in version 9.2, will be removed in v10.0.  Use `setComponents()` instead.
192
     */
193
    public function setSelector(string $selector): void
72✔
194
    {
195
        $parserState = new ParserState($selector, Settings::create());
72✔
196

197
        $components = self::parseComponents($parserState);
72✔
198

199
        // Check that the selector has been fully parsed:
200
        if (!$parserState->isEnd()) {
59✔
201
            throw new UnexpectedTokenException(
2✔
202
                'EOF',
2✔
203
                $parserState->peek(5),
2✔
204
                'literal'
2✔
205
            );
206
        }
207

208
        $this->components = $components;
58✔
209
    }
58✔
210

211
    /**
212
     * @return int<0, max>
213
     */
214
    public function getSpecificity(): int
32✔
215
    {
216
        return \array_sum(
32✔
217
            \array_map(
32✔
218
                static function (Component $component): int {
219
                    return $component->getSpecificity();
32✔
220
                },
32✔
221
                $this->components
32✔
222
            )
223
        );
224
    }
225

226
    public function render(OutputFormat $outputFormat): string
45✔
227
    {
228
        return \implode(
45✔
229
            '',
45✔
230
            \array_map(
45✔
231
                static function (Component $component) use ($outputFormat): string {
232
                    return $component->render($outputFormat);
45✔
233
                },
45✔
234
                $this->components
45✔
235
            )
236
        );
237
    }
238

239
    /**
240
     * @return array<string, bool|int|float|string|array<mixed>|null>
241
     *
242
     * @internal
243
     */
244
    public function getArrayRepresentation(): array
8✔
245
    {
246
        return [
247
            'class' => $this->getShortClassName(),
8✔
248
            'components' => \array_map(
8✔
249
                static function (Component $component): array {
250
                    return $component->getArrayRepresentation();
8✔
251
                },
8✔
252
                $this->components
8✔
253
            ),
254
        ];
255
    }
256
}
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