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

MyIntervals / PHP-CSS-Parser / 14049703608

25 Mar 2025 01:39AM UTC coverage: 52.257% (+0.6%) from 51.611%
14049703608

Pull #1206

github

web-flow
Merge a0631c648 into acfe85e5f
Pull Request #1206: [TASK] Add trait providing standard implementation of `Commentable`

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

61 existing lines in 2 files now uncovered.

961 of 1839 relevant lines covered (52.26%)

6.85 hits per line

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

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

3
declare(strict_types=1);
4

5
namespace Sabberworm\CSS\CSSList;
6

7
use Sabberworm\CSS\Comment\Comment;
8
use Sabberworm\CSS\Comment\Commentable;
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\Property\AtRule;
15
use Sabberworm\CSS\Property\Charset;
16
use Sabberworm\CSS\Property\CSSNamespace;
17
use Sabberworm\CSS\Property\Import;
18
use Sabberworm\CSS\Property\Selector;
19
use Sabberworm\CSS\Renderable;
20
use Sabberworm\CSS\RuleSet\AtRuleSet;
21
use Sabberworm\CSS\RuleSet\DeclarationBlock;
22
use Sabberworm\CSS\RuleSet\RuleSet;
23
use Sabberworm\CSS\Settings;
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
abstract class CSSList implements Renderable, Commentable
35
{
36
    /**
37
     * @var list<Comment>
38
     *
39
     * @internal since 8.8.0
40
     */
41
    protected $comments = [];
42

43
    /**
44
     * @var array<int<0, max>, RuleSet|CSSList|Import|Charset>
45
     *
46
     * @internal since 8.8.0
47
     */
48
    protected $contents = [];
49

50
    /**
51
     * @var int<0, max>
52
     *
53
     * @internal since 8.8.0
54
     */
55
    protected $lineNumber;
56

57
    /**
58
     * @param int<0, max> $lineNumber
59
     */
60
    public function __construct(int $lineNumber = 0)
60✔
61
    {
62
        $this->lineNumber = $lineNumber;
60✔
63
    }
60✔
64

65
    /**
66
     * @throws UnexpectedTokenException
67
     * @throws SourceException
68
     *
69
     * @internal since V8.8.0
70
     */
71
    public static function parseList(ParserState $parserState, CSSList $list): void
9✔
72
    {
73
        $isRoot = $list instanceof Document;
9✔
74
        if (\is_string($parserState)) {
9✔
75
            $parserState = new ParserState($parserState, Settings::create());
×
76
        }
77
        $usesLenientParsing = $parserState->getSettings()->usesLenientParsing();
9✔
78
        $comments = [];
9✔
79
        while (!$parserState->isEnd()) {
9✔
80
            $comments = \array_merge($comments, $parserState->consumeWhiteSpace());
9✔
81
            $listItem = null;
9✔
82
            if ($usesLenientParsing) {
9✔
83
                try {
84
                    $listItem = self::parseListItem($parserState, $list);
4✔
85
                } catch (UnexpectedTokenException $e) {
×
86
                    $listItem = false;
4✔
87
                }
88
            } else {
89
                $listItem = self::parseListItem($parserState, $list);
5✔
90
            }
91
            if ($listItem === null) {
9✔
92
                // List parsing finished
93
                return;
9✔
94
            }
95
            if ($listItem) {
9✔
96
                $listItem->addComments($comments);
9✔
97
                $list->append($listItem);
9✔
98
            }
99
            $comments = $parserState->consumeWhiteSpace();
9✔
100
        }
101
        $list->addComments($comments);
9✔
102
        if (!$isRoot && !$usesLenientParsing) {
9✔
103
            throw new SourceException('Unexpected end of document', $parserState->currentLine());
×
104
        }
105
    }
9✔
106

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

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

248
    /**
249
     * Tests an identifier for a given value. Since identifiers are all keywords, they can be vendor-prefixed.
250
     * We need to check for these versions too.
251
     */
252
    private static function identifierIs(string $identifier, string $match): bool
9✔
253
    {
254
        return (\strcasecmp($identifier, $match) === 0)
9✔
255
            ?: \preg_match("/^(-\\w+-)?$match$/i", $identifier) === 1;
9✔
256
    }
257

258
    /**
259
     * @return int<0, max>
260
     */
261
    public function getLineNo(): int
4✔
262
    {
263
        return $this->lineNumber;
4✔
264
    }
265

266
    /**
267
     * Prepends an item to the list of contents.
268
     *
269
     * @param RuleSet|CSSList|Import|Charset $item
270
     */
271
    public function prepend($item): void
×
272
    {
UNCOV
273
        \array_unshift($this->contents, $item);
×
UNCOV
274
    }
×
275

276
    /**
277
     * Appends an item to the list of contents.
278
     *
279
     * @param RuleSet|CSSList|Import|Charset $item
280
     */
281
    public function append($item): void
27✔
282
    {
283
        $this->contents[] = $item;
27✔
284
    }
27✔
285

286
    /**
287
     * Splices the list of contents.
288
     *
289
     * @param array<int, RuleSet|CSSList|Import|Charset> $replacement
290
     */
291
    public function splice(int $offset, ?int $length = null, ?array $replacement = null): void
×
292
    {
UNCOV
293
        \array_splice($this->contents, $offset, $length, $replacement);
×
UNCOV
294
    }
×
295

296
    /**
297
     * Inserts an item in the CSS list before its sibling. If the desired sibling cannot be found,
298
     * the item is appended at the end.
299
     *
300
     * @param RuleSet|CSSList|Import|Charset $item
301
     * @param RuleSet|CSSList|Import|Charset $sibling
302
     */
303
    public function insertBefore($item, $sibling): void
2✔
304
    {
305
        if (\in_array($sibling, $this->contents, true)) {
2✔
306
            $this->replace($sibling, [$item, $sibling]);
1✔
307
        } else {
308
            $this->append($item);
1✔
309
        }
310
    }
2✔
311

312
    /**
313
     * Removes an item from the CSS list.
314
     *
315
     * @param RuleSet|Import|Charset|CSSList $itemToRemove
316
     *        May be a `RuleSet` (most likely a `DeclarationBlock`), an `Import`,
317
     *        a `Charset` or another `CSSList` (most likely a `MediaQuery`)
318
     *
319
     * @return bool whether the item was removed
320
     */
321
    public function remove($itemToRemove): bool
×
322
    {
323
        $key = \array_search($itemToRemove, $this->contents, true);
×
UNCOV
324
        if ($key !== false) {
×
UNCOV
325
            unset($this->contents[$key]);
×
326
            return true;
×
327
        }
328

UNCOV
329
        return false;
×
330
    }
331

332
    /**
333
     * Replaces an item from the CSS list.
334
     *
335
     * @param RuleSet|Import|Charset|CSSList $oldItem
336
     *        May be a `RuleSet` (most likely a `DeclarationBlock`), an `Import`, a `Charset`
337
     *        or another `CSSList` (most likely a `MediaQuery`)
338
     * @param RuleSet|Import|Charset|CSSList|array<RuleSet|Import|Charset|CSSList> $newItem
339
     */
340
    public function replace($oldItem, $newItem): bool
1✔
341
    {
342
        $key = \array_search($oldItem, $this->contents, true);
1✔
343
        if ($key !== false) {
1✔
344
            if (\is_array($newItem)) {
1✔
345
                \array_splice($this->contents, $key, 1, $newItem);
1✔
346
            } else {
UNCOV
347
                \array_splice($this->contents, $key, 1, [$newItem]);
×
348
            }
349
            return true;
1✔
350
        }
351

UNCOV
352
        return false;
×
353
    }
354

355
    /**
356
     * @param array<int, RuleSet|Import|Charset|CSSList> $contents
357
     */
358
    public function setContents(array $contents): void
19✔
359
    {
360
        $this->contents = [];
19✔
361
        foreach ($contents as $content) {
19✔
362
            $this->append($content);
18✔
363
        }
364
    }
19✔
365

366
    /**
367
     * Removes a declaration block from the CSS list if it matches all given selectors.
368
     *
369
     * @param DeclarationBlock|array<Selector>|string $selectors the selectors to match
370
     * @param bool $removeAll whether to stop at the first declaration block found or remove all blocks
371
     */
372
    public function removeDeclarationBlockBySelector($selectors, bool $removeAll = false): void
×
373
    {
374
        if ($selectors instanceof DeclarationBlock) {
×
375
            $selectors = $selectors->getSelectors();
×
376
        }
377
        if (!\is_array($selectors)) {
×
378
            $selectors = \explode(',', $selectors);
×
379
        }
380
        foreach ($selectors as $key => &$selector) {
×
381
            if (!($selector instanceof Selector)) {
×
UNCOV
382
                if (!Selector::isValid($selector)) {
×
383
                    throw new UnexpectedTokenException(
×
UNCOV
384
                        "Selector did not match '" . Selector::SELECTOR_VALIDATION_RX . "'.",
×
385
                        $selector,
386
                        'custom'
×
387
                    );
388
                }
389
                $selector = new Selector($selector);
×
390
            }
391
        }
UNCOV
392
        foreach ($this->contents as $key => $item) {
×
393
            if (!($item instanceof DeclarationBlock)) {
×
394
                continue;
×
395
            }
396
            if ($item->getSelectors() == $selectors) {
×
UNCOV
397
                unset($this->contents[$key]);
×
UNCOV
398
                if (!$removeAll) {
×
UNCOV
399
                    return;
×
400
                }
401
            }
402
        }
UNCOV
403
    }
×
404

405
    protected function renderListContents(OutputFormat $outputFormat): string
×
406
    {
407
        $result = '';
×
408
        $isFirst = true;
×
UNCOV
409
        $nextLevelFormat = $outputFormat;
×
410
        if (!$this->isRootList()) {
×
411
            $nextLevelFormat = $outputFormat->nextLevel();
×
412
        }
UNCOV
413
        $nextLevelFormatter = $nextLevelFormat->getFormatter();
×
414
        $formatter = $outputFormat->getFormatter();
×
415
        foreach ($this->contents as $listItem) {
×
416
            $renderedCss = $formatter->safely(static function () use ($nextLevelFormat, $listItem): string {
417
                return $listItem->render($nextLevelFormat);
×
UNCOV
418
            });
×
419
            if ($renderedCss === null) {
×
420
                continue;
×
421
            }
UNCOV
422
            if ($isFirst) {
×
423
                $isFirst = false;
×
UNCOV
424
                $result .= $nextLevelFormatter->spaceBeforeBlocks();
×
425
            } else {
UNCOV
426
                $result .= $nextLevelFormatter->spaceBetweenBlocks();
×
427
            }
428
            $result .= $renderedCss;
×
429
        }
430

UNCOV
431
        if (!$isFirst) {
×
432
            // Had some output
433
            $result .= $formatter->spaceAfterBlocks();
×
434
        }
435

UNCOV
436
        return $result;
×
437
    }
438

439
    /**
440
     * Return true if the list can not be further outdented. Only important when rendering.
441
     */
442
    abstract public function isRootList(): bool;
443

444
    /**
445
     * Returns the stored items.
446
     *
447
     * @return array<int<0, max>, RuleSet|Import|Charset|CSSList>
448
     */
449
    public function getContents(): array
16✔
450
    {
451
        return $this->contents;
16✔
452
    }
453

454
    /**
455
     * @param list<Comment> $comments
456
     */
457
    public function addComments(array $comments): void
9✔
458
    {
459
        $this->comments = \array_merge($this->comments, $comments);
9✔
460
    }
9✔
461

462
    /**
463
     * @return list<Comment>
464
     */
UNCOV
465
    public function getComments(): array
×
466
    {
UNCOV
467
        return $this->comments;
×
468
    }
469

470
    /**
471
     * @param list<Comment> $comments
472
     */
473
    public function setComments(array $comments): void
×
474
    {
UNCOV
475
        $this->comments = $comments;
×
UNCOV
476
    }
×
477
}
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