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

MyIntervals / PHP-CSS-Parser / 20184675807

13 Dec 2025 01:31AM UTC coverage: 67.727% (+0.02%) from 67.71%
20184675807

Pull #1425

github

web-flow
Merge c8f3e2371 into a2c978ccb
Pull Request #1425: [BUGFIX] Improve recovery parsing upon a rogue `}`

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

10 existing lines in 1 file now uncovered.

1299 of 1918 relevant lines covered (67.73%)

31.44 hits per line

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

88.11
/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)
704✔
54
    {
55
        $this->ruleSet = new RuleSet($lineNumber);
704✔
56
        $this->setPosition($lineNumber);
704✔
57
    }
704✔
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
181✔
66
    {
67
        $comments = [];
181✔
68
        $result = new DeclarationBlock($parserState->currentLine());
181✔
69
        try {
70
            $selectors = self::parseSelectors($parserState, $comments);
181✔
71
            $result->setSelectors($selectors, $list);
165✔
72
            if ($parserState->comes('{')) {
162✔
73
                $parserState->consume(1);
162✔
74
            }
75
        } catch (UnexpectedTokenException $e) {
19✔
76
            if ($parserState->getSettings()->usesLenientParsing()) {
19✔
77
                if ($parserState->comes('}')) {
19✔
78
                    $parserState->consume(1);
8✔
79
                } else {
80
                    $parserState->consumeUntil('}', false, true);
11✔
81
                }
82
                return null;
19✔
83
            } else {
UNCOV
84
                throw $e;
×
85
            }
86
        }
87
        $result->setComments($comments);
162✔
88

89
        RuleSet::parseRuleSet($parserState, $result->getRuleSet());
162✔
90

91
        return $result;
162✔
92
    }
93

94
    /**
95
     * @param array<Selector|string>|string $selectors
96
     *
97
     * @throws UnexpectedTokenException
98
     */
99
    public function setSelectors($selectors, ?CSSList $list = null): void
329✔
100
    {
101
        if (\is_array($selectors)) {
329✔
102
            $selectorsToSet = $selectors;
167✔
103
        } else {
104
            // A string of comma-separated selectors requires parsing.
105
            // Parse as if it's the opening part of a rule.
106
            try {
107
                $parserState = new ParserState($selectors . '{', Settings::create());
162✔
108
                $selectorsToSet = self::parseSelectors($parserState);
162✔
109
                $parserState->consume('{'); // throw exception if this is not next
158✔
110
                if (!$parserState->isEnd()) {
157✔
111
                    throw new UnexpectedTokenException('EOF', 'more');
157✔
112
                }
113
            } catch (UnexpectedTokenException $exception) {
6✔
114
                // The exception message from parsing may refer to the faux `{` block start token,
115
                // which would be confusing.
116
                // Rethrow with a more useful message, that also includes the selector(s) string that was passed.
117
                throw new UnexpectedTokenException(
6✔
118
                    'Selector(s) string is not valid.',
6✔
119
                    $selectors,
120
                    'custom'
6✔
121
                );
122
            }
123
        }
124

125
        // Convert all items to a `Selector` if not already
126
        foreach ($selectorsToSet as $key => $selector) {
323✔
127
            if (!($selector instanceof Selector)) {
323✔
128
                if ($list === null || !($list instanceof KeyFrame)) {
322✔
129
                    if (!Selector::isValid($selector)) {
322✔
130
                        throw new UnexpectedTokenException(
3✔
131
                            "Selector did not match '" . Selector::SELECTOR_VALIDATION_RX . "'.",
3✔
132
                            $selector,
133
                            'custom'
3✔
134
                        );
135
                    }
136
                    $selectorsToSet[$key] = new Selector($selector);
319✔
137
                } else {
138
                    if (!KeyframeSelector::isValid($selector)) {
×
UNCOV
139
                        throw new UnexpectedTokenException(
×
140
                            "Selector did not match '" . KeyframeSelector::SELECTOR_VALIDATION_RX . "'.",
×
141
                            $selector,
UNCOV
142
                            'custom'
×
143
                        );
144
                    }
UNCOV
145
                    $selectorsToSet[$key] = new KeyframeSelector($selector);
×
146
                }
147
            }
148
        }
149

150
        // Discard the keys and reindex the array
151
        $this->selectors = \array_values($selectorsToSet);
320✔
152
    }
