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

MyIntervals / PHP-CSS-Parser / 22021135028

14 Feb 2026 05:08PM UTC coverage: 73.032% (+0.2%) from 72.791%
22021135028

Pull #1521

github

web-flow
Merge 87e9ba5ce into f6ddd4847
Pull Request #1521: [TASK] Rename methods in `RuleContainer`

51 of 58 new or added lines in 4 files covered. (87.93%)

3 existing lines in 1 file now uncovered.

1568 of 2147 relevant lines covered (73.03%)

35.76 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`, use the methods `addDeclaration()`, `getDeclarations()`, `removeDeclaration()`
25
 * and `removeMatchingDeclarations()`.
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 LegacyRuleContainerMethods;
33
    use Position;
34

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

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

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

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

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

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

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

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

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

208
        return $result;
334✔
209
    }
210

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

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

248
        return $result;
50✔
249
    }
250

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

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

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

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

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

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

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

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

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

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

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

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

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