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

MyIntervals / PHP-CSS-Parser / 13897089293

17 Mar 2025 10:25AM UTC coverage: 56.87%. Remained the same
13897089293

push

github

web-flow
[TASK] Add native type declarations for `CSSList` (#1181)

Part of #811

2 of 5 new or added lines in 1 file covered. (40.0%)

2 existing lines in 1 file now uncovered.

1043 of 1834 relevant lines covered (56.87%)

12.89 hits per line

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

12.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\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)
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 array<int, RuleSet|CSSList|Import|Charset> $replacement
287
     */
NEW
288
    public function splice(int $offset, ?int $length = null, ?array $replacement = null): void
×
289
    {
290
        \array_splice($this->contents, $offset, $length, $replacement);
×
291
    }
×
292

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

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

UNCOV
326
        return false;
×
327
    }
328

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

UNCOV
349
        return false;
×
350
    }
351

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

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

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

428
        if (!$isFirst) {
×
429
            // Had some output
430
            $result .= $formatter->spaceAfterBlocks();
×
431
        }
432

433
        return $result;
×
434
    }
435

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

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

451
    /**
452
     * @param list<Comment> $comments
453
     */
454
    public function addComments(array $comments): void
×
455
    {
456
        $this->comments = \array_merge($this->comments, $comments);
×
457
    }
×
458

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

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