320✔
153

154
    /**
155
     * Remove one of the selectors of the block.
156
     *
157
     * @param Selector|string $selectorToRemove
158
     */
159
    public function removeSelector($selectorToRemove): bool
×
160
    {
UNCOV
161
        if ($selectorToRemove instanceof Selector) {
×
162
            $selectorToRemove = $selectorToRemove->getSelector();
×
163
        }
164
        foreach ($this->selectors as $key => $selector) {
×
165
            if ($selector->getSelector() === $selectorToRemove) {
×
UNCOV
166
                unset($this->selectors[$key]);
×
UNCOV
167
                return true;
×
168
            }
169
        }
UNCOV
170
        return false;
×
171
    }
172

173
    /**
174
     * @return list<Selector>
175
     */
176
    public function getSelectors(): array
313✔
177
    {
178
        return $this->selectors;
313✔
179
    }
180

181
    public function getRuleSet(): RuleSet
173✔
182
    {
183
        return $this->ruleSet;
173✔
184
    }
185

186
    /**
187
     * @see RuleSet::addRule()
188
     */
189
    public function addRule(Rule $ruleToAdd, ?Rule $sibling = null): void
215✔
190
    {
191
        $this->ruleSet->addRule($ruleToAdd, $sibling);
215✔
192
    }
215✔
193

194
    /**
195
     * @see RuleSet::getRules()
196
     *
197
     * @return array<int<0, max>, Rule>
198
     */
199
    public function getRules(?string $searchPattern = null): array
177✔
200
    {
201
        return $this->ruleSet->getRules($searchPattern);
177✔
202
    }
203

204
    /**
205
     * @see RuleSet::setRules()
206
     *
207
     * @param array<Rule> $rules
208
     */
209
    public function setRules(array $rules): void
346✔
210
    {
211
        $this->ruleSet->setRules($rules);
346✔
212
    }
346✔
213

214
    /**
215
     * @see RuleSet::getRulesAssoc()
216
     *
217
     * @return array<string, Rule>
218
     */
219
    public function getRulesAssoc(?string $searchPattern = null): array
50✔
220
    {
221
        return $this->ruleSet->getRulesAssoc($searchPattern);
50✔
222
    }
223

224
    /**
225
     * @see RuleSet::removeRule()
226
     */
227
    public function removeRule(Rule $ruleToRemove): void
22✔
228
    {
229
        $this->ruleSet->removeRule($ruleToRemove);
22✔
230
    }
22✔
231

232
    /**
233
     * @see RuleSet::removeMatchingRules()
234
     */
235
    public function removeMatchingRules(string $searchPattern): void
28✔
236
    {
237
        $this->ruleSet->removeMatchingRules($searchPattern);
28✔
238
    }
28✔
239

240
    /**
241
     * @see RuleSet::removeAllRules()
242
     */
243
    public function removeAllRules(): void
4✔
244
    {
245
        $this->ruleSet->removeAllRules();
4✔
246
    }
4✔
247

248
    /**
249
     * @return non-empty-string
250
     *
251
     * @throws OutputException
252
     */
253
    public function render(OutputFormat $outputFormat): string
