Skip to content

Commit ef431d7

Browse files
committed
[BUGFIX] Allow comma-separated arguments in selectors
Fixes #138, #360 and #1289.
1 parent 8e45197 commit ef431d7

File tree

4 files changed

+100
-3
lines changed

4 files changed

+100
-3
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ Please also have a look at our
110110

111111
### Fixed
112112

113+
- Selector functions (like `:not`) with comma-separated arguments are now
114+
parsed correclty (#1292)
113115
- Allow comma in selectors (e.g. `:not(html, body)`) (#1293)
114116
- Insert `Rule` before sibling even with different property name
115117
(in `RuleSet::addRule()`) (#1270)

src/Parsing/ParserState.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,10 @@ public function consumeUntil(
345345
$start = $this->currentPosition;
346346

347347
while (!$this->isEnd()) {
348+
$comment = $this->consumeComment();
349+
if ($comment instanceof Comment) {
350+
$comments[] = $comment;
351+
}
348352
$character = $this->consume(1);
349353
if (\in_array($character, $stopCharacters, true)) {
350354
if ($includeEnd) {

src/RuleSet/DeclarationBlock.php

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,13 @@ public static function parse(ParserState $parserState, ?CSSList $list = null): ?
4040
$comments = [];
4141
$result = new DeclarationBlock($parserState->currentLine());
4242
try {
43+
$selectors = [];
4344
$selectorParts = [];
4445
$stringWrapperCharacter = null;
46+
$functionNestingLevel = 0;
4547
do {
4648
$selectorParts[] = $parserState->consume(1)
47-
. $parserState->consumeUntil(['{', '}', '\'', '"'], false, false, $comments);
49+
. $parserState->consumeUntil(['{', '}', '\'', '"', '(', ')', ','], false, false, $comments);
4850
$nextCharacter = $parserState->peek();
4951
switch ($nextCharacter) {
5052
case '\'':
@@ -58,9 +60,30 @@ public static function parse(ParserState $parserState, ?CSSList $list = null): ?
5860
}
5961
}
6062
break;
63+
case '(':
64+
if (!isset($stringWrapperCharacter)) {
65+
++$functionNestingLevel;
66+
}
67+
break;
68+
case ')':
69+
if (!isset($stringWrapperCharacter)) {
70+
if ($functionNestingLevel <= 0) {
71+
throw new UnexpectedTokenException('anything but', ')');
72+
}
73+
--$functionNestingLevel;
74+
}
75+
break;
76+
case ',':
77+
if (!isset($stringWrapperCharacter) && $functionNestingLevel === 0) {
78+
$selectors[] = \implode('', $selectorParts);
79+
$selectorParts = [];
80+
$parserState->consume(1);
81+
}
82+
break;
6183
}
6284
} while (!\in_array($nextCharacter, ['{', '}'], true) || \is_string($stringWrapperCharacter));
63-
$result->setSelectors(\implode('', $selectorParts), $list);
85+
$selectors[] = \implode('', $selectorParts); // add final or only selector
86+
$result->setSelectors($selectors, $list);
6487
if ($parserState->comes('{')) {
6588
$parserState->consume(1);
6689
}
@@ -97,7 +120,7 @@ public function setSelectors($selectors, ?CSSList $list = null): void
97120
if (!Selector::isValid($selector)) {
98121
throw new UnexpectedTokenException(
99122
"Selector did not match '" . Selector::SELECTOR_VALIDATION_RX . "'.",
100-
$selectors,
123+
$selector,
101124
'custom'
102125
);
103126
}

tests/Unit/RuleSet/DeclarationBlockTest.php

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66

77
use PHPUnit\Framework\TestCase;
88
use Sabberworm\CSS\CSSList\CSSListItem;
9+
use Sabberworm\CSS\Parsing\ParserState;
10+
use Sabberworm\CSS\Property\Selector;
911
use Sabberworm\CSS\RuleSet\DeclarationBlock;
12+
use Sabberworm\CSS\Settings;
13+
use TRegx\PhpUnit\DataProviders\DataProvider;
1014

1115
/**
1216
* @covers \Sabberworm\CSS\RuleSet\DeclarationBlock
@@ -30,4 +34,68 @@ public function implementsCSSListItem(): void
3034
{
3135
self::assertInstanceOf(CSSListItem::class, $this->subject);
3236
}
37+
38+
/**
39+
* @return array<non-empty-string, array{0: non-empty-string}>
40+
*/
41+
public static function provideSelector(): array
42+
{
43+
return [
44+
'type' => ['body'],
45+
'class' => ['.teapot'],
46+
'id' => ['#my-mug'],
47+
'`not`' => [':not(#your-mug)'],
48+
'`not` with multiple arguments' => [':not(#your-mug, .their-mug)'],
49+
];
50+
}
51+
52+
/**
53+
* @test
54+
*
55+
* @param non-empty-string $selector
56+
*
57+
* @dataProvider provideSelector
58+
*/
59+
public function parsesAndReturnsSingleSelector(string $selector): void
60+
{
61+
$subject = DeclarationBlock::parse(new ParserState($selector . '{}', Settings::create()));
62+
63+
$resultSelectorStrings = \array_map(
64+
static function (Selector $selectorObject): string {
65+
return $selectorObject->getSelector();
66+
},
67+
$subject->getSelectors()
68+
);
69+
self::assertSame([$selector], $resultSelectorStrings);
70+
}
71+
72+
/**
73+
* @return DataProvider<non-empty-string, array{0: non-empty-string, 1: non-empty-string}>
74+
*/
75+
public static function provideTwoSelectors(): DataProvider
76+
{
77+
return DataProvider::cross(self::provideSelector(), self::provideSelector());
78+
}
79+
80+
/**
81+
* @test
82+
*
83+
* @param non-empty-string $firstSelector
84+
* @param non-empty-string $secondSelector
85+
*
86+
* @dataProvider provideTwoSelectors
87+
*/
88+
public function parsesAndReturnsTwoCommaSeparatedSelectors(string $firstSelector, string $secondSelector): void
89+
{
90+
$joinedSelectors = $firstSelector . ',' . $secondSelector;
91+
$subject = DeclarationBlock::parse(new ParserState($joinedSelectors . '{}', Settings::create()));
92+
93+
$resultSelectorStrings = \array_map(
94+
static function (Selector $selectorObject): string {
95+
return $selectorObject->getSelector();
96+
},
97+
$subject->getSelectors()
98+
);
99+
self::assertSame([$firstSelector, $secondSelector], $resultSelectorStrings);
100+
}
33101
}

0 commit comments

Comments
 (0)