diff --git a/CHANGELOG.md b/CHANGELOG.md index 08dfef29..a40e5ff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,6 +110,7 @@ Please also have a look at our ### Fixed +- Parse selector functions (like `:not`) with comma-separated arguments (#1292) - Parse quoted attribute selector value containing comma (#1323) - Allow comma in selectors (e.g. `:not(html, body)`) (#1293) - Insert `Rule` before sibling even with different property name diff --git a/src/RuleSet/DeclarationBlock.php b/src/RuleSet/DeclarationBlock.php index d0b9654c..68926deb 100644 --- a/src/RuleSet/DeclarationBlock.php +++ b/src/RuleSet/DeclarationBlock.php @@ -43,8 +43,9 @@ public static function parse(ParserState $parserState, ?CSSList $list = null): ? $selectors = []; $selectorParts = []; $stringWrapperCharacter = null; + $functionNestingLevel = 0; $consumedNextCharacter = false; - static $stopCharacters = ['{', '}', '\'', '"', ',']; + static $stopCharacters = ['{', '}', '\'', '"', '(', ')', ',']; do { if (!$consumedNextCharacter) { $selectorParts[] = $parserState->consume(1); @@ -64,8 +65,21 @@ public static function parse(ParserState $parserState, ?CSSList $list = null): ? } } break; - case ',': + case '(': if (!\is_string($stringWrapperCharacter)) { + ++$functionNestingLevel; + } + break; + case ')': + if (!\is_string($stringWrapperCharacter)) { + if ($functionNestingLevel <= 0) { + throw new UnexpectedTokenException('anything but', ')'); + } + --$functionNestingLevel; + } + break; + case ',': + if (!\is_string($stringWrapperCharacter) && $functionNestingLevel === 0) { $selectors[] = \implode('', $selectorParts); $selectorParts = []; $parserState->consume(1); @@ -74,6 +88,9 @@ public static function parse(ParserState $parserState, ?CSSList $list = null): ? break; } } while (!\in_array($nextCharacter, ['{', '}'], true) || \is_string($stringWrapperCharacter)); + if ($functionNestingLevel !== 0) { + throw new UnexpectedTokenException(')', $nextCharacter); + } $selectors[] = \implode('', $selectorParts); // add final or only selector $result->setSelectors($selectors, $list); if ($parserState->comes('{')) { diff --git a/tests/Unit/RuleSet/DeclarationBlockTest.php b/tests/Unit/RuleSet/DeclarationBlockTest.php index 468a8515..4419ba0f 100644 --- a/tests/Unit/RuleSet/DeclarationBlockTest.php +++ b/tests/Unit/RuleSet/DeclarationBlockTest.php @@ -67,6 +67,7 @@ public static function provideSelector(): array 'pseudo-class' => [':hover'], 'type & pseudo-class' => ['a:hover'], '`not`' => [':not(#your-mug)'], + '`not` with multiple arguments' => [':not(#your-mug, .their-mug)'], 'pseudo-element' => ['::before'], 'attribute with `"`' => ['[alt="{}()[]\\"\',"]'], 'attribute with `\'`' => ['[alt=\'{}()[]"\\\',\']'], @@ -114,6 +115,40 @@ public function parsesTwoCommaSeparatedSelectors(string $firstSelector, string $ self::assertSame([$firstSelector, $secondSelector], self::getSelectorsAsStrings($subject)); } + /** + * @return array + */ + public static function provideInvalidSelector(): array + { + // TODO: the `parse` method consumes the first character without inspection, + // so the 'lone' test strings are prefixed with a space. + return [ + 'lone `(`' => [' ('], + 'lone `)`' => [' )'], + 'unclosed `(`' => [':not(#your-mug'], + 'extra `)`' => [':not(#your-mug))'], + ]; + } + + /** + * @test + * + * @param non-empty-string $selector + * + * @dataProvider provideInvalidSelector + */ + public function parseSkipsBlockWithInvalidSelector(string $selector): void + { + static $nextCss = ' .next {}'; + $css = $selector . ' {}' . $nextCss; + $parserState = new ParserState($css, Settings::create()); + + $subject = DeclarationBlock::parse($parserState); + + self::assertNull($subject); + self::assertTrue($parserState->comes($nextCss)); + } + /** * @return array */