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

MyIntervals / PHP-CSS-Parser / 14025852631

24 Mar 2025 02:23AM UTC coverage: 51.611%. Remained the same
14025852631

Pull #1212

github

web-flow
Merge 090d8afa4 into 639366092
Pull Request #1212: [TASK] Add (and use) a `CSSListItem` type

6 of 10 new or added lines in 2 files covered. (60.0%)

41 existing lines in 3 files now uncovered.

945 of 1831 relevant lines covered (51.61%)

6.72 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 Commentable, CSSListItem, Renderable
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>, CSSListItem>
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✔
UNCOV
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✔
UNCOV
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✔
UNCOV
103
            throw new SourceException('Unexpected end of document', $parserState->currentLine());
×
104
        }
105
    }
9✔
106

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

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

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

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

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

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

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

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

320
        return false;
×
321
    }
322

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

343
        return false;
×
344
    }
345

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

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

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

422
        if (!$isFirst) {
×
423
            // Had some output
424
            $result .= $formatter->spaceAfterBlocks();
×
425
        }
426

427
        return $result;
×
428
    }
429

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

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

445
    /**
446
     * @param list<Comment> $comments
447
     */
448
    public function addComments(array $comments): void
9✔
449
    {
450
        $this->comments = \array_merge($this->comments, $comments);
9✔
451
    }
9✔
452

453
    /**
454
     * @return list<Comment>
455
     */
456
    public function getComments(): array
×
457
    {
458
        return $this->comments;
×
459
    }
460

461
    /**
462
     * @param list<Comment> $comments
463
     */
464
    public function setComments(array $comments): void
×
465
    {
466
        $this->comments = $comments;
×
467
    }
×
468
}
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