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

MyIntervals / PHP-CSS-Parser / 17135516075

21 Aug 2025 06:23PM UTC coverage: 59.595%. Remained the same
17135516075

Pull #1368

github

web-flow
Merge 7fc251137 into 25877ed2e
Pull Request #1368: [BUGFIX] Use the safe regexp functions in `CSSList`

1118 of 1876 relevant lines covered (59.59%)

25.31 hits per line

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

53.4
/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)
83✔
53
    {
54
        $this->setPosition($lineNumber);
83✔
55
    }
83✔
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
9✔
64
    {
65
        $isRoot = $list instanceof Document;
9✔
66
        $usesLenientParsing = $parserState->getSettings()->usesLenientParsing();
9✔
67
        $comments = [];
9✔
68
        while (!$parserState->isEnd()) {
9✔
69
            $comments = \array_merge($comments, $parserState->consumeWhiteSpace());
9✔
70
            $listItem = null;
9✔
71
            if ($usesLenientParsing) {
9✔
72
                try {
73
                    $listItem = self::parseListItem($parserState, $list);
4✔
74
                } catch (UnexpectedTokenException $e) {
×
75
                    $listItem = false;
4✔
76
                }
77
            } else {
78
                $listItem = self::parseListItem($parserState, $list);
5✔
79
            }
80
            if ($listItem === null) {
9✔
81
                // List parsing finished
82
                return;
9✔
83
            }
84
            if ($listItem) {
9✔
85
                $listItem->addComments($comments);
9✔
86
                $list->append($listItem);
9✔
87
            }
88
            $comments = $parserState->consumeWhiteSpace();
9✔
89
        }
90
        $list->addComments($comments);
9✔
91
        if (!$isRoot && !$usesLenientParsing) {
9✔
92
            throw new SourceException('Unexpected end of document', $parserState->currentLine());
×
93
        }
94
    }
9✔
95

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

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

235
    /**
236
     * Tests an identifier for a given value. Since identifiers are all keywords, they can be vendor-prefixed.
237
     * We need to check for these versions too.
238
     */
239
    private static function identifierIs(string $identifier, string $match): bool
9✔
240
    {
241
        if (\strcasecmp($identifier, $match) === 0) {
9✔
242
            return true;
9✔
243
        }
244

245
        return preg_match("/^(-\\w+-)?$match$/i", $identifier) === 1;
8✔
246
    }
247

248
    /**
249
     * Prepends an item to the list of contents.
250
     */
251
    public function prepend(CSSListItem $item): void
×
252
    {
253
        \array_unshift($this->contents, $item);
×
254
    }
×
255

256
    /**
257
     * Appends an item to the list of contents.
258
     */
259
    public function append(CSSListItem $item): void
43✔
260
    {
261
        $this->contents[] = $item;
43✔
262
    }
43✔
263

264
    /**
265
     * Splices the list of contents.
266
     *
267
     * @param array<int, CSSListItem> $replacement
268
     */
269
    public function splice(int $offset, ?int $length = null, ?array $replacement = null): void
×
270
    {
271
        \array_splice($this->contents, $offset, $length, $replacement);
×
272
    }
×
273

274
    /**
275
     * Inserts an item in the CSS list before its sibling. If the desired sibling cannot be found,
276
     * the item is appended at the end.
277
     */
278
    public function insertBefore(CSSListItem $item, CSSListItem $sibling): void
2✔
279
    {
280
        if (\in_array($sibling, $this->contents, true)) {
2✔
281
            $this->replace($sibling, [$item, $sibling]);
1✔
282
        } else {
283
            $this->append($item);
1✔
284
        }
285
    }
2✔
286

287
    /**
288
     * Removes an item from the CSS list.
289
     *
290
     * @param CSSListItem $itemToRemove
291
     *        May be a `RuleSet` (most likely a `DeclarationBlock`), an `Import`,
292
     *        a `Charset` or another `CSSList` (most likely a `MediaQuery`)
293
     *
294
     * @return bool whether the item was removed
295
     */
296
    public function remove(CSSListItem $itemToRemove): bool
×
297
    {
298
        $key = \array_search($itemToRemove, $this->contents, true);
×
299
        if ($key !== false) {
×
300
            unset($this->contents[$key]);
×
301
            return true;
×
302
        }
303

304
        return false;
×
305
    }
306

