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

MyIntervals / PHP-CSS-Parser / 21338071049

25 Jan 2026 07:14PM UTC coverage: 71.315% (+0.6%) from 70.738%
21338071049

Pull #1478

github

web-flow
Merge a293ba132 into 416f6a7fe
Pull Request #1478: [TASK] Add `SelectorComponent` interface

1432 of 2008 relevant lines covered (71.31%)

30.64 hits per line

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

68.9
/src/CSSList/CSSList.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Sabberworm\CSS\CSSList;
6

7
use Sabberworm\CSS\Comment\CommentContainer;
8
use Sabberworm\CSS\CSSElement;
9
use Sabberworm\CSS\OutputFormat;
10
use Sabberworm\CSS\Parsing\ParserState;
11
use Sabberworm\CSS\Parsing\SourceException;
12
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
13
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
14
use Sabberworm\CSS\Position\Position;
15
use Sabberworm\CSS\Position\Positionable;
16
use Sabberworm\CSS\Property\AtRule;
17
use Sabberworm\CSS\Property\Charset;
18
use Sabberworm\CSS\Property\CSSNamespace;
19
use Sabberworm\CSS\Property\Import;
20
use Sabberworm\CSS\Property\Selector;
21
use Sabberworm\CSS\RuleSet\AtRuleSet;
22
use Sabberworm\CSS\RuleSet\DeclarationBlock;
23
use Sabberworm\CSS\RuleSet\RuleSet;
24
use Sabberworm\CSS\Value\CSSString;
25
use Sabberworm\CSS\Value\URL;
26
use Sabberworm\CSS\Value\Value;
27

28
use function Safe\preg_match;
29

30
/**
31
 * This is the most generic container available. It can contain `DeclarationBlock`s (rule sets with a selector),
32
 * `RuleSet`s as well as other `CSSList` objects.
33
 *
34
 * It can also contain `Import` and `Charset` objects stemming from at-rules.
35
 *
36
 * Note that `CSSListItem` extends both `Commentable` and `Renderable`,
37
 * so those interfaces must also be implemented by concrete subclasses.
38
 */
