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

MyIntervals / PHP-CSS-Parser / 13344956838

15 Feb 2025 12:20PM UTC coverage: 50.42% (-0.08%) from 50.498%
13344956838

Pull #925

github

web-flow
Merge 2ed8ad3a9 into e1fa3b678
Pull Request #925: [TASK] Drop redundant `OutputException` constructor

960 of 1904 relevant lines covered (50.42%)

11.56 hits per line

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

0.0
/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($lineNumber = 0)
×
61
    {
62
        $this->comments = [];
×
63
        $this->contents = [];
×
64
        $this->lineNumber = $lineNumber;
×
65
    }
×
66

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

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

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

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

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

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

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

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

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

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

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

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

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

407
    public function __toString(): string
×
408
    {
409
        return $this->render(new OutputFormat());
×
410
    }
411

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

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

444
        return $result;
×
445
    }
446

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

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

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

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

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