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

MyIntervals / PHP-CSS-Parser / 22034052586

15 Feb 2026 10:23AM UTC coverage: 72.791%. Remained the same
22034052586

push

github

web-flow
[CLEANUP] Tidy up comments in `RuleSet` (#1524)

Both in DocBlock and inline code comments.

Make them more precise, and refer to 'declaration' rather than 'rule' where
applicable.

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 declarations in this rule set, using the property name as the key,
36
     * with potentially multiple declarations 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 declarations matching the given property 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 declarations either beginning with font- or matching font.
180
     *
181
     * @param string|null $searchPattern
182
     *        Pattern to search for. If null, returns all declarations.
183
     *        If the pattern ends with a dash, all declarations 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 pattern was given
193
            // or the search pattern matches the found declaration's property name exactly
194
            // or the search pattern ends in “-”
195
            // ... and the found declaration's property name starts with the search pattern
196
            if (
197
                $searchPattern === null || $propertyName === $searchPattern
321✔
198
                || (
199
                    \strrpos($searchPattern, '-') === \strlen($searchPattern) - \strlen('-')
27✔
200
                    && (\strpos($propertyName, $searchPattern) === 0
13✔
201
                        || $propertyName === \substr($searchPattern, 0, -1))
321✔
202
                )
203
            ) {
204
                $result = \array_merge($result, $declarations);
321✔
205
            }
206
        }
207
        \usort($result, [self::class, 'comparePositionable']);
334✔
208

209
        return $result;
334✔
210
    }
211

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

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

249
        return $result;
50✔
250
    }
251

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

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

294
    public function removeAllRules(): void
4✔
295
    {
296
        $this->declarations = [];
4✔
297
    }
4✔
298

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

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

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

337
        return $formatter->removeLastSemicolon($result);
6✔
338
    }
339

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

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

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

378
        return $firstsLineNumber - $secondsLineNumber;
174✔
379
    }
380

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

389
        return false;
36✔
390
    }
391
}
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