39
abstract class CSSList implements CSSElement, CSSListItem, Positionable
40
{
41
    use CommentContainer;
42
    use Position;
43

44
    /**
45
     * @var array<int<0, max>, CSSListItem>
46
     *
47
     * @internal since 8.8.0
48
     */
49
    protected $contents = [];
50

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

59
    /**
60
     * @throws UnexpectedTokenException
61
     * @throws SourceException
62
     *
63
     * @internal since V8.8.0
64
     */
65
    public static function parseList(ParserState $parserState, CSSList $list): void
12✔
66
    {
67
        $isRoot = $list instanceof Document;
12✔
68
        $usesLenientParsing = $parserState->getSettings()->usesLenientParsing();
12✔
69
        $comments = [];
12✔
70
        while (!$parserState->isEnd()) {
12✔
71
            $parserState->consumeWhiteSpace($comments);
12✔
72
            $listItem = null;
12✔
73
            if ($usesLenientParsing) {
12✔
74
                try {
75
                    $positionBeforeParse = $parserState->currentColumn();
6✔
76
                    $listItem = self::parseListItem($parserState, $list);
6✔
77
                } catch (UnexpectedTokenException $e) {
×
78
                    $listItem = false;
×
79
                    // If the failed parsing did not consume anything that was to come ...
80
                    if ($parserState->currentColumn() === $positionBeforeParse && !$parserState->isEnd()) {
×
81
                        // ... the unexpected token needs to be skipped, otherwise there'll be an infinite loop.
82
                        $parserState->consume(1);
6✔
83
                    }
84
                }
85
            } else {
86
                $listItem = self::parseListItem($parserState, $list);
6✔
87
            }
88
            if ($listItem === null) {
12✔
89
                // List parsing finished
90
                return;
10✔
91
            }
92
            if ($listItem) {
12✔
93
                $listItem->addComments($comments);
12✔
94
                $list->append($listItem);
12✔
95
            }
96
            $comments = [];
12✔
97
            $parserState->consumeWhiteSpace($comments);
12✔
98
        }
99
        $list->addComments($comments);
12✔
100
        if (!$isRoot && !$usesLenientParsing) {
12✔
101
            throw new SourceException('Unexpected end of document', $parserState->currentLine());
×
102
        }
103
    }
12✔
104

105
    /**
106
     * @return CSSListItem|false|null
107
     *         If `null` is returned, it means the end of the list has been reached.
108
     *         If `false` is returned, it means an invalid item has been encountered,
109
     *         but parsing of the next item should proceed.
110
     *
111
     * @throws SourceException
112
     * @throws UnexpectedEOFException
113
     * @throws UnexpectedTokenException
114
     */
115
    private static function parseListItem(ParserState $parserState, CSSList $list)
12✔
116
    {
117
        $isRoot = $list instanceof Document;
12✔
118
        if ($parserState->comes('@')) {
12✔
119
            $atRule = self::parseAtRule($parserState);
11✔
120
            if ($atRule instanceof Charset) {
11✔
121
                if (!$isRoot) {
×
122
                    throw new UnexpectedTokenException(
×
123
                        '@charset may only occur in root document',
×
124
                        '',
×
125
                        'custom',
×
126
                        $parserState->currentLine()
×
127
                    );
128
                }
129
                if (\count($list->getContents()) > 0) {
×
130
                    throw new UnexpectedTokenException(
×
131
                        '@charset must be the first parseable token in a document',
×
132
                        '',
×
133
                        'custom',
×
134
                        $parserState->currentLine()
×
135
                    );
136
                }
137
                $parserState->setCharset($atRule->getCharset());
×
138
            }
139
            return $atRule;
11✔
140
        } elseif ($parserState->comes('}')) {
12✔
141
            if ($isRoot) {
12✔
142
                if ($parserState->getSettings()->usesLenientParsing()) {
2✔
143
                    $parserState->consume(1);
2✔
144
                    return self::parseListItem($parserState, $list);
2✔
145
                } else {
146
                    throw new SourceException('Unopened {', $parserState->currentLine());
×
147
                }
148
            } else {
149
                // End of list
150
                return null;
10✔
151
            }
152
        } else {
153
            return DeclarationBlock::parse($parserState, $list) ?? false;
11✔
154
        }
155
    }
156

157
    /**
158
     * @throws SourceException
159
     * @throws UnexpectedTokenException
160
     * @throws UnexpectedEOFException
161
     */
162
    private static function parseAtRule(ParserState $parserState): ?CSSListItem
11✔
163
    {
164
        $parserState->consume('@');
11✔
165
        $identifier = $parserState->parseIdentifier();
11✔
166
        $identifierLineNumber = $parserState->currentLine();
11✔
167
        $parserState->consumeWhiteSpace();
11✔
168
        if ($identifier === 'import') {
11✔
169
            $location = URL::parse($parserState);
1✔
170
            $parserState->consumeWhiteSpace();
1✔
171
            $mediaQuery = null;
1✔
172
            if (!$parserState->comes(';')) {
1✔
173
                $mediaQuery = \trim($parserState->consumeUntil([';', ParserState::EOF]));
×
174
                if ($mediaQuery === '') {
×
175
                    $mediaQuery = null;
×
176
                }
177
            }
178
            $parserState->consumeUntil([';', ParserState::EOF], true, true);
1✔
179
            return new Import($location, $mediaQuery, $identifierLineNumber);
1✔
180
        } elseif ($identifier === 'charset') {
10✔
181
            $charsetString = CSSString::parse($parserState);
×
182
            $parserState->consumeWhiteSpace();
×
183
            $parserState->consumeUntil([';', ParserState::EOF], true, true);
×
184
            return new Charset($charsetString, $identifierLineNumber);
×
185
        } elseif (self::identifierIs($identifier, 'keyframes')) {
10✔
186
            $result = new KeyFrame($identifierLineNumber);
1✔
187
            $result->setVendorKeyFrame($identifier);
1✔
188
            $result->setAnimationName(\trim($parserState->consumeUntil('{', false, true)));
1✔
189
            CSSList::parseList($parserState, $result);
1✔
190
            if ($parserState->comes('}')) {
1✔
191
                $parserState->consume('}');
1✔
192
            }
193
            return $result;
1✔
194
        } elseif ($identifier === 'namespace') {
9✔
195
            $prefix = null;
×
196
            $url = Value::parsePrimitiveValue($parserState);
×
197
            if (!$parserState->comes(';')) {
×
198
                $prefix = $url;
×
199
                $url = Value::parsePrimitiveValue($parserState);
×
200
            }
201
            $parserState->consumeUntil([';', ParserState::EOF], true, true);
×
202
            if ($prefix !== null && !\is_string($prefix)) {
×
203
                throw new UnexpectedTokenException('Wrong namespace prefix', $prefix, 'custom', $identifierLineNumber);
×
204
            }
205
            if (!($url instanceof CSSString || $url instanceof URL)) {
×
206
                throw new UnexpectedTokenException(
×
207
                    'Wrong namespace url of invalid type',
×
208
                    $url,
209
                    'custom',
×
210
                    $identifierLineNumber
211
                );
212
            }
213
            return new CSSNamespace($url, $prefix, $identifierLineNumber);
×
214
        } else {
215
            // Unknown other at rule (font-face or such)
216
            $arguments = \trim($parserState->consumeUntil('{', false, true));
9✔
217
            if (\substr_count($arguments, '(') !== \substr_count($arguments, ')')) {
9✔
218
                if ($parserState->getSettings()->usesLenientParsing()) {
×
219
                    return null;
×
220
                } else {
221
                    throw new SourceException('Unmatched brace count in media query', $parserState->currentLine());
×
222
                }
223
            }
224
            $useRuleSet = true;
9✔
225
            foreach (\explode('/', AtRule::BLOCK_RULES) as $blockRuleName) {
9✔
226
                if (self::identifierIs($identifier, $blockRuleName)) {
9✔
227
                    $useRuleSet = false;
9✔
228
                    break;
9✔
229
                }
230
            }
231
            if ($useRuleSet) {
9✔
232
                $atRule = new AtRuleSet($identifier, $arguments, $identifierLineNumber);
×
233
                RuleSet::parseRuleSet($parserState, $atRule);
×
234
            } else {
235
                $atRule = new AtRuleBlockList($identifier, $arguments, $identifierLineNumber);
9✔
236
                CSSList::parseList($parserState, $atRule);
9✔
237
                if ($parserState->comes('}')) {
9✔
238
                    $parserState->consume('}');
9✔
239
                }
240
            }
241
            return $atRule;
9✔
242
        }
243
    }
244

245
    /**
246
     * Tests an identifier for a given value. Since identifiers are all keywords, they can be vendor-prefixed.
247
     * We need to check for these versions too.
248
     */
249
    private static function identifierIs(string $identifier, string $match): bool
10✔
250
    {
251
        if (\strcasecmp($identifier, $match) === 0) {
10✔
252
            return true;
10✔
253
        }
254

255
        return preg_match("/^(-\\w+-)?$match$/i", $identifier) === 1;
9✔
256
    }
257

258
    /**
259
     * Prepends an item to the list of contents.
260
     */
261
    public function prepend(CSSListItem $item): void
×
262
    {
263
        \array_unshift($this->contents, $item);
×
264
    }
×
265

266
    /**
267
     * Appends an item to the list of contents.
268
     */
269
    public function append(CSSListItem $item): void
47✔
270
    {
271
        $this->contents[] = $item;
47✔
272
    }
47✔
273

274
    /**
275
     * Splices the list of contents.
276
     *
277
     * @param array<int, CSSListItem> $replacement
278
     */
279
    public function splice(int $offset, ?int $length = null, ?array $replacement = null): void
×
280
    {
281
        \array_splice($this->contents, $offset, $length, $replacement);
×
282
    }
×
283

284
    /**
285
     * Inserts an item in the CSS list before its sibling. If the desired sibling cannot be found,
286
     * the item is appended at the end.
287
     */
288
    public function insertBefore(CSSListItem $item, CSSListItem $sibling): void
2✔
289
    {
290
        if (\in_array($sibling, $this->contents, true)) {
2✔
291
            $this->replace($sibling, [$item, $sibling]);
1✔
292
        } else {
293
            $this->append($item);
1✔
294
        }
295
    }
2✔
296

297
    /**
298
     * Removes an item from the CSS list.
299
     *
300
     * @param CSSListItem $itemToRemove
301
     *        May be a `RuleSet` (most likely a `DeclarationBlock`), an `Import`,
302
     *        a `Charset` or another `CSSList` (most likely a `MediaQuery`)
303
     *
304
     * @return bool whether the item was removed
305
     */
306
    public function remove(CSSListItem $itemToRemove): bool
×
307
    {
308
        $key = \array_search($itemToRemove, $this->contents, true);
×
309
        if ($key !== false) {
×
310
            unset($this->contents[$key]);
×
311
            return true;
×
312
        }
313

314
        return false;
×
315
    }
316

317
    /**
318
     * Replaces an item from the CSS list.
319
     *
320
     * @param CSSListItem $oldItem
321
     *        May be a `RuleSet` (most likely a `DeclarationBlock`), an `Import`, a `Charset`
322
     *        or another `CSSList` (most likely a `MediaQuery`)
323
     * @param CSSListItem|array<CSSListItem> $newItem
324
     */
325
    public function replace(CSSListItem $oldItem, $newItem): bool
1✔
326
    {
327
        $key = \array_search($oldItem, $this->contents, true);
1✔
328
        if ($key !== false) {
1✔
329
            if (\is_array($newItem)) {
1✔
330
                \array_splice($this->contents, $key, 1, $newItem);
1✔
331
            } else {
332
                \array_splice($this->contents, $key, 1, [$newItem]);
×
333
            }
334
            return true;
1✔
335
        }
336

337
        return false;
×
338
    }
339

340
    /**
341
     * @param array<int, CSSListItem> $contents
342
     */
343
    public function setContents(array $contents): void
36✔
344
    {
345
        $this->contents = [];
36✔
346
        foreach ($contents as $content) {
36✔
347
            $this->append($content);
35✔
348
        }
349
    }
36✔
350

351
    /**
352
     * Removes a declaration block from the CSS list if it matches all given selectors.
353
     *
354
     * @param DeclarationBlock|array<Selector>|string $selectors the selectors to match
355
     * @param bool $removeAll whether to stop at the first declaration block found or remove all blocks
356
     */
357
    public function removeDeclarationBlockBySelector($selectors, bool $removeAll = false): void
9✔
358
    {
359
        if ($selectors instanceof DeclarationBlock) {
9✔
360
            $selectors = $selectors->getSelectors();
2✔
361
        }
362
        if (!\is_array($selectors)) {
9✔
363
            $selectors = \explode(',', $selectors);
×
364
        }
365
        foreach ($selectors as $key => &$selector) {
9✔
366
            if (!($selector instanceof Selector)) {
9✔
367
                if (!Selector::isValid($selector)) {
2✔
368
                    throw new UnexpectedTokenException(
×
369
                        "Selector did not match '" . Selector::SELECTOR_VALIDATION_RX . "'.",
×
370
                        $selector,
371
                        'custom'
×
372
                    );
373
                }
374
                $selector = new Selector($selector);
2✔
375
            }
376
        }
377
        foreach ($this->contents as $key => $item) {
9✔
378
            if (!($item instanceof DeclarationBlock)) {
9✔
379
                continue;
×
380
            }
381
            if (self::selectorsMatch($item->getSelectors(), $selectors)) {
9✔
382
                unset($this->contents[$key]);
9✔
383
                if (!$removeAll) {
9✔
384
                    return;
5✔
385
                }
386
            }
387
        }
388
    }
4✔
389

390
    protected function renderListContents(OutputFormat $outputFormat): string
2✔
391
    {
392
        $result = '';
2✔
393
        $isFirst = true;
2✔
394
        $nextLevelFormat = $outputFormat;
2✔
395
        if (!$this->isRootList()) {
2✔
396
            $nextLevelFormat = $outputFormat->nextLevel();
×
397
        }
398
        $nextLevelFormatter = $nextLevelFormat->getFormatter();
2✔
399
        $formatter = $outputFormat->getFormatter();
2✔
400
        foreach ($this->contents as $listItem) {
2✔
401
            $renderedCss = $formatter->safely(static function () use ($nextLevelFormat, $listItem): string {
402
                return $listItem->render($nextLevelFormat);
2✔
403
            });
2✔
404
            if ($renderedCss === null) {
2✔
405
                continue;
×
406
            }
407
            if ($isFirst) {
2✔
408
                $isFirst = false;
2✔
409
                $result .= $nextLevelFormatter->spaceBeforeBlocks();
2✔
410
            } else {
411
                $result .= $nextLevelFormatter->spaceBetweenBlocks();
×
412
            }
413
            $result .= $renderedCss;
2✔
414
        }
415

416
        if (!$isFirst) {
2✔
417
            // Had some output
418
            $result .= $formatter->spaceAfterBlocks();
2✔
419
        }
420

421
        return $result;
2✔
422
    }
423

424
    /**
425
     * Return true if the list can not be further outdented. Only important when rendering.
426
     */
427
    abstract public function isRootList(): bool;
428

429
    /**
430
     * Returns the stored items.
431
     *
432
     * @return array<int<0, max>, CSSListItem>
433
     */
434
    public function getContents(): array
34✔
435
    {
436
        return $this->contents;
34✔
437
    }
438

439
    /**
440
     * @return array<string, bool|int|float|string|array<mixed>|null>
441
     *
442
     * @internal
443
     */
444
    public function getArrayRepresentation(): array
5✔
445
    {
446
        throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`');
5✔
447
    }
448

449
    /**
450
     * @param list<Selector> $selectors1
451
     * @param list<Selector> $selectors2
452
     */
453
    private static function selectorsMatch(array $selectors1, array $selectors2): bool
9✔
454
    {
455
        $selectorStrings1 = self::getSelectorStrings($selectors1);
9✔
456
        $selectorStrings2 = self::getSelectorStrings($selectors2);
9✔
457

458
        \sort($selectorStrings1);
9✔
459
        \sort($selectorStrings2);
9✔
460

461
        return $selectorStrings1 === $selectorStrings2;
9✔
462
    }
463

464
    /**
465
     * @param list<Selector> $selectors
466
     *
467
     * @return list<string>
468
     */
469
    private static function getSelectorStrings(array $selectors): array
9✔
470
    {
471
        return \array_map(
9✔
472
            static function (Selector $selector): string {
473
                return $selector->getSelector();
9✔
474
            },
9✔
475
            $selectors
9✔
476
        );
477
    }
478
}
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