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

MyIntervals / PHP-CSS-Parser / 16502040178

24 Jul 2025 04:07PM UTC coverage: 59.595% (-0.2%) from 59.816%
16502040178

Pull #1194

github

web-flow
Merge cec8497d1 into ac31718ae
Pull Request #1194: [TASK] Use delegation for `DeclarationBlock` -> `RuleSet`

29 of 29 new or added lines in 2 files covered. (100.0%)

15 existing lines in 1 file now uncovered.

1118 of 1876 relevant lines covered (59.59%)

25.29 hits per line

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

78.0
/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\Rule\Rule;
17

18
/**
19
 * This class is a container for individual 'Rule'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 `addRule(Rule $rule)`, `getRules()` and `removeRule($rule)`
25
 * (which accepts either a `Rule` or a rule name; optionally suffixed by a dash to remove all related rules).
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>, Rule>>
39
     */
40
    private $rules = [];
41

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

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

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

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

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

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

169
        \array_splice($this->rules[$propertyName], $position, 0, [$ruleToAdd]);
333✔
170
    }
333✔
171

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

206
        return $result;
334✔
207
    }
208

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

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

245
        return $result;
50✔
246
    }
247

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

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

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

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

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

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

331
        return $formatter->removeLastSemicolon($result);
6✔
332
    }
333

334
    /**
335
     * @return int negative if `$first` is before `$second`; zero if they have the same position; positive otherwise
336
     *
337
     * @throws \UnexpectedValueException if either argument does not have a valid position, which should never happen
338
     */
339
    private static function comparePositionable(Positionable $first, Positionable $second): int
178✔
340
    {
341
        $firstsLineNumber = $first->getLineNumber();
178✔
342
        $secondsLineNumber = $second->getLineNumber();
178✔
343
        if (!\is_int($firstsLineNumber) || !\is_int($secondsLineNumber)) {
178✔
344
            throw new \UnexpectedValueException(
×
345
                'A Rule without a line number was passed to comparePositionable',
×
346
                1750637683
×
347
            );
348
        }
349

350
        if ($firstsLineNumber === $secondsLineNumber) {
178✔
351
            $firstsColumnNumber = $first->getColumnNumber();
19✔
352
            $secondsColumnNumber = $second->getColumnNumber();
19✔
353
            if (!\is_int($firstsColumnNumber) || !\is_int($secondsColumnNumber)) {
19✔
354
                throw new \UnexpectedValueException(
×
355
                    'A Rule without a column number was passed to comparePositionable',
×
356
                    1750637761
×
357
                );
358
            }
359
            return $firstsColumnNumber - $secondsColumnNumber;
19✔
360
        }
361

362
        return $firstsLineNumber - $secondsLineNumber;
174✔
363
    }
364

365
    private function hasRule(Rule $rule): bool
66✔
366
    {
367
        foreach ($this->rules as $rulesForAProperty) {
66✔
368
            if (\in_array($rule, $rulesForAProperty, true)) {
66✔
369
                return true;
30✔
370
            }
371
        }
372

373
        return false;
36✔
374
    }
375
}
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

© 2025 Coveralls, Inc