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

MyIntervals / PHP-CSS-Parser / 20066902487

09 Dec 2025 02:26PM UTC coverage: 62.883% (+0.08%) from 62.804%
20066902487

Pull #1419

github

web-flow
Merge e7a438785 into a5d1172ba
Pull Request #1419: [BUGFIX] Allow commas in attributes in `setSelectors`

5 of 6 new or added lines in 1 file covered. (83.33%)

15 existing lines in 1 file now uncovered.

1191 of 1894 relevant lines covered (62.88%)

30.53 hits per line

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

86.57
/src/RuleSet/DeclarationBlock.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Sabberworm\CSS\RuleSet;
6

7
use Sabberworm\CSS\Comment\Comment;
8
use Sabberworm\CSS\Comment\CommentContainer;
9
use Sabberworm\CSS\CSSElement;
10
use Sabberworm\CSS\CSSList\CSSList;
11
use Sabberworm\CSS\CSSList\CSSListItem;
12
use Sabberworm\CSS\CSSList\KeyFrame;
13
use Sabberworm\CSS\OutputFormat;
14
use Sabberworm\CSS\Parsing\OutputException;
15
use Sabberworm\CSS\Parsing\ParserState;
16
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
17
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
18
use Sabberworm\CSS\Position\Position;
19
use Sabberworm\CSS\Position\Positionable;
20
use Sabberworm\CSS\Property\KeyframeSelector;
21
use Sabberworm\CSS\Property\Selector;
22
use Sabberworm\CSS\Rule\Rule;
23
use Sabberworm\CSS\Settings;
24

25
/**
26
 * This class represents a `RuleSet` constrained by a `Selector`.
27
 *
28
 * It contains an array of selector objects (comma-separated in the CSS) as well as the rules to be applied to the
29
 * matching elements.
30
 *
31
 * Declaration blocks usually appear directly inside a `Document` or another `CSSList` (mostly a `MediaQuery`).
32
 *
33
 * Note that `CSSListItem` extends both `Commentable` and `Renderable`, so those interfaces must also be implemented.
34
 */
