diff --git a/CHANGELOG.md b/CHANGELOG.md index 4650d9fb..c6313f34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Please also have a look at our ### Fixed - Render very small numbers in `Size` without rounding them to zero (#1647) +- Ignore braces and semicolons inside quoted strings when parsing at-rule preludes (#1650) ### Documentation diff --git a/src/CSSList/CSSList.php b/src/CSSList/CSSList.php index 5ce812f6..7bce6341 100644 --- a/src/CSSList/CSSList.php +++ b/src/CSSList/CSSList.php @@ -212,7 +212,7 @@ private static function parseAtRule(ParserState $parserState): ?CSSListItem return new CSSNamespace($url, $prefix, $identifierLineNumber); } else { // Unknown other at rule (font-face, @layer, or such) - $arguments = \trim($parserState->consumeUntil(['{', ';'], false, false)); + $arguments = self::consumeAtRuleArguments($parserState); if ($parserState->comes(';')) { $parserState->consume(';'); return new AtRuleStatement($identifier, $arguments, $identifierLineNumber); @@ -246,6 +246,44 @@ private static function parseAtRule(ParserState $parserState): ?CSSListItem } } + /** + * Consumes an at-rule prelude without treating braces or semicolons inside strings as delimiters. + * + * @phpstan-impure This method advances the parser state. + */ + private static function consumeAtRuleArguments(ParserState $parserState): string + { + $arguments = ''; + while (!$parserState->isEnd()) { + $arguments .= $parserState->consumeUntil(['{', ';', '"', "'"]); + if ($parserState->comes('{') || $parserState->comes(';')) { + return \trim($arguments); + } + + $quote = $parserState->consume(1); + $trailingBackslashMatches = []; + $matchCount = \preg_match('/(\\\\+)\\z/', $arguments, $trailingBackslashMatches); + // An odd run escapes the quote; an even run leaves it as a delimiter. + $isEscaped = $matchCount === 1 && (\strlen($trailingBackslashMatches[1]) % 2) === 1; + $arguments .= $quote; + if ($isEscaped) { + continue; + } + + while (true) { + $character = $parserState->consume(1); + $arguments .= $character; + if ($character === '\\') { + $arguments .= $parserState->consume(1); + } elseif ($character === $quote) { + break; + } + } + } + + return \trim($arguments); + } + /** * Tests an identifier for a given value. Since identifiers are all keywords, they can be vendor-prefixed. * We need to check for these versions too. diff --git a/tests/Functional/Property/AtRuleStatementTest.php b/tests/Functional/Property/AtRuleStatementTest.php index 2b7c849c..db4322e3 100644 --- a/tests/Functional/Property/AtRuleStatementTest.php +++ b/tests/Functional/Property/AtRuleStatementTest.php @@ -130,4 +130,52 @@ public function rendersParsedLayerStatementWithTrailingSemicolon(): void self::assertStringContainsString('@layer reset;', $rendered); } + + /** + * @test + */ + public function doesNotTreatBracesOrSemicolonsInsideStringsAsAtRuleDelimiters(): void + { + $css = '@media (custom: "a;b{c}") { .inside { color: red; } } .after { color: blue; }'; + + $contents = (new Parser($css))->parse()->getContents(); + + self::assertCount(2, $contents); + self::assertInstanceOf(AtRuleBlockList::class, $contents[0]); + self::assertSame('(custom: "a;b{c}")', $contents[0]->atRuleArgs()); + } + + /** + * @test + */ + public function handlesOddAndEvenBackslashRunsBeforeQuotesInAtRuleArguments(): void + { + $oddBackslashCss = '@media (custom: "a\\";{b}") { .inside { color: red; } } .after { color: blue; }'; + $oddBackslashContents = (new Parser($oddBackslashCss))->parse()->getContents(); + + self::assertCount(2, $oddBackslashContents); + self::assertInstanceOf(AtRuleBlockList::class, $oddBackslashContents[0]); + self::assertSame('(custom: "a\\";{b}")', $oddBackslashContents[0]->atRuleArgs()); + + $evenBackslashCss = '@custom "a\\\\"; .after { color: blue; }'; + $evenBackslashContents = (new Parser($evenBackslashCss))->parse()->getContents(); + + self::assertCount(2, $evenBackslashContents); + self::assertInstanceOf(AtRuleStatement::class, $evenBackslashContents[0]); + self::assertSame('"a\\\\"', $evenBackslashContents[0]->atRuleArgs()); + } + + /** + * @test + */ + public function doesNotTreatDelimitersAfterBackslashInsideStringAsAtRuleDelimiters(): void + { + $css = '@media (custom: "a\\b;c{d}") { .inside { color: red; } } .after { color: blue; }'; + + $contents = (new Parser($css))->parse()->getContents(); + + self::assertCount(2, $contents); + self::assertInstanceOf(AtRuleBlockList::class, $contents[0]); + self::assertSame('(custom: "a\\b;c{d}")', $contents[0]->atRuleArgs()); + } }