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

MyIntervals / PHP-CSS-Parser / 13815834687

12 Mar 2025 03:55PM UTC coverage: 55.686% (-0.06%) from 55.746%
13815834687

Pull #1151

github

web-flow
Merge 04b5cc592 into 898d5cdd7
Pull Request #1151: [CLEANUP] Avoid magic method forwarding in `CSSList`

0 of 5 new or added lines in 1 file covered. (0.0%)

1 existing line in 1 file now uncovered.

1043 of 1873 relevant lines covered (55.69%)

12.39 hits per line

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

12.75
/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 array<array-key, Comment>
38
     *
39
     * @internal since 8.8.0
40
     */
41
    protected $comments = [];
42

43
    /**
44
     * @var array<int, 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)
51✔
61
    {
62
        $this->lineNumber = $lineNumber;
51✔
63
    }
51✔
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
×
72
    {
73
        $isRoot = $list instanceof Document;
×
74
        if (\is_string($parserState)) {
×
75
            $parserState = new ParserState($parserState, Settings::create());
×
76
        }
77
        $usesLenientParsing = $parserState->getSettings()->usesLenientParsing();
×
78
        $comments = [];
×
79
        while (!$parserState->isEnd()) {
×
80
            $comments = \array_merge($comments, $parserState->consumeWhiteSpace());
×
81
            $listItem = null;
×
82
            if ($usesLenientParsing) {
×
83
                try {
84
                    $listItem = self::parseListItem($parserState, $list);
×
85
                } catch (UnexpectedTokenException $e) {
×
86
                    $listItem = false;
×
87
                }
88
            } else {
89
                $listItem = self::parseListItem($parserState, $list);
×
90
            }
91
            if ($listItem === null) {
×
92
                // List parsing finished
93
                return;
×
94
            }
95
            if ($listItem) {
×
96
                $listItem->addComments($comments);
×
97
                $list->append($listItem);
×
98
            }
99
            $comments = $parserState->consumeWhiteSpace();
×
100
        }
101
        $list->addComments($comments);
×
102
        if (!$isRoot && !$usesLenientParsing) {
×
103
            throw new SourceException('Unexpected end of document', $parserState->currentLine());
×
104
        }
105
    }
×
106

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

155
    /**
156
     * @return AtRuleBlockList|KeyFrame|Charset|CSSNamespace|Import|AtRuleSet|null
157
     *
158
     * @throws SourceException
159
     * @throws UnexpectedTokenException
160
     * @throws UnexpectedEOFException
161
     */
162
    private static function parseAtRule(ParserState $parserState)