35
class DeclarationBlock implements CSSElement, CSSListItem, Positionable, RuleContainer
36
{
37
    use CommentContainer;
38
    use Position;
39

40
    /**
41
     * @var list<Selector>
42
     */
43
    private $selectors = [];
44

45
    /**
46
     * @var RuleSet
47
     */
48
    private $ruleSet;
49

50
    /**
51
     * @param int<1, max>|null $lineNumber
52
     */
53
    public function __construct(?int $lineNumber = null)
690✔
54
    {
55
        $this->ruleSet = new RuleSet($lineNumber);
690✔
56
        $this->setPosition($lineNumber);
690✔
57
    }
690✔
58

59
    /**
60
     * @throws UnexpectedTokenException
61
     * @throws UnexpectedEOFException
62
     *
63
     * @internal since V8.8.0
64
     */
65
    public static function parse(ParserState $parserState, ?CSSList $list = null): ?DeclarationBlock
169✔
66
    {
67
        $comments = [];
169✔
68
        $result = new DeclarationBlock($parserState->currentLine());
169✔
69
        try {
70
            $selectors = self::parseSelectors($parserState, $comments);
169✔
71
            $result->setSelectors($selectors, $list);
165✔
72
            if ($parserState->comes('{')) {
162✔
73
                $parserState->consume(1);
162✔
74
            }
75
        } catch (UnexpectedTokenException $e) {
7✔
76
            if ($parserState->getSettings()->usesLenientParsing()) {
7✔
77
                if (!$parserState->comes('}')) {
7✔
78
                    $parserState->consumeUntil('}', false, true);
5✔
79
                }
80
                return null;
7✔
81
            } else {
82
                throw $e;
×
83
            }
84
        }
85
        $result->setComments($comments);
162✔
86

87
        RuleSet::parseRuleSet($parserState, $result->getRuleSet());
162✔
88

89
        return $result;
162✔
90
    }
91

92
    /**
93
     * @param array<Selector|string>|string $selectors
94
     *
95
     * @throws UnexpectedTokenException
96
     */
97
    public function setSelectors($selectors, ?CSSList $list = null): void
327✔
98
    {
99
        if (\is_array($selectors)) {
327✔
100
            $selectorsToSet = $selectors;
167✔
101
        } else {
102
            // A string of comma-separated selectors requires parsing.
103
            // Parse as if it's the opening part of a rule.
104
            $parserState = new ParserState($selectors . '{', Settings::create());
160✔
105
            $selectorsToSet = self::parseSelectors($parserState);
160✔
106
            $parserState->consume('{'); // throw exception if this is not next
156✔
107
            if (!$parserState->isEnd()) {
156✔
NEW
108
                throw new UnexpectedTokenException('EOF', $parserState->peek(5));
×
109
            }
110
        }
111

112
        // Convert all items to a `Selector` if not already
113
        foreach ($selectorsToSet as $key => $selector) {
323✔
114
            if (!($selector instanceof Selector)) {
323✔
115
                if ($list === null || !($list instanceof KeyFrame)) {
322✔
116
                    if (!Selector::isValid($selector)) {
322✔
117
                        throw new UnexpectedTokenException(
3✔
118
                            "Selector did not match '" . Selector::SELECTOR_VALIDATION_RX . "'.",
3✔
119
                            $selector,
120
                            'custom'
3✔
121
                        );
122
                    }
123
                    $selectorsToSet[$key] = new Selector($selector);
319✔
124
                } else {
UNCOV
125
                    if (!KeyframeSelector::isValid($selector)) {
×
UNCOV
126
                        throw new UnexpectedTokenException(
×
UNCOV
127
                            "Selector did not match '" . KeyframeSelector::SELECTOR_VALIDATION_RX . "'.",
×
128
                            $selector,
UNCOV
129
                            'custom'
×
130
                        );
131
                    }
UNCOV
132
                    $selectorsToSet[$key] = new KeyframeSelector($selector);
×
133
                }
134
            }
135
        }
136

137
        // Discard the keys and reindex the array
138
        $this->selectors = \array_values($selectorsToSet);
320✔
139
    }
320✔
140

141
    /**
142
     * Remove one of the selectors of the block.
143
     *
144
     * @param Selector|string $selectorToRemove
145
     */
UNCOV
146
    public function removeSelector($selectorToRemove): bool
×
147
    {
UNCOV
148
        if ($selectorToRemove instanceof Selector) {
×
UNCOV
149
            $selectorToRemove = $selectorToRemove->getSelector();
×
150
        }
UNCOV
151
        foreach ($this->selectors as $key => $selector) {
×
UNCOV
152
            if ($selector->getSelector() === $selectorToRemove) {
×
UNCOV
153
                unset($this->selectors[$key]);
×
UNCOV
154
                return true;
×
155
            }
156
        }
157
        return false;
×
158
    }
159

160
    /**
161
     * @return list<Selector>
162
     */
163
    public function getSelectors(): array
313✔
164
    {
165
        return $this->selectors;
313✔
166
    }
167

168
    public function getRuleSet(): RuleSet
173✔
169
    {
170
        return $this->ruleSet;
173✔
171
    }
172

173
    /**
174
     * @see RuleSet::addRule()
175
     */
176
    public function addRule(Rule $ruleToAdd, ?Rule $sibling = null): void
215✔
177
    {
178
        $this->ruleSet->addRule($ruleToAdd, $sibling);
215✔
179
    }
215✔
180

181
    /**
182
     * @see RuleSet::getRules()
183
     *
184
     * @return array<int<0, max>, Rule>
185
     */
186
    public function getRules(?string $searchPattern = null): array
177✔
187
    {
188
        return $this->ruleSet->getRules($searchPattern);
177✔
189
    }
190

191
    /**
192
     * @see RuleSet::setRules()
193
     *
194
     * @param array<Rule> $rules
195
     */
196
    public function setRules(array $rules): void
346✔
197
    {
198
        $this->ruleSet->setRules($rules);
346✔
199
    }
346✔
200

201
    /**
202
     * @see RuleSet::getRulesAssoc()
203
     *
204
     * @return array<string, Rule>
205
     */
206
    public function getRulesAssoc(?string $searchPattern = null): array
50✔
207
    {
208
        return $this->ruleSet->getRulesAssoc($searchPattern);
50✔
209
    }
210

211
    /**
212
     * @see RuleSet::removeRule()
213
     */
214
    public function removeRule(Rule $ruleToRemove): void
22✔
215
    {
216
        $this->ruleSet->removeRule($ruleToRemove);
22✔
217
    }
22✔
218

219
    /**
220
     * @see RuleSet::removeMatchingRules()
221
     */
222
    public function removeMatchingRules(string $searchPattern): void
28✔
223
    {
224
        $this->ruleSet->removeMatchingRules($searchPattern);
28✔
225
    }
28✔
226

227
    /**
228
     * @see RuleSet::removeAllRules()
229
     */
230
    public function removeAllRules(): void
4✔
231
    {
232
        $this->ruleSet->removeAllRules();
4✔
233
    }
4✔
234

235
    /**
236
     * @return non-empty-string
237
     *
238
     * @throws OutputException
239
     */
240
    public function render(OutputFormat $outputFormat): string
6✔
241
    {
242
        $formatter = $outputFormat->getFormatter();
6✔
243
        $result = $formatter->comments($this);
6✔
244
        if (\count($this->selectors) === 0) {
6✔
245
            // If all the selectors have been removed, this declaration block becomes invalid
UNCOV
246
            throw new OutputException(
×
UNCOV
247
                'Attempt to print declaration block with missing selector',
×
UNCOV
248
                $this->getLineNumber()
×
249
            );
250
        }
251
        $result .= $outputFormat->getContentBeforeDeclarationBlock();
6✔
252
        $result .= $formatter->implode(
6✔
253
            $formatter->spaceBeforeSelectorSeparator() . ',' . $formatter->spaceAfterSelectorSeparator(),
6✔
254
            $this->selectors
6✔
255
        );
256
        $result .= $outputFormat->getContentAfterDeclarationBlockSelectors();
6✔
257
        $result .= $formatter->spaceBeforeOpeningBrace() . '{';
6✔
258
        $result .= $this->ruleSet->render($outputFormat);
6✔
259
        $result .= '}';
6✔
260
        $result .= $outputFormat->getContentAfterDeclarationBlock();
6✔
261

262
        return $result;
6✔
263
    }
264

265
    /**
266
     * @param list<Comment> $comments
267
     *
268
     * @return list<string>
269
     *
270
     * @throws UnexpectedTokenException
271
     */
272
    private static function parseSelectors(ParserState $parserState, array &$comments = []): array
329✔
273
    {
274
        $selectors = [];
329✔
275
        $selectorParts = [];
329✔
276
        $stringWrapperCharacter = null;
329✔
277
        $functionNestingLevel = 0;
329✔
278
        $consumedNextCharacter = false;
329✔
279
        static $stopCharacters = ['{', '}', '\'', '"', '(', ')', ','];
329✔
280

281
        do {
282
            if (!$consumedNextCharacter) {
329✔
283
                $selectorParts[] = $parserState->consume(1);
329✔
284
            }
285
            $selectorParts[] = $parserState->consumeUntil($stopCharacters, false, false, $comments);
329✔
286
            $nextCharacter = $parserState->peek();
329✔
287
            $consumedNextCharacter = false;
329✔
288
            switch ($nextCharacter) {
329✔
289
                case '\'':
329✔
290
                    // The fallthrough is intentional.
291
                case '"':
329✔
292
                    if (!\is_string($stringWrapperCharacter)) {
92✔
293
                        $stringWrapperCharacter = $nextCharacter;
92✔
294
                    } elseif ($stringWrapperCharacter === $nextCharacter) {
92✔
295
                        if (\substr(\end($selectorParts), -1) !== '\\') {
92✔
296
                            $stringWrapperCharacter = null;
92✔
297
                        }
298
                    }
299
                    break;
92✔
300
                case '(':
329✔
301
                    if (!\is_string($stringWrapperCharacter)) {
174✔
302
                        ++$functionNestingLevel;
98✔
303
                    }
304
                    break;
174✔
305
                case ')':
329✔
306
                    if (!\is_string($stringWrapperCharacter)) {
172✔
307
                        if ($functionNestingLevel <= 0) {
96✔
308
                            throw new UnexpectedTokenException('anything but', ')');
4✔
309
                        }
310
                        --$functionNestingLevel;
94✔
311
                    }
312
                    break;
170✔
313
                case ',':
325✔
314
                    if (!\is_string($stringWrapperCharacter) && $functionNestingLevel === 0) {
294✔
315
                        $selectors[] = \implode('', $selectorParts);
288✔
316
                        $selectorParts = [];
288✔
317
                        $parserState->consume(1);
288✔
318
                        $consumedNextCharacter = true;
288✔
319
                    }
320
                    break;
294✔
321
            }
322
        } while (!\in_array($nextCharacter, ['{', '}'], true) || \is_string($stringWrapperCharacter));
327✔
323

324
        if ($functionNestingLevel !== 0) {
325✔
325
            throw new UnexpectedTokenException(')', $nextCharacter);
4✔
326
        }
327
        $selectors[] = \implode('', $selectorParts); // add final or only selector
321✔
328

329
        return $selectors;
321✔
330
    }
331
}
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