307
    /**
308
     * Replaces an item from the CSS list.
309
     *
310
     * @param CSSListItem $oldItem
311
     *        May be a `RuleSet` (most likely a `DeclarationBlock`), an `Import`, a `Charset`
312
     *        or another `CSSList` (most likely a `MediaQuery`)
313
     * @param CSSListItem|array<CSSListItem> $newItem
314
     */
315
    public function replace(CSSListItem $oldItem, $newItem): bool
1✔
316
    {
317
        $key = \array_search($oldItem, $this->contents, true);
1✔
318
        if ($key !== false) {
1✔
319
            if (\is_array($newItem)) {
1✔
320
                \array_splice($this->contents, $key, 1, $newItem);
1✔
321
            } else {
322
                \array_splice($this->contents, $key, 1, [$newItem]);
×
323
            }
324
            return true;
1✔
325
        }
326

327
        return false;
×
328
    }
329

330
    /**
331
     * @param array<int, CSSListItem> $contents
332
     */
333
    public function setContents(array $contents): void
35✔
334
    {
335
        $this->contents = [];
35✔
336
        foreach ($contents as $content) {
35✔
337
            $this->append($content);
34✔
338
        }
339
    }
35✔
340

341
    /**
342
     * Removes a declaration block from the CSS list if it matches all given selectors.
343
     *
344
     * @param DeclarationBlock|array<Selector>|string $selectors the selectors to match
345
     * @param bool $removeAll whether to stop at the first declaration block found or remove all blocks
346
     */
347
    public function removeDeclarationBlockBySelector($selectors, bool $removeAll = false): void
8✔
348
    {
349
        if ($selectors instanceof DeclarationBlock) {
8✔
350
            $selectors = $selectors->getSelectors();
2✔
351
        }
352
        if (!\is_array($selectors)) {
8✔
353
            $selectors = \explode(',', $selectors);
×
354
        }
355
        foreach ($selectors as $key => &$selector) {
8✔
356
            if (!($selector instanceof Selector)) {
8✔
357
                if (!Selector::isValid($selector)) {
2✔
358
                    throw new UnexpectedTokenException(
×
359
                        "Selector did not match '" . Selector::SELECTOR_VALIDATION_RX . "'.",
×
360
                        $selector,
361
                        'custom'
×
362
                    );
363
                }
364
                $selector = new Selector($selector);
2✔
365
            }
366
        }
367
        foreach ($this->contents as $key => $item) {
8✔
368
            if (!($item instanceof DeclarationBlock)) {
8✔
369
                continue;
×
370
            }
371
            if ($item->getSelectors() == $selectors) {
8✔
372
                unset($this->contents[$key]);
8✔
373
                if (!$removeAll) {
8✔
374
                    return;
4✔
375
                }
376
            }
377
        }
378
    }
4✔
379

380
    protected function renderListContents(OutputFormat $outputFormat): string
×
381
    {
382
        $result = '';
×
383
        $isFirst = true;
×
384
        $nextLevelFormat = $outputFormat;
×
385
        if (!$this->isRootList()) {
×
386
            $nextLevelFormat = $outputFormat->nextLevel();
×
387
        }
388
        $nextLevelFormatter = $nextLevelFormat->getFormatter();
×
389
        $formatter = $outputFormat->getFormatter();
×
390
        foreach ($this->contents as $listItem) {
×
391
            $renderedCss = $formatter->safely(static function () use ($nextLevelFormat, $listItem): string {
392
                return $listItem->render($nextLevelFormat);
×
393
            });
×
394
            if ($renderedCss === null) {
×
395
                continue;
×
396
            }
397
            if ($isFirst) {
×
398
                $isFirst = false;
×
399
                $result .= $nextLevelFormatter->spaceBeforeBlocks();
×
400
            } else {
401
                $result .= $nextLevelFormatter->spaceBetweenBlocks();
×
402
            }
403
            $result .= $renderedCss;
×
404
        }
405

406
        if (!$isFirst) {
×
407
            // Had some output
408
            $result .= $formatter->spaceAfterBlocks();
×
409
        }
410

411
        return $result;
×
412
    }
413

414
    /**
415
     * Return true if the list can not be further outdented. Only important when rendering.
416
     */
417
    abstract public function isRootList(): bool;
418

419
    /**
420
     * Returns the stored items.
421
     *
422
     * @return array<int<0, max>, CSSListItem>
423
     */
424
    public function getContents(): array
32✔
425
    {
426
        return $this->contents;
32✔
427
    }
428
}
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