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

MyIntervals / PHP-CSS-Parser / 21410963225

27 Jan 2026 07:24PM UTC coverage: 70.34% (-1.0%) from 71.315%
21410963225

Pull #1484

github

web-flow
Merge 2320afc9e into 96410045c
Pull Request #1484: Remove `thecodingmachine/safe` dependency (2)

25 of 70 new or added lines in 8 files covered. (35.71%)

5 existing lines in 3 files now uncovered.

1449 of 2060 relevant lines covered (70.34%)

30.37 hits per line

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

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

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

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

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

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

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

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

253
        /** @phpstan-ignore theCodingMachineSafe.function */
254
        $matchResult = \preg_match("/^(-\\w+-)?$match$/i", $identifier);
9✔
255
        if ($matchResult === false) {
9✔
NEW
256
            throw new \RuntimeException('Unexpected error');
×
257
        }
258
        return $matchResult === 1;
9✔
259
    }
260

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

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

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

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

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

317
        return false;
×
318
    }
319

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

340
        return false;
×
341
    }
342

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

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

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

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

424
        return $result;
2✔
425
    }
426

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

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

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

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

461
        \sort($selectorStrings1);
9✔
462
        \sort($selectorStrings2);
9✔
463

464
        return $selectorStrings1 === $selectorStrings2;
9✔
465
    }
466

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