From 2dd17d7c298bd2f2d3203f653a5a6e6d94c9f084 Mon Sep 17 00:00:00 2001 From: sean0407 <22557931+sean0407@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:21:40 +0800 Subject: [PATCH 1/6] [BUGFIX] Handle strings in at-rule preludes --- CHANGELOG.md | 1 + src/CSSList/CSSList.php | 41 ++++++++++++++++++- .../Property/AtRuleStatementTest.php | 14 +++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4650d9fb..a4a6999b 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 (#1625) ### Documentation diff --git a/src/CSSList/CSSList.php b/src/CSSList/CSSList.php index 5ce812f6..9f55d5f8 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,45 @@ 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); + $trailingBackslashes = 0; + for ($i = \strlen($arguments) - 1; $i >= 0 && $arguments[$i] === '\\'; --$i) { + ++$trailingBackslashes; + } + $isEscaped = ($trailingBackslashes % 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..c8977fec 100644 --- a/tests/Functional/Property/AtRuleStatementTest.php +++ b/tests/Functional/Property/AtRuleStatementTest.php @@ -130,4 +130,18 @@ 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()); + } } From 0409bcb4972eb1ba6ce030a39acfd051b12f66de Mon Sep 17 00:00:00 2001 From: sean0407 <22557931+sean0407@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:11:09 +0800 Subject: [PATCH 2/6] [BUGFIX] Test escaped at-rule quotes --- src/CSSList/CSSList.php | 9 +++--- .../Property/AtRuleStatementTest.php | 28 ++++++++++++++++++- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/CSSList/CSSList.php b/src/CSSList/CSSList.php index 9f55d5f8..7bce6341 100644 --- a/src/CSSList/CSSList.php +++ b/src/CSSList/CSSList.php @@ -261,11 +261,10 @@ private static function consumeAtRuleArguments(ParserState $parserState): string } $quote = $parserState->consume(1); - $trailingBackslashes = 0; - for ($i = \strlen($arguments) - 1; $i >= 0 && $arguments[$i] === '\\'; --$i) { - ++$trailingBackslashes; - } - $isEscaped = ($trailingBackslashes % 2) === 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; diff --git a/tests/Functional/Property/AtRuleStatementTest.php b/tests/Functional/Property/AtRuleStatementTest.php index c8977fec..ded20831 100644 --- a/tests/Functional/Property/AtRuleStatementTest.php +++ b/tests/Functional/Property/AtRuleStatementTest.php @@ -136,7 +136,9 @@ public function rendersParsedLayerStatementWithTrailingSemicolon(): void */ public function doesNotTreatBracesOrSemicolonsInsideStringsAsAtRuleDelimiters(): void { - $css = '@media (custom: "a;b{c}") { .inside { color: red; } } .after { color: blue; }'; + $css = <<<'CSS' +@media (custom: "a;b{c}") { .inside { color: red; } } .after { color: blue; } +CSS; $contents = (new Parser($css))->parse()->getContents(); @@ -144,4 +146,28 @@ public function doesNotTreatBracesOrSemicolonsInsideStringsAsAtRuleDelimiters(): self::assertInstanceOf(AtRuleBlockList::class, $contents[0]); self::assertSame('(custom: "a;b{c}")', $contents[0]->atRuleArgs()); } + + /** + * @test + */ + public function handlesOddAndEvenBackslashRunsBeforeQuotesInAtRuleArguments(): void + { + $oddBackslashCss = <<<'CSS' +@media (custom: "a\";{b}") { .inside { color: red; } } .after { color: blue; } +CSS; + $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 = <<<'CSS' +@custom "a\\"; .after { color: blue; } +CSS; + $evenBackslashContents = (new Parser($evenBackslashCss))->parse()->getContents(); + + self::assertCount(2, $evenBackslashContents); + self::assertInstanceOf(AtRuleStatement::class, $evenBackslashContents[0]); + self::assertSame('"a\\\\"', $evenBackslashContents[0]->atRuleArgs()); + } } From 1bd9468610b794fdda3ae0f7821697c50af99e29 Mon Sep 17 00:00:00 2001 From: sean0407 <22557931+sean0407@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:24:42 +0800 Subject: [PATCH 3/6] [BUGFIX] Avoid nowdoc in regression tests --- tests/Functional/Property/AtRuleStatementTest.php | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/tests/Functional/Property/AtRuleStatementTest.php b/tests/Functional/Property/AtRuleStatementTest.php index ded20831..4925d33c 100644 --- a/tests/Functional/Property/AtRuleStatementTest.php +++ b/tests/Functional/Property/AtRuleStatementTest.php @@ -136,9 +136,7 @@ public function rendersParsedLayerStatementWithTrailingSemicolon(): void */ public function doesNotTreatBracesOrSemicolonsInsideStringsAsAtRuleDelimiters(): void { - $css = <<<'CSS' -@media (custom: "a;b{c}") { .inside { color: red; } } .after { color: blue; } -CSS; + $css = '@media (custom: "a;b{c}") { .inside { color: red; } } .after { color: blue; }'; $contents = (new Parser($css))->parse()->getContents(); @@ -152,18 +150,14 @@ public function doesNotTreatBracesOrSemicolonsInsideStringsAsAtRuleDelimiters(): */ public function handlesOddAndEvenBackslashRunsBeforeQuotesInAtRuleArguments(): void { - $oddBackslashCss = <<<'CSS' -@media (custom: "a\";{b}") { .inside { color: red; } } .after { color: blue; } -CSS; + $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 = <<<'CSS' -@custom "a\\"; .after { color: blue; } -CSS; + $evenBackslashCss = '@custom "a\\\\"; .after { color: blue; }'; $evenBackslashContents = (new Parser($evenBackslashCss))->parse()->getContents(); self::assertCount(2, $evenBackslashContents); From 1e332744056b2928aa19ae8c31e913be42b09f2b Mon Sep 17 00:00:00 2001 From: sean0407 <22557931+sean0407@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:38:29 +0800 Subject: [PATCH 4/6] [BUGFIX] Escape odd backslash in test fixture --- tests/Functional/Property/AtRuleStatementTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Functional/Property/AtRuleStatementTest.php b/tests/Functional/Property/AtRuleStatementTest.php index 4925d33c..c4bdda6c 100644 --- a/tests/Functional/Property/AtRuleStatementTest.php +++ b/tests/Functional/Property/AtRuleStatementTest.php @@ -150,7 +150,7 @@ public function doesNotTreatBracesOrSemicolonsInsideStringsAsAtRuleDelimiters(): */ public function handlesOddAndEvenBackslashRunsBeforeQuotesInAtRuleArguments(): void { - $oddBackslashCss = '@media (custom: "a\";{b}") { .inside { color: red; } } .after { color: blue; }'; + $oddBackslashCss = '@media (custom: "a\\";{b}") { .inside { color: red; } } .after { color: blue; }'; $oddBackslashContents = (new Parser($oddBackslashCss))->parse()->getContents(); self::assertCount(2, $oddBackslashContents); From 967dfbb31c15b10601629446a911a03178b8d0e9 Mon Sep 17 00:00:00 2001 From: Sean Li <22557931+sean0407@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:34:00 +0800 Subject: [PATCH 5/6] Add regression for backslash in at-rule strings --- CHANGELOG.md | 2 +- tests/Functional/Property/AtRuleStatementTest.php | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4a6999b..3a94fd53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +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 (#1625) +- Ignore braces and semicolons inside quoted strings when parsing at-rule preludes (#1625, #1650) ### Documentation diff --git a/tests/Functional/Property/AtRuleStatementTest.php b/tests/Functional/Property/AtRuleStatementTest.php index c4bdda6c..db4322e3 100644 --- a/tests/Functional/Property/AtRuleStatementTest.php +++ b/tests/Functional/Property/AtRuleStatementTest.php @@ -164,4 +164,18 @@ public function handlesOddAndEvenBackslashRunsBeforeQuotesInAtRuleArguments(): v 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()); + } } From c8cb2cefcb736f174288d33c73eed47b88e4663d Mon Sep 17 00:00:00 2001 From: Oliver Klee Date: Sun, 18 Oct 2026 12:52:04 +0200 Subject: [PATCH 6/6] Use the PR ID in the changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a94fd53..c6313f34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +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 (#1625, #1650) +- Ignore braces and semicolons inside quoted strings when parsing at-rule preludes (#1650) ### Documentation