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

MyIntervals / PHP-CSS-Parser / 22034112410

15 Feb 2026 10:22AM UTC coverage: 72.791%. Remained the same
22034112410

push

github

web-flow
[CLEANUP] Correct the DocBlock for `RuleSet` (#1523)

`RemoveRule` was split up in #1249 to avoid parameter type overloading,
but updating the class DocBlock was missed then.

The class DocBlock should not be describing what individual methods do,
so that additional duplicated information has been removed.
It exists in the DocBlock for the method.

1549 of 2128 relevant lines covered (72.79%)

33.92 hits per line

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

77.92
/src/RuleSet/RuleSet.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Sabberworm\CSS\RuleSet;
6

7
use Sabberworm\CSS\Comment\CommentContainer;
8
use Sabberworm\CSS\CSSElement;
9
use Sabberworm\CSS\CSSList\CSSListItem;
10
use Sabberworm\CSS\OutputFormat;
11
use Sabberworm\CSS\Parsing\ParserState;
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\Declaration;
17

18
/**
19
 * This class is a container for individual `Declaration`s.
20
 *
21
 * The most common form of a rule set is one constrained by a selector, i.e., a `DeclarationBlock`.
22
 * However, unknown `AtRule`s (like `@font-face`) are rule sets as well.
23
 *
24
 * If you want to manipulate a `RuleSet`,
25
 * use the methods `addRule()`, `getRules()`, `removeRule()`, `removeMatchingRules()`, etc.
26
 *
27
 * Note that `CSSListItem` extends both `Commentable` and `Renderable`, so those interfaces must also be implemented.
28
 */
29
class RuleSet implements CSSElement, CSSListItem, Positionable, RuleContainer
30
{
31
    use CommentContainer;
32
    use Position;
33

34
    /**
35
     * the rules in this rule set, using the property name as the key,
36
     * with potentially multiple rules per property name.
37
     *
38
     * @var array<string, array<int<0, max>, Declaration>>
39
     */
40
    private $declarations = [];
41

42
    /**
43
     * @param int<1, max>|null $lineNumber
44
     */
45
    public function __construct(?int $lineNumber = null)
354✔
46
    {
47
        $this->setPosition($lineNumber);
354✔
48
    }
354✔
49

50
    /**
51
     * @throws UnexpectedTokenException
52
     * @throws UnexpectedEOFException
53
     *
54
     * @internal since V8.8.0
55
     */
56
    public static function parseRuleSet(ParserState $parserState, RuleSet $ruleSet): void
×
57
    {
58
        while ($parserState->comes(';')) {
×
59
            $parserState->consume(';');
×
60
        }
61
        while (true) {
×
62
            $commentsBeforeRule = [];
×
63
            $parserState->consumeWhiteSpace($commentsBeforeRule);
×
64
            if ($parserState->comes('}')) {
×
65
                break;
×
66
            }
67
            $declaration = null;
×
68
            if ($parserState->getSettings()->usesLenientParsing()) {
×
69
                try {
70
                    $declaration = Declaration::parse($parserState, $commentsBeforeRule);
×
71
                } catch (UnexpectedTokenException $e) {
×
72
                    try {
73
                        $consumedText = $parserState->consumeUntil(["\n", ';', '}'], true);
×
74
                        // We need to “unfind” the matches to the end of the ruleSet as this will be matched later
75
                        if ($parserState->streql(\substr($consumedText, -1), '}')) {
×
76
                            $parserState->backtrack(1);
×
77
                        } else {
78
                            while ($parserState->comes(';')) {
×
79
                                $parserState->consume(';');
×
80
                            }
81
                        }
82
                    } catch (UnexpectedTokenException $e) {
×
83
                        // We’ve reached the end of the document. Just close the RuleSet.
84
                        return;
×
85
                    }
86
                }
87
            } else {
88
                $declaration = Declaration::parse($parserState, $commentsBeforeRule);
×
89
            }
90
            if ($declaration instanceof Declaration) {
×
91
                $ruleSet->addRule($declaration);
×
92
            }
93
        }
94
        $parserState->consume('}');
×
95
    }
×
96

97
    /**
98
     * @throws \UnexpectedValueException
99
     *         if the last `Declaration` is needed as a basis for setting position, but does not have a valid position,
100
     *         which should never happen
101
     */
102
    public function addRule(Declaration $declarationToAdd, ?Declaration $sibling = null): void
333✔
103
    {
104
        $propertyName = $declarationToAdd->getPropertyName();
333✔
105
        if (!isset($this->declarations[$propertyName])) {
333✔
106
            $this->declarations[$propertyName] = [];
333✔
107
        }
108

109
        $position = \count($this->declarations[$propertyName]);
333✔
110

111
        if ($sibling !== null) {
333✔
112
            $siblingIsInSet = false;
81✔
113
            $siblingPosition = \array_search($sibling, $this->declarations[$propertyName], true);
81✔
114
            if ($siblingPosition !== false) {
81✔
115
                $siblingIsInSet = true;
15✔
116
                $position = $siblingPosition;
15✔
117
            } else {
118
                $siblingIsInSet = $this->hasRule($sibling);
66✔
119
                if ($siblingIsInSet) {
66✔
120
                    // Maintain ordering within `$this->declarations[$propertyName]`
121
                    // by inserting before first `Declaration` with a same-or-later position than the sibling.
122
                    foreach ($this->declarations[$propertyName] as $index => $declaration) {
30✔
123
                        if (self::comparePositionable($declaration, $sibling) >= 0) {
6✔
124
                            $position = $index;
3✔
125
                            break;
3✔
126
                        }
127
                    }
128
                }
129
            }
130
            if ($siblingIsInSet) {
81✔
131
                // Increment column number of all existing rules on same line, starting at sibling
132
                $siblingLineNumber = $sibling->getLineNumber();
45✔
133
                $siblingColumnNumber = $sibling->getColumnNumber();
45✔
134
                foreach ($this->declarations as $declarationsForAProperty) {
45✔
135
                    foreach ($declarationsForAProperty as $declaration) {
45✔
136
                        if (
137
                            $declaration->getLineNumber() === $siblingLineNumber &&
45✔
138
                            $declaration->getColumnNumber() >= $siblingColumnNumber
45✔
139
                        ) {
140
                            $declaration->setPosition($siblingLineNumber, $declaration->getColumnNumber() + 1);
45✔
141
                        }
142
                    }
143
                }
144
                $declarationToAdd->setPosition($siblingLineNumber, $siblingColumnNumber);
45✔
145
            }
146
        }
147

148
        if ($declarationToAdd->getLineNumber() === null) {
333✔
149
            //this node is added manually, give it the next best line
150
            $columnNumber = $declarationToAdd->getColumnNumber() ?? 0;
310✔
151
            $declarations = $this->getRules();
310✔
152
            $declarationsCount = \count($declarations);
310✔
153
            if ($declarationsCount > 0) {
310✔
154
                $last = $declarations[$declarationsCount - 1];
228✔
155
                $lastsLineNumber = $last->getLineNumber();
228✔
156
                if (!\is_int($lastsLineNumber)) {
228✔
157
                    throw new \UnexpectedValueException(
×
158
                        'A Declaration without a line number was found during addRule',
×
159
                        1750718399
×
160
                    );
161
                }
162
                $declarationToAdd->setPosition($lastsLineNumber + 1, $columnNumber);
228✔
163
            } else {
164
                $declarationToAdd->setPosition(1, $columnNumber);
310✔
165
            }
166
        } elseif ($declarationToAdd->getColumnNumber() === null) {
113✔
167
            $declarationToAdd->setPosition($declarationToAdd->getLineNumber(), 0);
38✔
168
        }
169

170
        \array_splice($this->declarations[$propertyName], $position, 0, [$declarationToAdd]);
333✔
171
    }
333✔
172

173
    /**
174
     * Returns all rules matching the given rule name
175
     *
176
     * @example $ruleSet->getRules('font') // returns array(0 => $declaration, …) or array().
177
     *
178
     * @example $ruleSet->getRules('font-')
179
     *          //returns an array of all rules either beginning with font- or matching font.
180
     *
181
     * @param string|null $searchPattern
182
     *        Pattern to search for. If null, returns all rules.
183
     *        If the pattern ends with a dash, all rules starting with the pattern are returned
184
     *        as well as one matching the pattern with the dash excluded.
185
     *
186
     * @return array<int<0, max>, Declaration>
187
     */
188
    public function getRules(?string $searchPattern = null): array
334✔
189
    {
190
        $result = [];
334✔
191
        foreach ($this->declarations as $propertyName => $declarations) {
334✔
192
            // Either no search rule is given or the search rule matches the found rule exactly
193
            // or the search rule ends in “-” and the found rule starts with the search rule.
194
            if (
195
                $searchPattern === null || $propertyName === $searchPattern
321✔
196
                || (
197
                    \strrpos($searchPattern, '-') === \strlen($searchPattern) - \strlen('-')
27✔
198
                    && (\strpos($propertyName, $searchPattern) === 0
13✔
199
                        || $propertyName === \substr($searchPattern, 0, -1))
321✔
200
                )
201
            ) {
202
                $result = \array_merge($result, $declarations);
321✔
203
            }
204
        }
205
        \usort($result, [self::class, 'comparePositionable']);
334✔
206

207
        return $result;
334✔
208
    }
209

210
    /**
211
     * Overrides all the rules of this set.
212
     *
213
     * @param array<Declaration> $declarations The rules to override with.
214
     */
215
    public function setRules(array $declarations): void
346✔
216
    {
217
        $this->declarations = [];
346✔
218
        foreach ($declarations as $declaration) {
346✔
219
            $this->addRule($declaration);
291✔
220
        }
221
    }
346✔
222

223
    /**
224
     * Returns all rules matching the given pattern and returns them in an associative array with the rule’s name
225
     * as keys. This method exists mainly for backwards-compatibility and is really only partially useful.
226
     *
227
     * Note: This method loses some information: Calling this (with an argument of `background-`) on a declaration block
228
     * like `{ background-color: green; background-color; rgba(0, 127, 0, 0.7); }` will only yield an associative array
229
     * containing the rgba-valued rule while `getRules()` would yield an indexed array containing both.
230
     *
231
     * @param string|null $searchPattern
232
     *        Pattern to search for. If null, returns all rules. If the pattern ends with a dash,
233
     *        all rules starting with the pattern are returned as well as one matching the pattern with the dash
234
     *        excluded.
235
     *
236
     * @return array<string, Declaration>
237
     */
238
    public function getRulesAssoc(?string $searchPattern = null): array
50✔
239
    {
240
        /** @var array<string, Declaration> $result */
241
        $result = [];
50✔
242
        foreach ($this->getRules($searchPattern) as $declaration) {
50✔
243
            $result[$declaration->getPropertyName()] = $declaration;
34✔
244
        }
245

246
        return $result;
50✔
247
    }
248

249
    /**
250
     * Removes a `Declaration` from this `RuleSet` by identity.
251
     */
252
    public function removeRule(Declaration $declarationToRemove): void
22✔
253
    {
254
        $nameOfPropertyToRemove = $declarationToRemove->getPropertyName();
22✔
255
        if (!isset($this->declarations[$nameOfPropertyToRemove])) {
22✔
256
            return;
8✔
257
        }
258
        foreach ($this->declarations[$nameOfPropertyToRemove] as $key => $declaration) {
14✔
259
            if ($declaration === $declarationToRemove) {
14✔
260
                unset($this->declarations[$nameOfPropertyToRemove][$key]);
10✔
261
            }
262
        }
263
    }
14✔
264

265
    /**
266
     * Removes rules by property name or search pattern.
267
     *
268
     * @param string $searchPattern
269
     *        pattern to remove.
270
     *        If the pattern ends in a dash,
271
     *        all rules starting with the pattern are removed as well as one matching the pattern with the dash
272
     *        excluded.
273
     */
274
    public function removeMatchingRules(string $searchPattern): void
28✔
275
    {
276
        foreach ($this->declarations as $propertyName => $declarations) {
28✔
277
            // Either the search rule matches the found rule exactly
278
            // or the search rule ends in “-” and the found rule starts with the search rule or equals it
279
            // (without the trailing dash).
280
            if (
281
                $propertyName === $searchPattern
26✔
282
                || (\strrpos($searchPattern, '-') === \strlen($searchPattern) - \strlen('-')
22✔
283
                    && (\strpos($propertyName, $searchPattern) === 0
12✔
284
                        || $propertyName === \substr($searchPattern, 0, -1)))
26✔
285
            ) {
286
                unset($this->declarations[$propertyName]);
24✔
287
            }
288
        }
289
    }
28✔
290

291
    public function removeAllRules(): void
4✔
292
    {
293
        $this->declarations = [];
4✔
294
    }
4✔
295

296
    /**
297
     * @internal
298
     */
299
    public function render(OutputFormat $outputFormat): string
6✔
300
    {
301
        return $this->renderRules($outputFormat);
6✔
302
    }
303

304
    protected function renderRules(OutputFormat $outputFormat): string
6✔
305
    {
306
        $result = '';
6✔
307
        $isFirst = true;
6✔
308
        $nextLevelFormat = $outputFormat->nextLevel();
6✔
309
        foreach ($this->getRules() as $declaration) {
6✔
310
            $nextLevelFormatter = $nextLevelFormat->getFormatter();
5✔
311
            $renderedRule = $nextLevelFormatter->safely(
5✔
312
                static function () use ($declaration, $nextLevelFormat): string {
313
                    return $declaration->render($nextLevelFormat);
5✔
314
                }
5✔
315
            );
316
            if ($renderedRule === null) {
5✔
317
                continue;
×
318
            }
319
            if ($isFirst) {
5✔
320
                $isFirst = false;
5✔
321
                $result .= $nextLevelFormatter->spaceBeforeRules();
5✔
322
            } else {
323
                $result .= $nextLevelFormatter->spaceBetweenRules();
4✔
324
            }
325
            $result .= $renderedRule;
5✔
326
        }
327

328
        $formatter = $outputFormat->getFormatter();
6✔
329
        if (!$isFirst) {
6✔
330
            // Had some output
331
            $result .= $formatter->spaceAfterRules();
5✔
332
        }
333

334
        return $formatter->removeLastSemicolon($result);
6✔
335
    }
336

337
    /**
338
     * @return array<string, bool|int|float|string|array<mixed>|null>
339
     *
340
     * @internal
341
     */
342
    public function getArrayRepresentation(): array
1✔
343
    {
344
        throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`');
1✔
345
    }
346

347
    /**
348
     * @return int negative if `$first` is before `$second`; zero if they have the same position; positive otherwise
349
     *
350
     * @throws \UnexpectedValueException if either argument does not have a valid position, which should never happen
351
     */
352
    private static function comparePositionable(Positionable $first, Positionable $second): int
178✔
353
    {
354
        $firstsLineNumber = $first->getLineNumber();
178✔
355
        $secondsLineNumber = $second->getLineNumber();
178✔
356
        if (!\is_int($firstsLineNumber) || !\is_int($secondsLineNumber)) {
178✔
357
            throw new \UnexpectedValueException(
×
358
                'A Declaration without a line number was passed to comparePositionable',
×
359
                1750637683
×
360
            );
361
        }
362

363
        if ($firstsLineNumber === $secondsLineNumber) {
178✔
364
            $firstsColumnNumber = $first->getColumnNumber();
19✔
365
            $secondsColumnNumber = $second->getColumnNumber();
19✔
366
            if (!\is_int($firstsColumnNumber) || !\is_int($secondsColumnNumber)) {
19✔
367
                throw new \UnexpectedValueException(
×
368
                    'A Declaration without a column number was passed to comparePositionable',
×
369
                    1750637761
×
370
                );
371
            }
372
            return $firstsColumnNumber - $secondsColumnNumber;
19✔
373
        }
374

375
        return $firstsLineNumber - $secondsLineNumber;
174✔
376
    }
377

378
    private function hasRule(Declaration $declaration): bool
66✔
379
    {
380
        foreach ($this->declarations as $declarationsForAProperty) {
66✔
381
            if (\in_array($declaration, $declarationsForAProperty, true)) {
66✔
382
                return true;
30✔
383
            }
384
        }
385

386
        return false;
36✔
387
    }
388
}
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