6✔
254
    {
255
        $formatter = $outputFormat->getFormatter();
6✔
256
        $result = $formatter->comments($this);
6✔
257
        if (\count($this->selectors) === 0) {
6✔
258
            // If all the selectors have been removed, this declaration block becomes invalid
259
            throw new OutputException(
×
UNCOV
260
                'Attempt to print declaration block with missing selector',
×
UNCOV
261
                $this->getLineNumber()
×
262
            );
263
        }
264
        $result .= $outputFormat->getContentBeforeDeclarationBlock();
6✔
265
        $result .= $formatter->implode(
6✔
266
            $formatter->spaceBeforeSelectorSeparator() . ',' . $formatter->spaceAfterSelectorSeparator(),
6✔
267
            $this->selectors
6✔
268
        );
269
        $result .= $outputFormat->getContentAfterDeclarationBlockSelectors();
6✔
270
        $result .= $formatter->spaceBeforeOpeningBrace() . '{';
6✔
271
        $result .= $this->ruleSet->render($outputFormat);
6✔
272
        $result .= '}';
6✔
273
        $result .= $outputFormat->getContentAfterDeclarationBlock();
6✔
274

275
        return $result;
6✔
276
    }
277

278
    /**
279
     * @param list<Comment> $comments
280
     *
281
     * @return list<string>
282
     *
283
     * @throws UnexpectedTokenException
284
     */
285
    private static function parseSelectors(ParserState $parserState, array &$comments = []): array
343✔
286
    {
287
        $selectors = [];
343✔
288

289
        while (true) {
343✔
290
            $selectors[] = self::parseSelector($parserState, $comments);
343✔
291
            if (!$parserState->consumeIfComes(',')) {
323✔
292
                break;
323✔
293
            }
294
        }
295

296
        return $selectors;
323✔
297
    }
298

299
    /**
300
     * @param list<Comment> $comments
301
     *
302
     * @throws UnexpectedTokenException
303
     */
304
    private static function parseSelector(ParserState $parserState, array &$comments = []): string
343✔
305
    {
306
        $selectorParts = [];
343✔
307
        $stringWrapperCharacter = null;
343✔
308
        $functionNestingLevel = 0;
343✔
309
        static $stopCharacters = ['{', '}', '\'', '"', '(', ')', ','];
343✔
310

311
        while (true) {
343✔
312
            $selectorParts[] = $parserState->consume(1);
343✔
313
            $selectorParts[] = $parserState->consumeUntil($stopCharacters, false, false, $comments);
343✔
314
            $nextCharacter = $parserState->peek();
343✔
315
            switch ($nextCharacter) {
343✔
316
                case '\'':
343✔
317
                    // The fallthrough is intentional.
318
                case '"':
343✔
319
                    if (!\is_string($stringWrapperCharacter)) {
92✔
320
                        $stringWrapperCharacter = $nextCharacter;
92✔
321
                    } elseif ($stringWrapperCharacter === $nextCharacter) {
92✔
322
                        if (\substr(\end($selectorParts), -1) !== '\\') {
92✔
323
                            $stringWrapperCharacter = null;
92✔
324
                        }
325
                    }
326
                    break;
92✔
327
                case '(':
343✔
328
                    if (!\is_string($stringWrapperCharacter)) {
183✔
329
                        ++$functionNestingLevel;
107✔
330
                    }
331
                    break;
183✔
332
                case ')':
343✔
333
                    if (!\is_string($stringWrapperCharacter)) {
178✔
334
                        if ($functionNestingLevel <= 0) {
102✔
335
                            throw new UnexpectedTokenException('anything but', ')');
10✔
336
                        }
337
                        --$functionNestingLevel;
97✔
338
                    }
339
                    break;
173✔
340
                case '{':
333✔
341
                    // The fallthrough is intentional.
342
                case '}':
303✔
343
                    if (!\is_string($stringWrapperCharacter)) {
333✔
344
                        break 2;
333✔
345
                    }
346
                    break;
92✔
347
                case ',':
294✔
348
                    if (!\is_string($stringWrapperCharacter) && $functionNestingLevel === 0) {
294✔
349
                        break 2;
288✔
350
                    }
351
                    break;
132✔
352
            }
353
        }
354

355
        if ($functionNestingLevel !== 0) {
333✔
356
            throw new UnexpectedTokenException(')', $nextCharacter);
10✔
357
        }
358

359
        return \implode('', $selectorParts);
323✔
360
    }
361
}
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