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

MyIntervals / PHP-CSS-Parser / 21341414301

25 Jan 2026 11:24PM UTC coverage: 67.035% (-4.3%) from 71.315%
21341414301

Pull #1471

github

web-flow
Merge a93a1bec4 into 416f6a7fe
Pull Request #1471: [TASK] Add `SelectorComponent` interface and classes

16 of 130 new or added lines in 3 files covered. (12.31%)

1 existing line in 1 file now uncovered.

1397 of 2084 relevant lines covered (67.03%)

28.94 hits per line

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

0.0
/src/Property/Selector/CompoundSelector.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Sabberworm\CSS\Property\Selector;
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\Renderable;
12

13
use function Safe\preg_match;
14

15
/**
16
 * Class representing a CSS compound selector.
17
 * Selectors have to be split at combinators (space, `>`, `+`, `~`) before being passed to this class.
18
 */
19
class CompoundSelector implements Renderable, SelectorComponent
20
{
21
    private const SELECTOR_VALIDATION_RX = '/
22
        ^(
23
            (?:
24
                # any sequence of valid unescaped characters, except quotes
25
                [a-zA-Z0-9\\x{00A0}-\\x{FFFF}_^$|*=\\[\\]()\\-\\s\\.:#,]++
26
                |
27
                # one or more escaped characters
28
                (?:\\\\.)++
29
                |
30
                # quoted text, like in `[id="example"]`
31
                (?:
32
                    # opening quote
33
                    ([\'"])
34
                    (?:
35
                        # sequence of characters except closing quote or backslash
36
                        (?:(?!\\g{-1}|\\\\).)++
37
                        |
38
                        # one or more escaped characters
39
                        (?:\\\\.)++
40
                    )*+ # zero or more times
41
                    # closing quote or end (unmatched quote is currently allowed)
42
                    (?:\\g{-1}|$)
43
                )
44
            )*+ # zero or more times
45
            |
46
            # keyframe animation progress percentage (e.g. 50%), untrimmed
47
            \\s*+(?:\\d++%)\\s*+
48
        )$
49
        /ux';
50

51
    /**
52
     * @var string
53
     */
54
    private $value;
55

NEW
56
    public function __construct(string $value)
×
57
    {
NEW
58
        $this->setValue($value);
×
NEW
59
    }
×
60

61
    /**
62
     * @param list<Comment> $comments
63
     *
64
     * @throws UnexpectedTokenException
65
     *
66
     * @internal
67
     */
NEW
68
    public static function parse(ParserState $parserState, array &$comments = []): self
×
69
    {
NEW
70
        $selectorParts = [];
×
NEW
71
        $stringWrapperCharacter = null;
×
NEW
72
        $functionNestingLevel = 0;
×
NEW
73
        static $stopCharacters = [
×
74
            '{',
75
            '}',
76
            '\'',
77
            '"',
78
            '(',
79
            ')',
80
            ',',
81
            ' ',
82
            "\t",
83
            "\n",
84
            "\r",
85
            '>',
86
            '+',
87
            '~',
88
            ParserState::EOF,
89
            '',
90
        ];
91

NEW
92
        $parserState->consumeWhiteSpace($comments);
×
93

NEW
94
        while (true) {
×
NEW
95
            $selectorParts[] = $parserState->consumeUntil($stopCharacters, false, false, $comments);
×
NEW
96
            $nextCharacter = $parserState->peek();
×
NEW
97
            switch ($nextCharacter) {
×
NEW
98
                case '':
×
99
                    // EOF
NEW
100
                    break 2;
×
NEW
101
                case '\'':
×
102
                    // The fallthrough is intentional.
NEW
103
                case '"':
×
NEW
104
                    if (!\is_string($stringWrapperCharacter)) {
×
NEW
105
                        $stringWrapperCharacter = $nextCharacter;
×
NEW
106
                    } elseif ($stringWrapperCharacter === $nextCharacter) {
×
NEW
107
                        if (\substr(\end($selectorParts), -1) !== '\\') {
×
NEW
108
                            $stringWrapperCharacter = null;
×
109
                        }
110
                    }
NEW
111
                    break;
×
NEW
112
                case '(':
×
NEW
113
                    if (!\is_string($stringWrapperCharacter)) {
×
NEW
114
                        ++$functionNestingLevel;
×
115
                    }
NEW
116
                    break;
×
NEW
117
                case ')':
×
NEW
118
                    if (!\is_string($stringWrapperCharacter)) {
×
NEW
119
                        if ($functionNestingLevel <= 0) {
×
NEW
120
                            throw new UnexpectedTokenException(
×
NEW
121
                                'anything but',
×
NEW
122
                                ')',
×
NEW
123
                                'literal',
×
NEW
124
                                $parserState->currentLine()
×
125
                            );
126
                        }
NEW
127
                        --$functionNestingLevel;
×
128
                    }
NEW
129
                    break;
×
NEW
130
                case '{':
×
131
                    // The fallthrough is intentional.
NEW
132
                case '}':
×
NEW
133
                    if (!\is_string($stringWrapperCharacter)) {
×
NEW
134
                        break 2;
×
135
                    }
NEW
136
                    break;
×
NEW
137
                case ',':
×
138
                    // The fallthrough is intentional.
NEW
139
                case ' ':
×
140
                    // The fallthrough is intentional.
NEW
141
                case "\t":
×
142
                    // The fallthrough is intentional.
NEW
143
                case "\n":
×
144
                    // The fallthrough is intentional.
NEW
145
                case "\r":
×
146
                    // The fallthrough is intentional.
NEW
147
                case '>':
×
148
                    // The fallthrough is intentional.
NEW
149
                case '+':
×
150
                    // The fallthrough is intentional.
NEW
151
                case '~':
×
NEW
152
                    if (!\is_string($stringWrapperCharacter) && $functionNestingLevel === 0) {
×
NEW
153
                        break 2;
×
154
                    }
NEW
155
                    break;
×
156
            }
NEW
157
            $selectorParts[] = $parserState->consume(1);
×
158
        }
159

NEW
160
        if ($functionNestingLevel !== 0) {
×
NEW
161
            throw new UnexpectedTokenException(')', $nextCharacter, 'literal', $parserState->currentLine());
×
162
        }
NEW
163
        if (\is_string($stringWrapperCharacter)) {
×
NEW
164
            throw new UnexpectedTokenException(
×
NEW
165
                $stringWrapperCharacter,
×
166
                $nextCharacter,
NEW
167
                'literal',
×
NEW
168
                $parserState->currentLine()
×
169
            );
170
        }
171

NEW
172
        $value = \trim(\implode('', $selectorParts));
×
NEW
173
        if ($value === '') {
×
NEW
174
            throw new UnexpectedTokenException('selector', $nextCharacter, 'literal', $parserState->currentLine());
×
175
        }
NEW
176
        if (!self::isValid($value)) {
×
NEW
177
            throw new UnexpectedTokenException(
×
NEW
178
                "Selector did not match '" . self::SELECTOR_VALIDATION_RX . "'.",
×
179
                $value,
NEW
180
                'custom',
×
NEW
181
                $parserState->currentLine()
×
182
            );
183
        }
184

NEW
185
        return new self($value);
×
186
    }
187

NEW
188
    public function getValue(): string
×
189
    {
NEW
190
        return $this->value;
×
191
    }
192

NEW
193
    public function setValue(string $value): void
×
194
    {
NEW
195
        $this->value = \trim($value);
×
NEW
196
    }
×
197

198
    /**
199
     * @return int<0, max>
200
     */
NEW
201
    public function getSpecificity(): int
×
202
    {
NEW
203
        return SpecificityCalculator::calculate($this->value);
×
204
    }
205

NEW
206
    public function render(OutputFormat $outputFormat): string
×
207
    {
NEW
208
        return $this->getValue();
×
209
    }
210

211
    /**
212
     * @return array<string, bool|int|float|string|array<mixed>|null>
213
     *
214
     * @internal
215
     */
NEW
216
    public function getArrayRepresentation(): array
×
217
    {
NEW
218
        throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`');
×
219
    }
220

NEW
221
    private static function isValid(string $value): bool
×
222
    {
NEW
223
        $numberOfMatches = preg_match(self::SELECTOR_VALIDATION_RX, $value);
×
224

NEW
225
        return $numberOfMatches === 1;
×
226
    }
227
}
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