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

MyIntervals / PHP-CSS-Parser / 15836356191

23 Jun 2025 10:20PM UTC coverage: 58.049% (-0.05%) from 58.102%
15836356191

Pull #1283

github

web-flow
Merge d29896264 into b0ce7af89
Pull Request #1283: [TASK] Update `RuleSet::comparePositionable` to use new methods

9 of 15 new or added lines in 1 file covered. (60.0%)

1053 of 1814 relevant lines covered (58.05%)

16.73 hits per line

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

89.51
/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`,
28
 * so those interfaces must also be implemented by concrete subclasses.
29
 */
30
abstract class RuleSet implements CSSElement, CSSListItem, Positionable, RuleContainer
31
{
32
    use CommentContainer;
33
    use Position;
34

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

43
    /**
44
     * @param int<0, max> $lineNumber
45
     */
46
    public function __construct(int $lineNumber = 0)
349✔
47
    {
48
        $this->setPosition($lineNumber);
349✔
49
    }
349✔
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
6✔
58
    {
59
        while ($parserState->comes(';')) {
6✔
60
            $parserState->consume(';');
×
61
        }
62
        while (true) {
6✔
63
            $commentsBeforeRule = $parserState->consumeWhiteSpace();
6✔
64
            if ($parserState->comes('}')) {
6✔
65
                break;
6✔
66
            }
67
            $rule = null;
6✔
68
            if ($parserState->getSettings()->usesLenientParsing()) {
6✔
69
                try {
70
                    $rule = Rule::parse($parserState, $commentsBeforeRule);
4✔
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;
4✔
85
                    }
86
                }
87
            } else {
88
                $rule = Rule::parse($parserState, $commentsBeforeRule);
2✔
89
            }
90
            if ($rule instanceof Rule) {
6✔
91
                $ruleSet->addRule($rule);
6✔
92
            }
93
        }
94
        $parserState->consume('}');
6✔
95
    }
6✔
96

97
    public function addRule(Rule $ruleToAdd, ?Rule $sibling = null): void
334✔
98
    {
99
        $propertyName = $ruleToAdd->getRule();
334✔
100
        if (!isset($this->rules[$propertyName])) {
334✔
101
            $this->rules[$propertyName] = [];
334✔
102
        }
103

104
        $position = \count($this->rules[$propertyName]);
334✔
105

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

143
        if ($ruleToAdd->getLineNumber() === null) {
334✔
144
            //this node is added manually, give it the next best line
145
            $columnNumber = $ruleToAdd->getColumnNumber() ?? 0;
307✔
146
            $rules = $this->getRules();
307✔
147
            $rulesCount = \count($rules);
307✔
148
            if ($rulesCount > 0) {
307✔
149
                $last = $rules[$rulesCount - 1];
225✔
150
                $ruleToAdd->setPosition($last->getLineNo() + 1, $columnNumber);
225✔
151
            } else {
152
                $ruleToAdd->setPosition(1, $columnNumber);
307✔
153
            }
154
        } elseif ($ruleToAdd->getColumnNumber() === null) {
119✔
155
            $ruleToAdd->setPosition($ruleToAdd->getLineNumber(), 0);
38✔
156
        }
157

158
        \array_splice($this->rules[$propertyName], $position, 0, [$ruleToAdd]);
334✔
159
    }
334✔
160

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

195
        return $result;
334✔
196
    }
197

198
    /**
199
     * Overrides all the rules of this set.
200
     *
201
     * @param array<Rule> $rules The rules to override with.
202
     */
203
    public function setRules(array $rules): void
341✔
204
    {
205
        $this->rules = [];
341✔
206
        foreach ($rules as $rule) {
341✔
207
            $this->addRule($rule);
287✔
208
        }
209
    }
341✔
210

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

234
        return $result;
50✔
235
    }
236

237
    /**
238
     * Removes a `Rule` from this `RuleSet` by identity.
239
     */
240
    public function removeRule(Rule $ruleToRemove): void
22✔
241
    {
242
        $nameOfPropertyToRemove = $ruleToRemove->getRule();
22✔
243
        if (!isset($this->rules[$nameOfPropertyToRemove])) {
22✔
244
            return;
8✔
245
        }
246
        foreach ($this->rules[$nameOfPropertyToRemove] as $key => $rule) {
14✔
247
            if ($rule === $ruleToRemove) {
14✔
248
                unset($this->rules[$nameOfPropertyToRemove][$key]);
10✔
249
            }
250
        }
251
    }
14✔
252

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

279
    public function removeAllRules(): void
4✔
280
    {
281
        $this->rules = [];
4✔
282
    }
4✔
283

284
    protected function renderRules(OutputFormat $outputFormat): string
5✔
285
    {
286
        $result = '';
5✔
287
        $isFirst = true;
5✔
288
        $nextLevelFormat = $outputFormat->nextLevel();
5✔
289
        foreach ($this->getRules() as $rule) {
5✔
290
            $nextLevelFormatter = $nextLevelFormat->getFormatter();
5✔
291
            $renderedRule = $nextLevelFormatter->safely(static function () use ($rule, $nextLevelFormat): string {
292
                return $rule->render($nextLevelFormat);
5✔
293
            });
5✔
294
            if ($renderedRule === null) {
5✔
295
                continue;
×
296
            }
297
            if ($isFirst) {
5✔
298
                $isFirst = false;
5✔
299
                $result .= $nextLevelFormatter->spaceBeforeRules();
5✔
300
            } else {
301
                $result .= $nextLevelFormatter->spaceBetweenRules();
1✔
302
            }
303
            $result .= $renderedRule;
5✔
304
        }
305

306
        $formatter = $outputFormat->getFormatter();
5✔
307
        if (!$isFirst) {
5✔
308
            // Had some output
309
            $result .= $formatter->spaceAfterRules();
5✔
310
        }
311

312
        return $formatter->removeLastSemicolon($result);
5✔
313
    }
314

315
    /**
316
     * @return int negative if `$first` is before `$second`; zero if they have the same position; positive otherwise
317
     *
318
     * @throws \UnexpectedValueException if either argument does not have a valid position, which should never happen
319
     */
320
    private static function comparePositionable(Positionable $first, Positionable $second): int
176✔
321
    {
322
        $firstsLineNumber = $first->getLineNumber();
176✔
323
        $secondsLineNumber = $second->getLineNumber();
176✔
324
        if (!\is_int($firstsLineNumber) || !\is_int($secondsLineNumber)) {
176✔
NEW
325
            throw new \UnexpectedValueException(
×
NEW
326
                'A Rule without a line number was passed to comparePositionable',
×
NEW
327
                1750637683
×
328
            );
329
        }
330

331
        if ($firstsLineNumber === $secondsLineNumber) {
176✔
332
            $firstsColumnNumber = $first->getColumnNumber();
21✔
333
            $secondsColumnNumber = $second->getColumnNumber();
21✔
334
            if (!\is_int($firstsColumnNumber) || !\is_int($secondsColumnNumber)) {
21✔
NEW
335
                throw new \UnexpectedValueException(
×
NEW
336
                    'A Rule without a column number was passed to comparePositionable',
×
NEW
337
                    1750637761
×
338
                );
339
            }
340
            return $firstsColumnNumber - $secondsColumnNumber;
21✔
341
        }
342

343
        return $firstsLineNumber - $secondsLineNumber;
171✔
344
    }
345

346
    private function hasRule(Rule $rule): bool
66✔
347
    {
348
        foreach ($this->rules as $rulesForAProperty) {
66✔
349
            if (\in_array($rule, $rulesForAProperty, true)) {
66✔
350
                return true;
30✔
351
            }
352
        }
353

354
        return false;
36✔
355
    }
356
}
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