×
163
    {
164
        $parserState->consume('@');
×
165
        $identifier = $parserState->parseIdentifier();
×
166
        $identifierLineNumber = $parserState->currentLine();
×
167
        $parserState->consumeWhiteSpace();
×
168
        if ($identifier === 'import') {
×
169
            $location = URL::parse($parserState);
×
170
            $parserState->consumeWhiteSpace();
×
171
            $mediaQuery = null;
×
172
            if (!$parserState->comes(';')) {
×
173
                $mediaQuery = \trim($parserState->consumeUntil([';', ParserState::EOF]));
×
174
                if ($mediaQuery === '') {
×
175
                    $mediaQuery = null;
×
176
                }
177
            }
178
            $parserState->consumeUntil([';', ParserState::EOF], true, true);
×
179
            return new Import($location, $mediaQuery, $identifierLineNumber);
×
180
        } elseif ($identifier === 'charset') {
×
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')) {
×
186
            $result = new KeyFrame($identifierLineNumber);
×
187
            $result->setVendorKeyFrame($identifier);
×
188
            $result->setAnimationName(\trim($parserState->consumeUntil('{', false, true)));
×
189
            CSSList::parseList($parserState, $result);
×
190
            if ($parserState->comes('}')) {
×
191
                $parserState->consume('}');
×
192
            }
193
            return $result;
×
194
        } elseif ($identifier === 'namespace') {
×
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));
×
217
            if (\substr_count($arguments, '(') != \substr_count($arguments, ')')) {
×
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;
×
225
            foreach (\explode('/', AtRule::BLOCK_RULES) as $blockRuleName) {
×
226
                if (self::identifierIs($identifier, $blockRuleName)) {
×
227
                    $useRuleSet = false;
×
228
                    break;
×
229
                }
230
            }
231
            if ($useRuleSet) {
×
232
                $atRule = new AtRuleSet($identifier, $arguments, $identifierLineNumber);
×
233
                RuleSet::parseRuleSet($parserState, $atRule);
×
234
            } else {
235
                $atRule = new AtRuleBlockList($identifier, $arguments, $identifierLineNumber);
×
236
                CSSList::parseList($parserState, $atRule);
×
237
                if ($parserState->comes('}')) {
×
238
                    $parserState->consume('}');
×
239
                }
240
            }
241
            return $atRule;
×
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
×
250
    {
251
        return (\strcasecmp($identifier, $match) === 0)
×
252
            ?: \preg_match("/^(-\\w+-)?$match$/i", $identifier) === 1;
×
253
    }
254

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

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

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

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

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

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

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

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

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

403
    /**
404
     * @deprecated in V8.8.0, will be removed in V9.0.0. Use `render` instead.
405
     */
406
    public function __toString(): string
×
407
    {
408
        return $this->render(new OutputFormat());
×
409
    }
410

411
    /**
412
     * @return string
413
     */
414
    protected function renderListContents(OutputFormat $outputFormat)
×
415
    {
416
        $result = '';
×
417
        $isFirst = true;
×
418
        $nextLevelFormat = $outputFormat;
×
419
        if (!$this->isRootList()) {
×
420
            $nextLevelFormat = $outputFormat->nextLevel();
×
421
        }
NEW
422
        $nextLevelFormatter = $nextLevelFormat->getFormatter();
×
NEW
423
        $formatter = $outputFormat->getFormatter();
×
UNCOV
424
        foreach ($this->contents as $listItem) {
×
425
            $renderedCss = $formatter->safely(static function () use ($nextLevelFormat, $listItem): string {
426
                return $listItem->render($nextLevelFormat);
×
427
            });
×
428
            if ($renderedCss === null) {
×
429
                continue;
×
430
            }
431
            if ($isFirst) {
×
432
                $isFirst = false;
×
NEW
433
                $result .= $nextLevelFormatter->spaceBeforeBlocks();
×
434
            } else {
NEW
435
                $result .= $nextLevelFormatter->spaceBetweenBlocks();
×
436
            }
437
            $result .= $renderedCss;
×
438
        }
439

440
        if (!$isFirst) {
×
441
            // Had some output
NEW
442
            $result .= $formatter->spaceAfterBlocks();
×
443
        }
444

445
        return $result;
×
446
    }
447

448
    /**
449
     * Return true if the list can not be further outdented. Only important when rendering.
450
     *
451
     * @return bool
452
     */
453
    abstract public function isRootList();
454

455
    /**
456
     * Returns the stored items.
457
     *
458
     * @return array<int, RuleSet|Import|Charset|CSSList>
459
     */
460
    public function getContents()
7✔
461
    {
462
        return $this->contents;
7✔
463
    }
464

465
    /**
466
     * @param array<array-key, Comment> $comments
467
     */
468
    public function addComments(array $comments): void
×
469
    {
470
        $this->comments = \array_merge($this->comments, $comments);
×
471
    }
×
472

473
    /**
474
     * @return array<array-key, Comment>
475
     */
476
    public function getComments(): array
×
477
    {
478
        return $this->comments;
×
479
    }
480

481
    /**
482
     * @param array<array-key, Comment> $comments
483
     */
484
    public function setComments(array $comments): void
×
485
    {
486
        $this->comments = $comments;
×
487
    }
×
488
}
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

© 2025 Coveralls, Inc