From c7eb1c1d4ef3413e6df3a0d82f15e68c5fd654ce Mon Sep 17 00:00:00 2001 From: Oliver Klee Date: Sun, 12 Oct 2025 20:14:26 +0200 Subject: [PATCH 01/43] [TASK] Improve the Composer script naming (#1388) The scripts for checking things are not CI-specific. So rename their shared prefix from `ci:` to `check:`. --- .github/workflows/ci.yml | 4 +- .github/workflows/codecoverage.yml | 2 +- CONTRIBUTING.md | 4 +- composer.json | 66 +++++++++++++++--------------- 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 251e8fc4d..952c786ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: composer show; - name: PHP Lint - run: composer ci:php:lint + run: composer check:php:lint unit-tests: name: Unit tests @@ -145,4 +145,4 @@ jobs: phive --no-progress install --trust-gpg-keys 0FDE18AE1D09E19F60F6B1CBC00543248C87FB13,BBAB5DF0A0D6672989CF1869E82B2FB314E9906E - name: Run Command - run: composer ci:${{ matrix.command }} + run: composer check:${{ matrix.command }} diff --git a/.github/workflows/codecoverage.yml b/.github/workflows/codecoverage.yml index 29c37ba50..cefdd554a 100644 --- a/.github/workflows/codecoverage.yml +++ b/.github/workflows/codecoverage.yml @@ -52,7 +52,7 @@ jobs: composer show; - name: Run Tests - run: composer ci:tests:coverage + run: composer check:tests:coverage - name: Show generated coverage files run: ls -lah diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1d0085f3a..41f833679 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,7 +80,7 @@ code coverage of the fixed bugs and the new features. To run the existing PHPUnit tests, run this command: ```bash -composer ci:tests:unit +composer check:tests:unit ``` ## Coding Style @@ -94,7 +94,7 @@ We will only merge pull requests that follow the project's coding style. Please check your code with the provided static code analysis tools: ```bash -composer ci:static +composer check:static ``` Please make your code clean, well-readable and easy to understand. diff --git a/composer.json b/composer.json index bec30b48d..379b52206 100644 --- a/composer.json +++ b/composer.json @@ -67,31 +67,31 @@ } }, "scripts": { - "ci": [ - "@ci:static", - "@ci:dynamic" + "check": [ + "@check:static", + "@check:dynamic" ], - "ci:composer:normalize": "\"./.phive/composer-normalize\" --dry-run", - "ci:dynamic": [ - "@ci:tests" + "check:composer:normalize": "\"./.phive/composer-normalize\" --dry-run", + "check:dynamic": [ + "@check:tests" ], - "ci:php:fixer": "\"./.phive/php-cs-fixer\" --config=config/php-cs-fixer.php fix --dry-run -v --show-progress=dots --diff bin src tests config", - "ci:php:lint": "parallel-lint src tests config bin", - "ci:php:rector": "rector --no-progress-bar --dry-run --config=config/rector.php", - "ci:php:stan": "phpstan --no-progress --configuration=config/phpstan.neon", - "ci:static": [ - "@ci:composer:normalize", - "@ci:php:fixer", - "@ci:php:lint", - "@ci:php:rector", - "@ci:php:stan" + "check:php:fixer": "\"./.phive/php-cs-fixer\" --config=config/php-cs-fixer.php fix --dry-run -v --show-progress=dots --diff bin src tests config", + "check:php:lint": "parallel-lint src tests config bin", + "check:php:rector": "rector --no-progress-bar --dry-run --config=config/rector.php", + "check:php:stan": "phpstan --no-progress --configuration=config/phpstan.neon", + "check:static": [ + "@check:composer:normalize", + "@check:php:fixer", + "@check:php:lint", + "@check:php:rector", + "@check:php:stan" ], - "ci:tests": [ - "@ci:tests:unit" + "check:tests": [ + "@check:tests:unit" ], - "ci:tests:coverage": "phpunit --do-not-cache-result --coverage-clover=coverage.xml", - "ci:tests:sof": "phpunit --stop-on-failure --do-not-cache-result", - "ci:tests:unit": "phpunit --do-not-cache-result", + "check:tests:coverage": "phpunit --do-not-cache-result --coverage-clover=coverage.xml", + "check:tests:sof": "phpunit --stop-on-failure --do-not-cache-result", + "check:tests:unit": "phpunit --do-not-cache-result", "fix": [ "@fix:php" ], @@ -106,18 +106,18 @@ "phpstan:baseline": "phpstan --configuration=config/phpstan.neon --generate-baseline=config/phpstan-baseline.neon --allow-empty-baseline" }, "scripts-descriptions": { - "ci": "Runs all dynamic and static code checks.", - "ci:composer:normalize": "Checks the formatting and structure of the composer.json.", - "ci:dynamic": "Runs all dynamic code checks (i.e., currently, the unit tests).", - "ci:php:fixer": "Checks the code style with PHP CS Fixer.", - "ci:php:lint": "Checks the syntax of the PHP code.", - "ci:php:rector": "Checks the code for possible code updates and refactoring.", - "ci:php:stan": "Checks the types with PHPStan.", - "ci:static": "Runs all static code analysis checks for the code.", - "ci:tests": "Runs all dynamic tests (i.e., currently, the unit tests).", - "ci:tests:coverage": "Runs the unit tests with code coverage.", - "ci:tests:sof": "Runs the unit tests and stops at the first failure.", - "ci:tests:unit": "Runs all unit tests.", + "check": "Runs all dynamic and static code checks.", + "check:composer:normalize": "Checks the formatting and structure of the composer.json.", + "check:dynamic": "Runs all dynamic code checks (i.e., currently, the unit tests).", + "check:php:fixer": "Checks the code style with PHP CS Fixer.", + "check:php:lint": "Checks the syntax of the PHP code.", + "check:php:rector": "Checks the code for possible code updates and refactoring.", + "check:php:stan": "Checks the types with PHPStan.", + "check:static": "Runs all static code analysis checks for the code.", + "check:tests": "Runs all dynamic tests (i.e., currently, the unit tests).", + "check:tests:coverage": "Runs the unit tests with code coverage.", + "check:tests:sof": "Runs the unit tests and stops at the first failure.", + "check:tests:unit": "Runs all unit tests.", "fix": "Runs all fixers", "fix:composer:normalize": "Reformats and sorts the composer.json file.", "fix:php": "Autofixes all autofixable issues in the PHP code.", From 0adb83295105c68bbd45891fda6268c94104cc00 Mon Sep 17 00:00:00 2001 From: Oliver Klee Date: Sun, 16 Nov 2025 19:27:01 +0100 Subject: [PATCH 02/43] [FEATURE] Add line length code check and fixer (#1392) --- composer.json | 7 +++++++ config/phpcs.xml | 12 ++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 config/phpcs.xml diff --git a/composer.json b/composer.json index 379b52206..5c6efd2c9 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "rawr/phpunit-data-provider": "3.3.1", "rector/rector": "1.2.10 || 2.1.7", "rector/type-perfect": "1.0.0 || 2.1.0", + "squizlabs/php_codesniffer": "4.0.1", "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1" }, "suggest": { @@ -75,6 +76,7 @@ "check:dynamic": [ "@check:tests" ], + "check:php:codesniffer": "phpcs --standard=config/phpcs.xml config src tests", "check:php:fixer": "\"./.phive/php-cs-fixer\" --config=config/php-cs-fixer.php fix --dry-run -v --show-progress=dots --diff bin src tests config", "check:php:lint": "parallel-lint src tests config bin", "check:php:rector": "rector --no-progress-bar --dry-run --config=config/rector.php", @@ -82,6 +84,7 @@ "check:static": [ "@check:composer:normalize", "@check:php:fixer", + "@check:php:codesniffer", "@check:php:lint", "@check:php:rector", "@check:php:stan" @@ -99,8 +102,10 @@ "fix:php": [ "@fix:composer:normalize", "@fix:php:rector", + "@fix:php:codesniffer", "@fix:php:fixer" ], + "fix:php:codesniffer": "phpcbf --standard=config/phpcs.xml config src tests", "fix:php:fixer": "\"./.phive/php-cs-fixer\" --config=config/php-cs-fixer.php fix bin src tests", "fix:php:rector": "rector --config=config/rector.php", "phpstan:baseline": "phpstan --configuration=config/phpstan.neon --generate-baseline=config/phpstan-baseline.neon --allow-empty-baseline" @@ -109,6 +114,7 @@ "check": "Runs all dynamic and static code checks.", "check:composer:normalize": "Checks the formatting and structure of the composer.json.", "check:dynamic": "Runs all dynamic code checks (i.e., currently, the unit tests).", + "check:php:codesniffer": "Checks the code style with PHP_CodeSniffer.", "check:php:fixer": "Checks the code style with PHP CS Fixer.", "check:php:lint": "Checks the syntax of the PHP code.", "check:php:rector": "Checks the code for possible code updates and refactoring.", @@ -121,6 +127,7 @@ "fix": "Runs all fixers", "fix:composer:normalize": "Reformats and sorts the composer.json file.", "fix:php": "Autofixes all autofixable issues in the PHP code.", + "fix:php:codesniffer": "Reformats the code with PHP_CodeSniffer.", "fix:php:fixer": "Fixes autofixable issues found by PHP CS Fixer.", "fix:php:rector": "Fixes autofixable issues found by Rector.", "phpstan:baseline": "Updates the PHPStan baseline file to match the code." diff --git a/config/phpcs.xml b/config/phpcs.xml new file mode 100644 index 000000000..bb8801e75 --- /dev/null +++ b/config/phpcs.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + From f5ad2e777f93cfeacd7fc35fef2ed1061e753299 Mon Sep 17 00:00:00 2001 From: Oliver Klee Date: Sun, 16 Nov 2025 19:27:47 +0100 Subject: [PATCH 03/43] [TASK] Update the development dependencies (#1381) --- .phive/phars.xml | 2 +- composer.json | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.phive/phars.xml b/.phive/phars.xml index d9ab49f38..8b34d5ce0 100644 --- a/.phive/phars.xml +++ b/.phive/phars.xml @@ -1,5 +1,5 @@ - + diff --git a/composer.json b/composer.json index 5c6efd2c9..849cc4f2b 100644 --- a/composer.json +++ b/composer.json @@ -30,12 +30,12 @@ "require-dev": { "php-parallel-lint/php-parallel-lint": "1.4.0", "phpstan/extension-installer": "1.4.3", - "phpstan/phpstan": "1.12.28 || 2.1.25", - "phpstan/phpstan-phpunit": "1.4.2 || 2.0.7", - "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.6", + "phpstan/phpstan": "1.12.32 || 2.1.32", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7", "phpunit/phpunit": "8.5.48", "rawr/phpunit-data-provider": "3.3.1", - "rector/rector": "1.2.10 || 2.1.7", + "rector/rector": "1.2.10 || 2.2.8", "rector/type-perfect": "1.0.0 || 2.1.0", "squizlabs/php_codesniffer": "4.0.1", "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1" From 7dd2d852b20c12d4779585d018c731edce34bc16 Mon Sep 17 00:00:00 2001 From: Oliver Klee Date: Mon, 17 Nov 2025 00:08:22 +0100 Subject: [PATCH 04/43] [TASK] Replace deprecated PHP-CS-Fixer rules (#1393) --- config/php-cs-fixer.php | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/config/php-cs-fixer.php b/config/php-cs-fixer.php index c10bf59ae..326105a0b 100644 --- a/config/php-cs-fixer.php +++ b/config/php-cs-fixer.php @@ -6,18 +6,18 @@ ->setRiskyAllowed(true) ->setRules( [ - '@PER-CS2.0' => true, - '@PER-CS2.0:risky' => true, - - '@PHPUnit50Migration:risky' => true, - '@PHPUnit52Migration:risky' => true, - '@PHPUnit54Migration:risky' => true, - '@PHPUnit55Migration:risky' => true, - '@PHPUnit56Migration:risky' => true, - '@PHPUnit57Migration:risky' => true, - '@PHPUnit60Migration:risky' => true, - '@PHPUnit75Migration:risky' => true, - '@PHPUnit84Migration:risky' => true, + '@PER-CS2x0' => true, + '@PER-CS2x0:risky' => true, + + '@PHPUnit5x0Migration:risky' => true, + '@PHPUnit5x2Migration:risky' => true, + '@PHPUnit5x4Migration:risky' => true, + '@PHPUnit5x5Migration:risky' => true, + '@PHPUnit5x6Migration:risky' => true, + '@PHPUnit5x7Migration:risky' => true, + '@PHPUnit6x0Migration:risky' => true, + '@PHPUnit7x5Migration:risky' => true, + '@PHPUnit8x4Migration:risky' => true, // overwrite the PER2 defaults to restore compatibility with PHP 7.x 'trailing_comma_in_multiline' => ['elements' => ['arrays']], From 3f2ec3d64595970003e7e065b1b6e919bcc799e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:07:57 +0100 Subject: [PATCH 05/43] [Dependabot] Bump actions/checkout from 5 to 6 (#1395) Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++--- .github/workflows/codecoverage.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 952c786ed..dbab016b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install PHP uses: shivammathur/setup-php@v2 @@ -63,7 +63,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install PHP uses: shivammathur/setup-php@v2 @@ -112,7 +112,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install PHP uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/codecoverage.yml b/.github/workflows/codecoverage.yml index cefdd554a..457471e9d 100644 --- a/.github/workflows/codecoverage.yml +++ b/.github/workflows/codecoverage.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install PHP uses: shivammathur/setup-php@v2 From c29c15a56036989c3beaaf01882cdb5ec442b40e Mon Sep 17 00:00:00 2001 From: Oliver Klee Date: Thu, 27 Nov 2025 19:49:38 +0100 Subject: [PATCH 06/43] [CLEANUP] Drop PHPDoc type annotations for constants (#1397) It has turned out that PHPStan does not need them. Fixes #1396 --- src/Parsing/ParserState.php | 3 --- src/Property/AtRule.php | 2 -- src/Property/KeyframeSelector.php | 2 -- src/Property/Selector.php | 2 -- src/Property/Selector/SpecificityCalculator.php | 4 ---- src/Value/CalcFunction.php | 7 ------- src/Value/Size.php | 8 -------- tests/Functional/Value/ValueTest.php | 2 -- tests/OutputFormatTest.php | 3 --- tests/Unit/Value/ValueTest.php | 2 -- 10 files changed, 35 deletions(-) diff --git a/src/Parsing/ParserState.php b/src/Parsing/ParserState.php index cc69ed974..864910fcb 100644 --- a/src/Parsing/ParserState.php +++ b/src/Parsing/ParserState.php @@ -16,9 +16,6 @@ */ class ParserState { - /** - * @var null - */ public const EOF = null; /** diff --git a/src/Property/AtRule.php b/src/Property/AtRule.php index 49a160a1a..7a00660f8 100644 --- a/src/Property/AtRule.php +++ b/src/Property/AtRule.php @@ -16,8 +16,6 @@ interface AtRule extends CSSListItem * Since there are more set rules than block rules, * we’re whitelisting the block rules and have anything else be treated as a set rule. * - * @var non-empty-string - * * @internal since 8.5.2 */ public const BLOCK_RULES = 'media/document/supports/region-style/font-feature-values'; diff --git a/src/Property/KeyframeSelector.php b/src/Property/KeyframeSelector.php index 47881771d..3d5af7b0b 100644 --- a/src/Property/KeyframeSelector.php +++ b/src/Property/KeyframeSelector.php @@ -11,8 +11,6 @@ class KeyframeSelector extends Selector * - comma is not allowed unless escaped or quoted; * - percentage value is allowed by itself. * - * @var non-empty-string - * * @internal since 8.5.2 */ public const SELECTOR_VALIDATION_RX = '/ diff --git a/src/Property/Selector.php b/src/Property/Selector.php index a647378e5..8f6290b4b 100644 --- a/src/Property/Selector.php +++ b/src/Property/Selector.php @@ -17,8 +17,6 @@ class Selector implements Renderable { /** - * @var non-empty-string - * * @internal since 8.5.2 */ public const SELECTOR_VALIDATION_RX = '/ diff --git a/src/Property/Selector/SpecificityCalculator.php b/src/Property/Selector/SpecificityCalculator.php index b2f1323e5..6c7bbccd5 100644 --- a/src/Property/Selector/SpecificityCalculator.php +++ b/src/Property/Selector/SpecificityCalculator.php @@ -15,8 +15,6 @@ final class SpecificityCalculator { /** * regexp for specificity calculations - * - * @var non-empty-string */ private const NON_ID_ATTRIBUTES_AND_PSEUDO_CLASSES_RX = '/ (\\.[\\w]+) # classes @@ -39,8 +37,6 @@ final class SpecificityCalculator /** * regexp for specificity calculations - * - * @var non-empty-string */ private const ELEMENTS_AND_PSEUDO_ELEMENTS_RX = '/ ((^|[\\s\\+\\>\\~]+)[\\w]+ # elements diff --git a/src/Value/CalcFunction.php b/src/Value/CalcFunction.php index dba6e1dd9..cd1648876 100644 --- a/src/Value/CalcFunction.php +++ b/src/Value/CalcFunction.php @@ -10,14 +10,7 @@ class CalcFunction extends CSSFunction { - /** - * @var int - */ private const T_OPERAND = 1; - - /** - * @var int - */ private const T_OPERATOR = 2; /** diff --git a/src/Value/Size.php b/src/Value/Size.php index eac736d79..e8e2a1d00 100644 --- a/src/Value/Size.php +++ b/src/Value/Size.php @@ -19,8 +19,6 @@ class Size extends PrimitiveValue { /** * vh/vw/vm(ax)/vmin/rem are absolute insofar as they don’t scale to the immediate parent (only the viewport) - * - * @var list */ private const ABSOLUTE_SIZE_UNITS = [ 'px', @@ -40,14 +38,8 @@ class Size extends PrimitiveValue 'rem', ]; - /** - * @var list - */ private const RELATIVE_SIZE_UNITS = ['%', 'em', 'ex', 'ch', 'fr']; - /** - * @var list - */ private const NON_SIZE_UNITS = ['deg', 'grad', 'rad', 's', 'ms', 'turn', 'Hz', 'kHz']; /** diff --git a/tests/Functional/Value/ValueTest.php b/tests/Functional/Value/ValueTest.php index 4d65ee2a8..b8dd5b4f1 100644 --- a/tests/Functional/Value/ValueTest.php +++ b/tests/Functional/Value/ValueTest.php @@ -20,8 +20,6 @@ final class ValueTest extends TestCase * the default set of delimiters for parsing most values * * @see \Sabberworm\CSS\Rule\Rule::listDelimiterForRule - * - * @var list */ private const DEFAULT_DELIMITERS = [',', ' ', '/']; diff --git a/tests/OutputFormatTest.php b/tests/OutputFormatTest.php index 3a8deb30e..cb0dfd42c 100644 --- a/tests/OutputFormatTest.php +++ b/tests/OutputFormatTest.php @@ -15,9 +15,6 @@ */ final class OutputFormatTest extends TestCase { - /** - * @var string - */ private const TEST_CSS = << */ private const DEFAULT_DELIMITERS = [',', ' ', '/']; From fe29992d5ef3c74e8c9927549cbf82072eea9d6c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 12:49:32 +0100 Subject: [PATCH 07/43] [Dependabot] Update phpunit/phpunit requirement from 8.5.48 to 8.5.49 (#1401) Updates the requirements on [phpunit/phpunit](https://github.com/sebastianbergmann/phpunit) to permit the latest version. - [Release notes](https://github.com/sebastianbergmann/phpunit/releases) - [Changelog](https://github.com/sebastianbergmann/phpunit/blob/8.5.49/ChangeLog-8.5.md) - [Commits](https://github.com/sebastianbergmann/phpunit/compare/8.5.48...8.5.49) --- updated-dependencies: - dependency-name: phpunit/phpunit dependency-version: 8.5.49 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 849cc4f2b..476b3bcb2 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ "phpstan/phpstan": "1.12.32 || 2.1.32", "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8", "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7", - "phpunit/phpunit": "8.5.48", + "phpunit/phpunit": "8.5.49", "rawr/phpunit-data-provider": "3.3.1", "rector/rector": "1.2.10 || 2.2.8", "rector/type-perfect": "1.0.0 || 2.1.0", From b01197de48c9d26da55e8247c0cfccac640b1697 Mon Sep 17 00:00:00 2001 From: Simon Chester Date: Tue, 2 Dec 2025 15:56:41 +1300 Subject: [PATCH 08/43] [BUGFIX] Parse calc split over multiple lines (#1399) --- CHANGELOG.md | 2 + src/Value/CalcFunction.php | 7 +- tests/Unit/Value/CalcFunctionTest.php | 206 ++++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/Value/CalcFunctionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8978fbb71..ded270b73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ Please also have a look at our ### Fixed - Use typesafe versions of PHP functions (#1379, #1380, #1382, #1383, #1384) +- Fix parsing of `calc` expressions when a newline immediately precedes or + follows a + or - operator (#1399) ### Documentation diff --git a/src/Value/CalcFunction.php b/src/Value/CalcFunction.php index cd1648876..f674ed151 100644 --- a/src/Value/CalcFunction.php +++ b/src/Value/CalcFunction.php @@ -8,6 +8,8 @@ use Sabberworm\CSS\Parsing\UnexpectedEOFException; use Sabberworm\CSS\Parsing\UnexpectedTokenException; +use function Safe\preg_match; + class CalcFunction extends CSSFunction { private const T_OPERAND = 1; @@ -60,9 +62,8 @@ public static function parse(ParserState $parserState, bool $ignoreCase = false) if (\in_array($parserState->peek(), $operators, true)) { if (($parserState->comes('-') || $parserState->comes('+'))) { if ( - $parserState->peek(1, -1) !== ' ' - || !($parserState->comes('- ') - || $parserState->comes('+ ')) + preg_match('/\\s/', $parserState->peek(1, -1)) !== 1 + || preg_match('/\\s/', $parserState->peek(1, 1)) !== 1 ) { throw new UnexpectedTokenException( " {$parserState->peek()} ", diff --git a/tests/Unit/Value/CalcFunctionTest.php b/tests/Unit/Value/CalcFunctionTest.php new file mode 100644 index 000000000..b87f742b5 --- /dev/null +++ b/tests/Unit/Value/CalcFunctionTest.php @@ -0,0 +1,206 @@ +parse($css); + + self::assertInstanceOf(CalcFunction::class, $calcFunction); + self::assertSame('calc', $calcFunction->getName()); + + $args = $calcFunction->getArguments(); + self::assertCount(1, $args); + self::assertInstanceOf(CalcRuleValueList::class, $args[0]); + + /** @var CalcRuleValueList $value */ + $value = $args[0]; + $components = $value->getListComponents(); + self::assertCount(3, $components); // 100%, -, 20px + + self::assertInstanceOf(Size::class, $components[0]); + self::assertSame(100.0, $components[0]->getSize()); + self::assertSame('%', $components[0]->getUnit()); + + self::assertSame('-', $components[1]); + + self::assertInstanceOf(Size::class, $components[2]); + self::assertSame(20.0, $components[2]->getSize()); + self::assertSame('px', $components[2]->getUnit()); + } + /** + * @test + */ + public function parseNestedCalc(): void + { + $css = 'calc(100% - calc(20px + 1em))'; + $calcFunction = $this->parse($css); + + /** @var CalcRuleValueList $value */ + $value = $calcFunction->getArguments()[0]; + $components = $value->getListComponents(); + + self::assertCount(3, $components); + self::assertSame('-', $components[1]); + + /** @var CalcFunction */ + $nestedCalc = $components[2]; + self::assertInstanceOf(CalcFunction::class, $nestedCalc); + + /** @var CalcRuleValueList $nestedValue */ + $nestedValue = $nestedCalc->getArguments()[0]; + self::assertInstanceOf(CalcRuleValueList::class, $nestedValue); + $nestedComponents = $nestedValue->getListComponents(); + + self::assertCount(3, $nestedComponents); + self::assertSame('+', $nestedComponents[1]); + } + + /** + * @test + */ + public function parseWithParentheses(): void + { + $css = 'calc((100% - 20px) * 2)'; + $calcFunction = $this->parse($css); + + /** @var CalcRuleValueList $value */ + $value = $calcFunction->getArguments()[0]; + $components = $value->getListComponents(); + + self::assertCount(7, $components); + self::assertSame('(', $components[0]); + self::assertInstanceOf(Size::class, $components[1]); // 100% + self::assertSame('-', $components[2]); + self::assertInstanceOf(Size::class, $components[3]); // 20px + self::assertSame(')', $components[4]); + self::assertSame('*', $components[5]); + self::assertInstanceOf(Size::class, $components[6]); // 2 + } + + /** + * @return array + */ + public function provideValidOperatorSyntax(): array + { + return [ + '+ op' => ['calc(100% + 20px)', 'calc(100% + 20px)'], + '- op' => ['calc(100% - 20px)', 'calc(100% - 20px)'], + '* op' => ['calc(100% * 20)', 'calc(100% * 20)'], + '* op no space' => ['calc(100%*20)', 'calc(100% * 20)'], + '/ op' => ['calc(100% / 20)', 'calc(100% / 20)'], + '/ op no space' => ['calc(100%/20)', 'calc(100% / 20)'], + ]; + } + + /** + * @test + * + * @dataProvider provideValidOperatorSyntax + */ + public function parseValidOperators(string $css, string $rendered): void + { + $calcFunction = $this->parse($css); + $output = $calcFunction->render(OutputFormat::create()); + self::assertSame($rendered, $output); + } + + /** + * @return array + */ + public function provideMultiline(): array + { + return [ + 'right newline' => ["calc(100% +\n20px)", 'calc(100% + 20px)'], + 'right and outer newline' => ["calc(\n100% +\n20px\n)", 'calc(100% + 20px)'], + 'left newline' => ["calc(100%\n+ 20px)", 'calc(100% + 20px)'], + 'both newline' => ["calc(100%\n+\n20px)", 'calc(100% + 20px)'], + 'tab whitespace' => ["calc(100%\t+\t20px)", 'calc(100% + 20px)'], + '- op' => ["calc(100%\n-\n20px)", 'calc(100% - 20px)'], + '/ op' => ["calc(100% /\n20)", 'calc(100% / 20)'], + ]; + } + + /** + * @test + * + * @dataProvider provideMultiline + */ + public function parseMultiline(string $css, string $rendered): void + { + $calcFunction = $this->parse($css); + $output = $calcFunction->render(OutputFormat::create()); + self::assertSame($rendered, $output); + } + + /** + * @return array + */ + public function provideInvalidSyntax(): array + { + return [ + 'missing space around -' => ['calc(100%-20px)'], + 'missing space around +' => ['calc(100%+20px)'], + 'invalid operator' => ['calc(100% ^ 20px)'], + ]; + } + + /** + * @test + * + * @dataProvider provideInvalidSyntax + */ + public function parseThrowsExceptionForInvalidSyntax(string $css): void + { + $this->expectException(UnexpectedTokenException::class); + $this->parse($css); + } + + /** + * @test + */ + public function parseThrowsExceptionIfCalledWithWrongFunctionName(): void + { + $css = 'wrong(100% - 20px)'; + $parserState = new ParserState($css, Settings::create()); + + $this->expectException(UnexpectedTokenException::class); + $this->expectExceptionMessage('calc'); + CalcFunction::parse($parserState); + } + + /** + * Parse provided CSS as a CalcFunction + * + * @param string $css + * @return CalcFunction + */ + private function parse(string $css): CalcFunction + { + $parserState = new ParserState($css, Settings::create()); + + $function = CalcFunction::parse($parserState); + self::assertInstanceOf(CalcFunction::class, $function); + return $function; + } +} From 961427932a7ddb97cdf39a4cddcad044db4c399e Mon Sep 17 00:00:00 2001 From: Simon Chester Date: Tue, 2 Dec 2025 22:12:50 +1300 Subject: [PATCH 09/43] [CLEANUP] Remove unneccesary `@var` PHPDoc annotations (#1402) --- tests/Unit/Value/CalcFunctionTest.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/Unit/Value/CalcFunctionTest.php b/tests/Unit/Value/CalcFunctionTest.php index b87f742b5..0eedba289 100644 --- a/tests/Unit/Value/CalcFunctionTest.php +++ b/tests/Unit/Value/CalcFunctionTest.php @@ -33,7 +33,6 @@ public function parseSimpleCalc(): void self::assertCount(1, $args); self::assertInstanceOf(CalcRuleValueList::class, $args[0]); - /** @var CalcRuleValueList $value */ $value = $args[0]; $components = $value->getListComponents(); self::assertCount(3, $components); // 100%, -, 20px @@ -63,11 +62,9 @@ public function parseNestedCalc(): void self::assertCount(3, $components); self::assertSame('-', $components[1]); - /** @var CalcFunction */ $nestedCalc = $components[2]; self::assertInstanceOf(CalcFunction::class, $nestedCalc); - /** @var CalcRuleValueList $nestedValue */ $nestedValue = $nestedCalc->getArguments()[0]; self::assertInstanceOf(CalcRuleValueList::class, $nestedValue); $nestedComponents = $nestedValue->getListComponents(); From 8be4011d7ba0d1e2027d6baaa727830b67d9c8f6 Mon Sep 17 00:00:00 2001 From: Simon Chester Date: Tue, 2 Dec 2025 23:58:35 +1300 Subject: [PATCH 10/43] [FEATURE] Parse container queries (#1400) --- CHANGELOG.md | 2 ++ src/Property/AtRule.php | 2 +- tests/CSSList/AtRuleBlockListTest.php | 3 +++ tests/Unit/Property/AtRuleTest.php | 22 ++++++++++++++++++++++ 4 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Property/AtRuleTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ded270b73..ab613dc3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Please also have a look at our ### Added +- Support for CSS container queries (#1400) + ### Changed ### Deprecated diff --git a/src/Property/AtRule.php b/src/Property/AtRule.php index 7a00660f8..f5ae475ea 100644 --- a/src/Property/AtRule.php +++ b/src/Property/AtRule.php @@ -18,7 +18,7 @@ interface AtRule extends CSSListItem * * @internal since 8.5.2 */ - public const BLOCK_RULES = 'media/document/supports/region-style/font-feature-values'; + public const BLOCK_RULES = 'media/document/supports/region-style/font-feature-values/container'; /** * @return non-empty-string diff --git a/tests/CSSList/AtRuleBlockListTest.php b/tests/CSSList/AtRuleBlockListTest.php index 9a725b212..3539463bf 100644 --- a/tests/CSSList/AtRuleBlockListTest.php +++ b/tests/CSSList/AtRuleBlockListTest.php @@ -48,6 +48,9 @@ public static function provideSyntacticallyCorrectAtRule(): array } ', ], + 'container' => [ + '@container (min-width: 60rem) { .items { background: blue; } }', + ], ]; } diff --git a/tests/Unit/Property/AtRuleTest.php b/tests/Unit/Property/AtRuleTest.php new file mode 100644 index 000000000..53793bea8 --- /dev/null +++ b/tests/Unit/Property/AtRuleTest.php @@ -0,0 +1,22 @@ + Date: Tue, 2 Dec 2025 20:24:14 +0100 Subject: [PATCH 11/43] [DOCS] Polish the changelog (#1404) --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab613dc3d..216525f57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Please also have a look at our ### Added -- Support for CSS container queries (#1400) +- Add support for CSS container queries (#1400) ### Changed @@ -22,7 +22,7 @@ Please also have a look at our - Use typesafe versions of PHP functions (#1379, #1380, #1382, #1383, #1384) - Fix parsing of `calc` expressions when a newline immediately precedes or - follows a + or - operator (#1399) + follows a `+` or `-` operator (#1399) ### Documentation From a7e229b03d63233547c1febb48e71ba6e88548d8 Mon Sep 17 00:00:00 2001 From: Simon Chester Date: Wed, 3 Dec 2025 12:01:48 +1300 Subject: [PATCH 12/43] [CLEANUP] Tweak PHPDoc types in calc unit test (#1405) --- tests/Unit/Value/CalcFunctionTest.php | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/Unit/Value/CalcFunctionTest.php b/tests/Unit/Value/CalcFunctionTest.php index 0eedba289..f4907d477 100644 --- a/tests/Unit/Value/CalcFunctionTest.php +++ b/tests/Unit/Value/CalcFunctionTest.php @@ -96,7 +96,7 @@ public function parseWithParentheses(): void } /** - * @return array + * @return array */ public function provideValidOperatorSyntax(): array { @@ -123,7 +123,7 @@ public function parseValidOperators(string $css, string $rendered): void } /** - * @return array + * @return array */ public function provideMultiline(): array { @@ -151,7 +151,7 @@ public function parseMultiline(string $css, string $rendered): void } /** - * @return array + * @return array */ public function provideInvalidSyntax(): array { @@ -188,9 +188,6 @@ public function parseThrowsExceptionIfCalledWithWrongFunctionName(): void /** * Parse provided CSS as a CalcFunction - * - * @param string $css - * @return CalcFunction */ private function parse(string $css): CalcFunction { From 3f57730f6c7445d3e2e1fc9c0cdbab98bf6c6124 Mon Sep 17 00:00:00 2001 From: JakeQZ Date: Thu, 4 Dec 2025 12:08:11 +0000 Subject: [PATCH 13/43] [CLEANUP] Tighten `DeclarationBlock::selectors` type (#1407) Use a local variable in `setSelectors()` rather than temporarily assigning the property with a type it's not supposed to have. Ensure that the property is set to a non-sparse numerical array. Also tighten the return type of `getSelectors()`. (Precursor to #1330.) --- CHANGELOG.md | 3 +++ src/RuleSet/DeclarationBlock.php | 19 ++++++++++++------- tests/Unit/RuleSet/DeclarationBlockTest.php | 15 +++++++++++++++ 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 216525f57..0a06bcef4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ Please also have a look at our ### Changed +- The array keys passed to `DeclarationBlock::setSelectors()` are no longer + preserved (#1407) + ### Deprecated ### Removed diff --git a/src/RuleSet/DeclarationBlock.php b/src/RuleSet/DeclarationBlock.php index 4d0775460..8a0f4e487 100644 --- a/src/RuleSet/DeclarationBlock.php +++ b/src/RuleSet/DeclarationBlock.php @@ -36,7 +36,7 @@ class DeclarationBlock implements CSSElement, CSSListItem, Positionable, RuleCon use Position; /** - * @var array + * @var list */ private $selectors = []; @@ -146,11 +146,13 @@ public static function parse(ParserState $parserState, ?CSSList $list = null): ? public function setSelectors($selectors, ?CSSList $list = null): void { if (\is_array($selectors)) { - $this->selectors = $selectors; + $selectorsToSet = $selectors; } else { - $this->selectors = \explode(',', $selectors); + $selectorsToSet = \explode(',', $selectors); } - foreach ($this->selectors as $key => $selector) { + + // Convert all items to a `Selector` if not already + foreach ($selectorsToSet as $key => $selector) { if (!($selector instanceof Selector)) { if ($list === null || !($list instanceof KeyFrame)) { if (!Selector::isValid($selector)) { @@ -160,7 +162,7 @@ public function setSelectors($selectors, ?CSSList $list = null): void 'custom' ); } - $this->selectors[$key] = new Selector($selector); + $selectorsToSet[$key] = new Selector($selector); } else { if (!KeyframeSelector::isValid($selector)) { throw new UnexpectedTokenException( @@ -169,10 +171,13 @@ public function setSelectors($selectors, ?CSSList $list = null): void 'custom' ); } - $this->selectors[$key] = new KeyframeSelector($selector); + $selectorsToSet[$key] = new KeyframeSelector($selector); } } } + + // Discard the keys and reindex the array + $this->selectors = \array_values($selectorsToSet); } /** @@ -195,7 +200,7 @@ public function removeSelector($selectorToRemove): bool } /** - * @return array + * @return list */ public function getSelectors(): array { diff --git a/tests/Unit/RuleSet/DeclarationBlockTest.php b/tests/Unit/RuleSet/DeclarationBlockTest.php index 4b20e9fc8..5f226846a 100644 --- a/tests/Unit/RuleSet/DeclarationBlockTest.php +++ b/tests/Unit/RuleSet/DeclarationBlockTest.php @@ -278,4 +278,19 @@ public function getRuleSetReturnsObjectWithLineNumberPassedToConstructor(?int $l self::assertSame($lineNumber, $result->getLineNumber()); } + + /** + * @test + * + * Any type of array may be passed to the method, but the resultant property should be a `list`. + */ + public function setSelectorsIgnoresKeys(): void + { + $subject = new DeclarationBlock(); + $subject->setSelectors(['Bob' => 'html', 'Mary' => 'body']); + + $result = $subject->getSelectors(); + + self::assertSame([0, 1], \array_keys($result)); + } } From 4a8a1c1811a5df09c4c584f4d122a7b9fb19b44d Mon Sep 17 00:00:00 2001 From: Oliver Klee Date: Thu, 4 Dec 2025 13:15:39 +0100 Subject: [PATCH 14/43] [DOCS] Sort changelog entries in reverse chronological order (#1408) To be consistent with the git log, new entries in the changelog should be first. This also makes it easier to see at a glance what was changed recently. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a06bcef4..ccdfc1ded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,9 @@ Please also have a look at our ### Fixed -- Use typesafe versions of PHP functions (#1379, #1380, #1382, #1383, #1384) - Fix parsing of `calc` expressions when a newline immediately precedes or follows a `+` or `-` operator (#1399) +- Use typesafe versions of PHP functions (#1379, #1380, #1382, #1383, #1384) ### Documentation From cb0292de5ba5351711b9c20914d9adf541ccab75 Mon Sep 17 00:00:00 2001 From: Oliver Klee Date: Thu, 4 Dec 2025 13:36:24 +0100 Subject: [PATCH 15/43] [TASK] Add coverage metadata to `AtRuleTest` (#1409) As interfaces cannot be covered using an `@covers` annotation, we need to use `@coversNothing` instead. This will allow us to require coverage metadata for testcases later down the road. --- tests/Unit/Property/AtRuleTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Unit/Property/AtRuleTest.php b/tests/Unit/Property/AtRuleTest.php index 53793bea8..6094fbf83 100644 --- a/tests/Unit/Property/AtRuleTest.php +++ b/tests/Unit/Property/AtRuleTest.php @@ -7,6 +7,9 @@ use PHPUnit\Framework\TestCase; use Sabberworm\CSS\Property\AtRule; +/** + * @coversNothing + */ final class AtRuleTest extends TestCase { /** From 51a3c0acf0e26ae9782c25c1fae63a26708aca30 Mon Sep 17 00:00:00 2001 From: Oliver Klee Date: Thu, 4 Dec 2025 14:16:10 +0100 Subject: [PATCH 16/43] [TASK] Avoid heredoc in a test (#1410) This allows the testcase being autoformatted without breaking the tests for PHP 7.2 https://www.php.net/manual/en/language.types.string.php#language.types.string.syntax.heredoc --- tests/ParserTest.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/ParserTest.php b/tests/ParserTest.php index b80280a77..a2bd4e703 100644 --- a/tests/ParserTest.php +++ b/tests/ParserTest.php @@ -570,11 +570,10 @@ public function selectorRemoval(): void public function comments(): void { $document = self::parsedStructureForFile('comments'); - $expected = <<render()); } From dc99bbde6c021dea62e77c831ffc3d9cbefb904b Mon Sep 17 00:00:00 2001 From: JakeQZ Date: Thu, 4 Dec 2025 17:30:36 +0000 Subject: [PATCH 17/43] [BUGFIX] Allow order-insensitve `removeDeclarationBlockBySelector` (#1406) Also internally avoid comparison with `==`. Resolves #1330 --- CHANGELOG.md | 1 + config/phpstan-baseline.neon | 6 ------ src/CSSList/CSSList.php | 32 +++++++++++++++++++++++++++++- tests/Unit/CSSList/CSSListTest.php | 16 +++++++++++++++ 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccdfc1ded..e35259471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Please also have a look at our ### Fixed +- Allow `removeDeclarationBlockBySelector()` to be order-insensitve (#1406) - Fix parsing of `calc` expressions when a newline immediately precedes or follows a `+` or `-` operator (#1399) - Use typesafe versions of PHP functions (#1379, #1380, #1382, #1383, #1384) diff --git a/config/phpstan-baseline.neon b/config/phpstan-baseline.neon index 6205096ae..4c4add4da 100644 --- a/config/phpstan-baseline.neon +++ b/config/phpstan-baseline.neon @@ -1,11 +1,5 @@ parameters: ignoreErrors: - - - message: '#^Loose comparison via "\=\=" is not allowed\.$#' - identifier: equal.notAllowed - count: 1 - path: ../src/CSSList/CSSList.php - - message: '#^Parameter \#2 \$found of class Sabberworm\\CSS\\Parsing\\UnexpectedTokenException constructor expects string, Sabberworm\\CSS\\Value\\CSSFunction\|Sabberworm\\CSS\\Value\\CSSString\|Sabberworm\\CSS\\Value\\LineName\|Sabberworm\\CSS\\Value\\Size\|Sabberworm\\CSS\\Value\\URL given\.$#' identifier: argument.type diff --git a/src/CSSList/CSSList.php b/src/CSSList/CSSList.php index e09a03a98..56495f388 100644 --- a/src/CSSList/CSSList.php +++ b/src/CSSList/CSSList.php @@ -370,7 +370,7 @@ public function removeDeclarationBlockBySelector($selectors, bool $removeAll = f if (!($item instanceof DeclarationBlock)) { continue; } - if ($item->getSelectors() == $selectors) { + if (self::selectorsMatch($item->getSelectors(), $selectors)) { unset($this->contents[$key]); if (!$removeAll) { return; @@ -427,4 +427,34 @@ public function getContents(): array { return $this->contents; } + + /** + * @param list $selectors1 + * @param list $selectors2 + */ + private static function selectorsMatch(array $selectors1, array $selectors2): bool + { + $selectorStrings1 = self::getSelectorStrings($selectors1); + $selectorStrings2 = self::getSelectorStrings($selectors2); + + \sort($selectorStrings1); + \sort($selectorStrings2); + + return $selectorStrings1 === $selectorStrings2; + } + + /** + * @param list $selectors + * + * @return list + */ + private static function getSelectorStrings(array $selectors): array + { + return \array_map( + static function (Selector $selector): string { + return $selector->getSelector(); + }, + $selectors + ); + } } diff --git a/tests/Unit/CSSList/CSSListTest.php b/tests/Unit/CSSList/CSSListTest.php index ada176e9a..523ada093 100644 --- a/tests/Unit/CSSList/CSSListTest.php +++ b/tests/Unit/CSSList/CSSListTest.php @@ -239,6 +239,22 @@ public function removeDeclarationBlockBySelectorRemovesDeclarationBlockWithOutso self::assertSame([], $subject->getContents()); } + /** + * @test + */ + public function removeDeclarationBlockBySelectorRemovesDeclarationBlockWithSelectorsInReverseOrder(): void + { + $subject = new ConcreteCSSList(); + $declarationBlock = new DeclarationBlock(); + $declarationBlock->setSelectors(['html', 'body']); + $subject->setContents([$declarationBlock]); + self::assertNotSame([], $subject->getContents()); // make sure contents are set + + $subject->removeDeclarationBlockBySelector([new Selector('body'), new Selector('html')]); + + self::assertSame([], $subject->getContents()); + } + /** * @test */ From 00f637f6e98065bbfe2f083bc207bfdf1db08ea3 Mon Sep 17 00:00:00 2001 From: Oliver Klee Date: Fri, 5 Dec 2025 12:28:08 +0100 Subject: [PATCH 18/43] [TASK] Avoid heredoc in another test (#1411) This allows the testcase being autoformatted without breaking the tests for PHP 7.2. https://www.php.net/manual/en/language.types.string.php#language.types.string.syntax.heredoc --- tests/OutputFormatTest.php | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/tests/OutputFormatTest.php b/tests/OutputFormatTest.php index cb0dfd42c..104ad7b5c 100644 --- a/tests/OutputFormatTest.php +++ b/tests/OutputFormatTest.php @@ -15,22 +15,19 @@ */ final class OutputFormatTest extends TestCase { - private const TEST_CSS = << Date: Sat, 6 Dec 2025 10:58:18 +0100 Subject: [PATCH 19/43] [Dependabot] Update phpunit/phpunit requirement from 8.5.49 to 8.5.50 (#1413) Updates the requirements on [phpunit/phpunit](https://github.com/sebastianbergmann/phpunit) to permit the latest version. - [Release notes](https://github.com/sebastianbergmann/phpunit/releases) - [Changelog](https://github.com/sebastianbergmann/phpunit/blob/8.5.50/ChangeLog-8.5.md) - [Commits](https://github.com/sebastianbergmann/phpunit/compare/8.5.49...8.5.50) --- updated-dependencies: - dependency-name: phpunit/phpunit dependency-version: 8.5.50 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 476b3bcb2..caaf9a54a 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ "phpstan/phpstan": "1.12.32 || 2.1.32", "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8", "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7", - "phpunit/phpunit": "8.5.49", + "phpunit/phpunit": "8.5.50", "rawr/phpunit-data-provider": "3.3.1", "rector/rector": "1.2.10 || 2.2.8", "rector/type-perfect": "1.0.0 || 2.1.0", From 49bf66f053a84daf6b34294c59f756b1cd8757e9 Mon Sep 17 00:00:00 2001 From: 8ctopus Date: Mon, 8 Dec 2025 18:59:46 +0400 Subject: [PATCH 20/43] [CLEANUP] Clean extra whitespace in CSS selector (#1398) --- CHANGELOG.md | 1 + src/Property/Selector.php | 8 ++++- tests/ParserTest.php | 2 +- tests/Unit/Property/SelectorTest.php | 47 ++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e35259471..3026b9364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Please also have a look at our ### Changed +- Cleanup extra whitespace in css selector (#1398) - The array keys passed to `DeclarationBlock::setSelectors()` are no longer preserved (#1407) diff --git a/src/Property/Selector.php b/src/Property/Selector.php index 8f6290b4b..b935b2a82 100644 --- a/src/Property/Selector.php +++ b/src/Property/Selector.php @@ -9,6 +9,7 @@ use Sabberworm\CSS\Renderable; use function Safe\preg_match; +use function Safe\preg_replace; /** * Class representing a single CSS selector. Selectors have to be split by the comma prior to being passed into this @@ -74,7 +75,12 @@ public function getSelector(): string public function setSelector(string $selector): void { - $this->selector = \trim($selector); + $selector = \trim($selector); + + $hasAttribute = \strpos($selector, '[') !== false; + + // Whitespace can't be adjusted within an attribute selector, as it would change its meaning + $this->selector = !$hasAttribute ? preg_replace('/\\s++/', ' ', $selector) : $selector; } /** diff --git a/tests/ParserTest.php b/tests/ParserTest.php index a2bd4e703..7604feb31 100644 --- a/tests/ParserTest.php +++ b/tests/ParserTest.php @@ -770,7 +770,7 @@ public function selectorIgnoresInFile(): void $document = self::parsedStructureForFile('selector-ignores', Settings::create()->withMultibyteSupport(true)); $expected = '.some[selectors-may=\'contain-a-{\'] {}' . "\n" - . '.this-selector .valid {width: 100px;}' + . '.this-selector .valid {width: 100px;}' . "\n" . '@media only screen and (min-width: 200px) {.test {prop: val;}}'; self::assertSame($expected, $document->render()); diff --git a/tests/Unit/Property/SelectorTest.php b/tests/Unit/Property/SelectorTest.php index e53e8274d..cfec36fba 100644 --- a/tests/Unit/Property/SelectorTest.php +++ b/tests/Unit/Property/SelectorTest.php @@ -141,4 +141,51 @@ public function isValidForInvalidSelectorReturnsFalse(string $selector): void { self::assertFalse(Selector::isValid($selector)); } + + /** + * @test + */ + public function cleansUpSpacesWithinSelector(): void + { + $selector = 'p > small'; + + $subject = new Selector($selector); + + self::assertSame('p > small', $subject->getSelector()); + } + + /** + * @test + */ + public function cleansUpTabsWithinSelector(): void + { + $selector = "p\t>\tsmall"; + + $subject = new Selector($selector); + + self::assertSame('p > small', $subject->getSelector()); + } + + /** + * @test + */ + public function cleansUpNewLineWithinSelector(): void + { + $selector = "p\n>\nsmall"; + + $subject = new Selector($selector); + + self::assertSame('p > small', $subject->getSelector()); + } + + + /** + * @test + */ + public function doesNotCleanupSpacesWithinAttributeSelector(): void + { + $subject = new Selector('a[title="extra space"]'); + + self::assertSame('a[title="extra space"]', $subject->getSelector()); + } } From 21712ccbb979b00d82fabb87286d5c435f958096 Mon Sep 17 00:00:00 2001 From: JakeQZ Date: Mon, 8 Dec 2025 16:54:36 +0000 Subject: [PATCH 21/43] [DOCS] Capitalize 'CSS' in the changelog (#1414) Also correct grammar of 'clean up', which is two separate words when used as a verb (though as a noun or adjective is a single word). --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3026b9364..c5882abcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ Please also have a look at our ### Changed -- Cleanup extra whitespace in css selector (#1398) +- Clean up extra whitespace in CSS selector (#1398) - The array keys passed to `DeclarationBlock::setSelectors()` are no longer preserved (#1407) From 400edb2d5404fcb51a21a0d9c601f01bc637380d Mon Sep 17 00:00:00 2001 From: JakeQZ Date: Mon, 8 Dec 2025 20:18:11 +0000 Subject: [PATCH 22/43] [DEVELOPER] Disallow 'heredoc' and 'nowdoc' for strings (#1415) Part of #1412. --- config/phpcs.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/phpcs.xml b/config/phpcs.xml index bb8801e75..f4a05b783 100644 --- a/config/phpcs.xml +++ b/config/phpcs.xml @@ -9,4 +9,5 @@ + From e6b19f22fa06e0e336d702ecabfe2566f3401838 Mon Sep 17 00:00:00 2001 From: JakeQZ Date: Tue, 9 Dec 2025 08:36:02 +0000 Subject: [PATCH 23/43] [CLEANUP] Extract method `DeclarationBlock::parseSelectors` (#1416) This now does the selector-parsing that `parse()` used to do itself. Should help with #1324 and https://github.com/MyIntervals/PHP-CSS-Parser/pull/1398#issuecomment-3590773005 --- src/RuleSet/DeclarationBlock.php | 121 ++++++++++++++++++------------- 1 file changed, 69 insertions(+), 52 deletions(-) diff --git a/src/RuleSet/DeclarationBlock.php b/src/RuleSet/DeclarationBlock.php index 8a0f4e487..b5ce1eda3 100644 --- a/src/RuleSet/DeclarationBlock.php +++ b/src/RuleSet/DeclarationBlock.php @@ -4,6 +4,7 @@ namespace Sabberworm\CSS\RuleSet; +use Sabberworm\CSS\Comment\Comment; use Sabberworm\CSS\Comment\CommentContainer; use Sabberworm\CSS\CSSElement; use Sabberworm\CSS\CSSList\CSSList; @@ -65,58 +66,7 @@ public static function parse(ParserState $parserState, ?CSSList $list = null): ? $comments = []; $result = new DeclarationBlock($parserState->currentLine()); try { - $selectors = []; - $selectorParts = []; - $stringWrapperCharacter = null; - $functionNestingLevel = 0; - $consumedNextCharacter = false; - static $stopCharacters = ['{', '}', '\'', '"', '(', ')', ',']; - do { - if (!$consumedNextCharacter) { - $selectorParts[] = $parserState->consume(1); - } - $selectorParts[] = $parserState->consumeUntil($stopCharacters, false, false, $comments); - $nextCharacter = $parserState->peek(); - $consumedNextCharacter = false; - switch ($nextCharacter) { - case '\'': - // The fallthrough is intentional. - case '"': - if (!\is_string($stringWrapperCharacter)) { - $stringWrapperCharacter = $nextCharacter; - } elseif ($stringWrapperCharacter === $nextCharacter) { - if (\substr(\end($selectorParts), -1) !== '\\') { - $stringWrapperCharacter = null; - } - } - break; - 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); - $consumedNextCharacter = true; - } - break; - } - } while (!\in_array($nextCharacter, ['{', '}'], true) || \is_string($stringWrapperCharacter)); - if ($functionNestingLevel !== 0) { - throw new UnexpectedTokenException(')', $nextCharacter); - } - $selectors[] = \implode('', $selectorParts); // add final or only selector + $selectors = self::parseSelectors($parserState, $comments); $result->setSelectors($selectors, $list); if ($parserState->comes('{')) { $parserState->consume(1); @@ -303,4 +253,71 @@ public function render(OutputFormat $outputFormat): string return $result; } + + /** + * @param array $comments + * + * @return list + * + * @throws UnexpectedTokenException + */ + private static function parseSelectors(ParserState $parserState, array &$comments): array + { + $selectors = []; + $selectorParts = []; + $stringWrapperCharacter = null; + $functionNestingLevel = 0; + $consumedNextCharacter = false; + static $stopCharacters = ['{', '}', '\'', '"', '(', ')', ',']; + + do { + if (!$consumedNextCharacter) { + $selectorParts[] = $parserState->consume(1); + } + $selectorParts[] = $parserState->consumeUntil($stopCharacters, false, false, $comments); + $nextCharacter = $parserState->peek(); + $consumedNextCharacter = false; + switch ($nextCharacter) { + case '\'': + // The fallthrough is intentional. + case '"': + if (!\is_string($stringWrapperCharacter)) { + $stringWrapperCharacter = $nextCharacter; + } elseif ($stringWrapperCharacter === $nextCharacter) { + if (\substr(\end($selectorParts), -1) !== '\\') { + $stringWrapperCharacter = null; + } + } + break; + 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); + $consumedNextCharacter = true; + } + break; + } + } while (!\in_array($nextCharacter, ['{', '}'], true) || \is_string($stringWrapperCharacter)); + + if ($functionNestingLevel !== 0) { + throw new UnexpectedTokenException(')', $nextCharacter); + } + $selectors[] = \implode('', $selectorParts); // add final or only selector + + return $selectors; + } } From d1af3bb56cd8e061d2811c917934be21748eb863 Mon Sep 17 00:00:00 2001 From: Oliver Klee Date: Tue, 9 Dec 2025 13:18:32 +0100 Subject: [PATCH 24/43] [DOCS] Document how to create git tags (#1417) Copied from our sister project at https://github.com/MyIntervals/emogrifier/pull/1532 --- docs/release-checklist.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 48d50b7e6..5d673b898 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -13,3 +13,28 @@ [Releases tab](https://github.com/MyIntervals/PHP-CSS-Parser/releases), create a new release and copy the change log entries to the new release. 1. Post about the new release on social media. + +## Working with git tags + +List all tags: + +```bash +git tag +``` + +Locally create a tag from the current `HEAD` commit and push it to the git +remote `origin`: + +```bash +git tag -a v4.2.0 -m "Tag version 4.2.0" +git push --tags +``` + +Locally create a +[GPG-signed](https://git-scm.com/book/ms/v2/Git-Tools-Signing-Your-Work) tag +from the current `HEAD` commit and push it to the git remote `origin`: + +```bash +git tag -a -s v4.2.0 -m "Tag version 4.2.0" +git push --tags +``` From a5d1172ba7e4a0f2a8acb205c91c1fe2e848e819 Mon Sep 17 00:00:00 2001 From: JakeQZ Date: Tue, 9 Dec 2025 12:43:02 +0000 Subject: [PATCH 25/43] [CLEANUP] Tighten `consumeUntil` `$comments` parameter type (#1418) --- src/Parsing/ParserState.php | 2 +- src/RuleSet/DeclarationBlock.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Parsing/ParserState.php b/src/Parsing/ParserState.php index 864910fcb..722cd92c6 100644 --- a/src/Parsing/ParserState.php +++ b/src/Parsing/ParserState.php @@ -328,7 +328,7 @@ public function isEnd(): bool /** * @param list|string|self::EOF $stopCharacters - * @param array $comments + * @param list $comments * * @throws UnexpectedEOFException * @throws UnexpectedTokenException diff --git a/src/RuleSet/DeclarationBlock.php b/src/RuleSet/DeclarationBlock.php index b5ce1eda3..65a4862f0 100644 --- a/src/RuleSet/DeclarationBlock.php +++ b/src/RuleSet/DeclarationBlock.php @@ -255,7 +255,7 @@ public function render(OutputFormat $outputFormat): string } /** - * @param array $comments + * @param list $comments * * @return list * From 8d123a220a3f070d10c2054e39363635df0b4479 Mon Sep 17 00:00:00 2001 From: JakeQZ Date: Tue, 9 Dec 2025 18:44:35 +0000 Subject: [PATCH 26/43] [BUGFIX] Allow commas in attributes in `setSelectors` (#1419) This fixes an issue noted in #1324. Also add additional test data to fully exercise the code. --- CHANGELOG.md | 2 + src/RuleSet/DeclarationBlock.php | 23 ++++++- tests/Unit/RuleSet/DeclarationBlockTest.php | 70 +++++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5882abcf..74bdef76e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ Please also have a look at our ### Fixed +- Support attribute selectors with values containing commas in + `DeclarationBlock::setSelectors()` (#1419) - Allow `removeDeclarationBlockBySelector()` to be order-insensitve (#1406) - Fix parsing of `calc` expressions when a newline immediately precedes or follows a `+` or `-` operator (#1399) diff --git a/src/RuleSet/DeclarationBlock.php b/src/RuleSet/DeclarationBlock.php index 65a4862f0..81590d518 100644 --- a/src/RuleSet/DeclarationBlock.php +++ b/src/RuleSet/DeclarationBlock.php @@ -20,6 +20,7 @@ use Sabberworm\CSS\Property\KeyframeSelector; use Sabberworm\CSS\Property\Selector; use Sabberworm\CSS\Rule\Rule; +use Sabberworm\CSS\Settings; /** * This class represents a `RuleSet` constrained by a `Selector`. @@ -98,7 +99,25 @@ public function setSelectors($selectors, ?CSSList $list = null): void if (\is_array($selectors)) { $selectorsToSet = $selectors; } else { - $selectorsToSet = \explode(',', $selectors); + // A string of comma-separated selectors requires parsing. + // Parse as if it's the opening part of a rule. + try { + $parserState = new ParserState($selectors . '{', Settings::create()); + $selectorsToSet = self::parseSelectors($parserState); + $parserState->consume('{'); // throw exception if this is not next + if (!$parserState->isEnd()) { + throw new UnexpectedTokenException('EOF', 'more'); + } + } catch (UnexpectedTokenException $exception) { + // The exception message from parsing may refer to the faux `{` block start token, + // which would be confusing. + // Rethrow with a more useful message, that also includes the selector(s) string that was passed. + throw new UnexpectedTokenException( + 'Selector(s) string is not valid.', + $selectors, + 'custom' + ); + } } // Convert all items to a `Selector` if not already @@ -261,7 +280,7 @@ public function render(OutputFormat $outputFormat): string * * @throws UnexpectedTokenException */ - private static function parseSelectors(ParserState $parserState, array &$comments): array + private static function parseSelectors(ParserState $parserState, array &$comments = []): array { $selectors = []; $selectorParts = []; diff --git a/tests/Unit/RuleSet/DeclarationBlockTest.php b/tests/Unit/RuleSet/DeclarationBlockTest.php index 5f226846a..685112d55 100644 --- a/tests/Unit/RuleSet/DeclarationBlockTest.php +++ b/tests/Unit/RuleSet/DeclarationBlockTest.php @@ -8,6 +8,7 @@ use Sabberworm\CSS\CSSElement; use Sabberworm\CSS\CSSList\CSSListItem; use Sabberworm\CSS\Parsing\ParserState; +use Sabberworm\CSS\Parsing\UnexpectedTokenException; use Sabberworm\CSS\Position\Positionable; use Sabberworm\CSS\Property\Selector; use Sabberworm\CSS\Rule\Rule; @@ -293,4 +294,73 @@ public function setSelectorsIgnoresKeys(): void self::assertSame([0, 1], \array_keys($result)); } + + /** + * @test + * + * @param non-empty-string $selector + * + * @dataProvider provideSelector + */ + public function setSelectorsSetsSingleSelectorProvidedAsString(string $selector): void + { + $subject = new DeclarationBlock(); + + $subject->setSelectors($selector); + + $result = $subject->getSelectors(); + self::assertSame([$selector], self::getSelectorsAsStrings($subject)); + } + + /** + * @test + * + * @param non-empty-string $firstSelector + * @param non-empty-string $secondSelector + * + * @dataProvider provideTwoSelectors + */ + public function setSelectorsSetsTwoCommaSeparatedSelectorsProvidedAsString( + string $firstSelector, + string $secondSelector + ): void { + $joinedSelectors = $firstSelector . ', ' . $secondSelector; + $subject = new DeclarationBlock(); + + $subject->setSelectors($joinedSelectors); + + $result = $subject->getSelectors(); + self::assertSame([$firstSelector, $secondSelector], self::getSelectorsAsStrings($subject)); + } + + /** + * Provides selectors that would be parsed without error in the context of full CSS, but are nonetheless invalid. + * + * @return array + */ + public static function provideInvalidStandaloneSelector(): array + { + return [ + 'rogue `{`' => ['a { b'], + 'rogue `}`' => ['a } b'], + ]; + } + + /** + * @test + * + * @param non-empty-string $selector + * + * @dataProvider provideInvalidSelector + * @dataProvider provideInvalidStandaloneSelector + */ + public function setSelectorsThrowsExceptionWithInvalidSelector(string $selector): void + { + $this->expectException(UnexpectedTokenException::class); + $this->expectExceptionMessageMatches('/^Selector\\(s\\) string is not valid. /'); + + $subject = new DeclarationBlock(); + + $subject->setSelectors($selector); + } } From 42502fd99a4cd0068a97163a0fde302592f385d9 Mon Sep 17 00:00:00 2001 From: JakeQZ Date: Thu, 11 Dec 2025 21:27:54 +0000 Subject: [PATCH 27/43] [BUGFIX] Parse comment(s) immediately preceding selector (#1421) Also parse consecutive comments. --- CHANGELOG.md | 2 + src/Parsing/ParserState.php | 23 +++++- tests/ParserTest.php | 7 +- tests/Unit/Parsing/ParserStateTest.php | 100 +++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 tests/Unit/Parsing/ParserStateTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 74bdef76e..ffbd588f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ Please also have a look at our ### Fixed +- Parse comment(s) immediately preceding a selector (#1421) +- Parse consecutive comments (#1421) - Support attribute selectors with values containing commas in `DeclarationBlock::setSelectors()` (#1419) - Allow `removeDeclarationBlockBySelector()` to be order-insensitve (#1406) diff --git a/src/Parsing/ParserState.php b/src/Parsing/ParserState.php index 722cd92c6..e17c3f3ab 100644 --- a/src/Parsing/ParserState.php +++ b/src/Parsing/ParserState.php @@ -343,6 +343,7 @@ public function consumeUntil( $consumedCharacters = ''; $start = $this->currentPosition; + $comments = \array_merge($comments, $this->consumeComments()); while (!$this->isEnd()) { $character = $this->consume(1); if (\in_array($character, $stopCharacters, true)) { @@ -354,10 +355,7 @@ public function consumeUntil( return $consumedCharacters; } $consumedCharacters .= $character; - $comment = $this->consumeComment(); - if ($comment instanceof Comment) { - $comments[] = $comment; - } + $comments = \array_merge($comments, $this->consumeComments()); } if (\in_array(self::EOF, $stopCharacters, true)) { @@ -455,4 +453,21 @@ private function strsplit(string $string): array return $result; } + + /** + * @return list + */ + private function consumeComments(): array + { + $comments = []; + + while (true) { + $comment = $this->consumeComment(); + if ($comment instanceof Comment) { + $comments[] = $comment; + } else { + return $comments; + } + } + } } diff --git a/tests/ParserTest.php b/tests/ParserTest.php index 7604feb31..5b360bdae 100644 --- a/tests/ParserTest.php +++ b/tests/ParserTest.php @@ -1048,10 +1048,9 @@ public function commentExtracting(): void $fooBarBlock = $nodes[1]; self::assertInstanceOf(Commentable::class, $fooBarBlock); $fooBarBlockComments = $fooBarBlock->getComments(); - // TODO Support comments in selectors. - // $this->assertCount(2, $fooBarBlockComments); - // $this->assertSame("* Number 4 *", $fooBarBlockComments[0]->getComment()); - // $this->assertSame("* Number 5 *", $fooBarBlockComments[1]->getComment()); + self::assertCount(2, $fooBarBlockComments); + self::assertSame(' Number 4 ', $fooBarBlockComments[0]->getComment()); + self::assertSame(' Number 5 ', $fooBarBlockComments[1]->getComment()); // Declaration rules. self::assertInstanceOf(DeclarationBlock::class, $fooBarBlock); diff --git a/tests/Unit/Parsing/ParserStateTest.php b/tests/Unit/Parsing/ParserStateTest.php new file mode 100644 index 000000000..245b083f8 --- /dev/null +++ b/tests/Unit/Parsing/ParserStateTest.php @@ -0,0 +1,100 @@ + + * } + * > + */ + public static function provideTextForConsumptionWithComments(): array + { + return [ + 'comment at start' => [ + 'text' => '/*comment*/hello{', + 'stopCharacter' => '{', + 'expectedConsumedText' => 'hello', + 'expectedComments' => ['comment'], + ], + 'comment at end' => [ + 'text' => 'hello/*comment*/{', + 'stopCharacter' => '{', + 'expectedConsumedText' => 'hello', + 'expectedComments' => ['comment'], + ], + 'comment in middle' => [ + 'text' => 'hell/*comment*/o{', + 'stopCharacter' => '{', + 'expectedConsumedText' => 'hello', + 'expectedComments' => ['comment'], + ], + 'two comments at start' => [ + 'text' => '/*comment1*//*comment2*/hello{', + 'stopCharacter' => '{', + 'expectedConsumedText' => 'hello', + 'expectedComments' => ['comment1', 'comment2'], + ], + 'two comments at end' => [ + 'text' => 'hello/*comment1*//*comment2*/{', + 'stopCharacter' => '{', + 'expectedConsumedText' => 'hello', + 'expectedComments' => ['comment1', 'comment2'], + ], + 'two comments interspersed' => [ + 'text' => 'he/*comment1*/ll/*comment2*/o{', + 'stopCharacter' => '{', + 'expectedConsumedText' => 'hello', + 'expectedComments' => ['comment1', 'comment2'], + ], + ]; + } + + /** + * @test + * + * @param non-empty-string $text + * @param non-empty-string $stopCharacter + * @param non-empty-string $expectedConsumedText + * @param non-empty-list $expectedComments + * + * @dataProvider provideTextForConsumptionWithComments + */ + public function consumeUntilExtractsComments( + string $text, + string $stopCharacter, + string $expectedConsumedText, + array $expectedComments + ): void { + $subject = new ParserState($text, Settings::create()); + + $comments = []; + $result = $subject->consumeUntil($stopCharacter, false, false, $comments); + + self::assertSame($expectedConsumedText, $result); + $commentsAsText = \array_map( + static function (Comment $comment): string { + return $comment->getComment(); + }, + $comments + ); + self::assertSame($expectedComments, $commentsAsText); + } +} From 1b1f91cf3ec01e121c9e6ed5834e85b42adb7b53 Mon Sep 17 00:00:00 2001 From: JakeQZ Date: Fri, 12 Dec 2025 07:44:08 +0000 Subject: [PATCH 28/43] [TASK] Add `ParserState::consumeIfComes` method (#1422) This, when used (in various places), will be more efficient than doing a `peek()` followed by a `consume()`. --- src/Parsing/ParserState.php | 21 +++++++++ tests/Unit/Parsing/ParserStateTest.php | 60 ++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/src/Parsing/ParserState.php b/src/Parsing/ParserState.php index e17c3f3ab..df8253772 100644 --- a/src/Parsing/ParserState.php +++ b/src/Parsing/ParserState.php @@ -279,6 +279,27 @@ public function consume($value = 1): string return $result; } + /** + * If the possibly-expected next content is next, consume it. + * + * @param non-empty-string $nextContent + * + * @return bool whether the possibly-expected content was found and consumed + */ + public function consumeIfComes(string $nextContent): bool + { + $length = $this->strlen($nextContent); + if (!$this->streql($this->substr($this->currentPosition, $length), $nextContent)) { + return false; + } + + $numberOfLines = \substr_count($nextContent, "\n"); + $this->lineNumber += $numberOfLines; + $this->currentPosition += $this->strlen($nextContent); + + return true; + } + /** * @param string $expression * @param int<1, max>|null $maximumLength diff --git a/tests/Unit/Parsing/ParserStateTest.php b/tests/Unit/Parsing/ParserStateTest.php index 245b083f8..bd22508ab 100644 --- a/tests/Unit/Parsing/ParserStateTest.php +++ b/tests/Unit/Parsing/ParserStateTest.php @@ -97,4 +97,64 @@ static function (Comment $comment): string { ); self::assertSame($expectedComments, $commentsAsText); } + + /** + * @test + */ + public function consumeIfComesComsumesMatchingContent(): void + { + $subject = new ParserState('abc', Settings::create()); + + $subject->consumeIfComes('ab'); + + self::assertSame('c', $subject->peek()); + } + + /** + * @test + */ + public function consumeIfComesDoesNotComsumeNonMatchingContent(): void + { + $subject = new ParserState('a', Settings::create()); + + $subject->consumeIfComes('x'); + + self::assertSame('a', $subject->peek()); + } + + /** + * @test + */ + public function consumeIfComesReturnsTrueIfContentConsumed(): void + { + $subject = new ParserState('abc', Settings::create()); + + $result = $subject->consumeIfComes('ab'); + + self::assertTrue($result); + } + + /** + * @test + */ + public function consumeIfComesReturnsFalseIfContentNotConsumed(): void + { + $subject = new ParserState('a', Settings::create()); + + $result = $subject->consumeIfComes('x'); + + self::assertFalse($result); + } + + /** + * @test + */ + public function consumeIfComesUpdatesLineNumber(): void + { + $subject = new ParserState("\n", Settings::create()); + + $subject->consumeIfComes("\n"); + + self::assertSame(2, $subject->currentLine()); + } } From 024f1f352a9a87abb5956ea249bc7488c2023bcb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 12:07:26 +0100 Subject: [PATCH 29/43] [Dependabot] Bump actions/cache from 4 to 5 (#1423) Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++--- .github/workflows/codecoverage.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbab016b1..b35ea69f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: run: composer config --global --list - name: Cache dependencies installed with composer - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/composer key: php${{ matrix.php-version }}-composer-${{ hashFiles('**/composer.json') }} @@ -77,7 +77,7 @@ jobs: run: composer config --global --list - name: Cache dependencies installed with composer - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/composer key: php${{ matrix.php-version }}-composer-${{ hashFiles('**/composer.json') }} @@ -126,7 +126,7 @@ jobs: run: composer config --global --list - name: Cache dependencies installed with composer - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/composer key: php${{ matrix.php-version }}-composer-${{ hashFiles('**/composer.json') }} diff --git a/.github/workflows/codecoverage.yml b/.github/workflows/codecoverage.yml index 457471e9d..15b1c0546 100644 --- a/.github/workflows/codecoverage.yml +++ b/.github/workflows/codecoverage.yml @@ -39,7 +39,7 @@ jobs: run: composer config --global --list - name: Cache dependencies installed with composer - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/composer key: php${{ matrix.php-version }}-composer-${{ hashFiles('**/composer.json') }} From a2c978ccb43881c756f0503ff0523c9ba3d50bea Mon Sep 17 00:00:00 2001 From: JakeQZ Date: Fri, 12 Dec 2025 13:41:30 +0000 Subject: [PATCH 30/43] [CLEANUP] Extract method `DeclarationBlock::parseSelector` (#1420) Closes #1324 --- src/RuleSet/DeclarationBlock.php | 43 ++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/src/RuleSet/DeclarationBlock.php b/src/RuleSet/DeclarationBlock.php index 81590d518..456dc33b2 100644 --- a/src/RuleSet/DeclarationBlock.php +++ b/src/RuleSet/DeclarationBlock.php @@ -283,19 +283,33 @@ public function render(OutputFormat $outputFormat): string private static function parseSelectors(ParserState $parserState, array &$comments = []): array { $selectors = []; + + while (true) { + $selectors[] = self::parseSelector($parserState, $comments); + if (!$parserState->consumeIfComes(',')) { + break; + } + } + + return $selectors; + } + + /** + * @param list $comments + * + * @throws UnexpectedTokenException + */ + private static function parseSelector(ParserState $parserState, array &$comments = []): string + { $selectorParts = []; $stringWrapperCharacter = null; $functionNestingLevel = 0; - $consumedNextCharacter = false; static $stopCharacters = ['{', '}', '\'', '"', '(', ')', ',']; - do { - if (!$consumedNextCharacter) { - $selectorParts[] = $parserState->consume(1); - } + while (true) { + $selectorParts[] = $parserState->consume(1); $selectorParts[] = $parserState->consumeUntil($stopCharacters, false, false, $comments); $nextCharacter = $parserState->peek(); - $consumedNextCharacter = false; switch ($nextCharacter) { case '\'': // The fallthrough is intentional. @@ -321,22 +335,25 @@ private static function parseSelectors(ParserState $parserState, array &$comment --$functionNestingLevel; } break; + case '{': + // The fallthrough is intentional. + case '}': + if (!\is_string($stringWrapperCharacter)) { + break 2; + } + break; case ',': if (!\is_string($stringWrapperCharacter) && $functionNestingLevel === 0) { - $selectors[] = \implode('', $selectorParts); - $selectorParts = []; - $parserState->consume(1); - $consumedNextCharacter = true; + break 2; } break; } - } while (!\in_array($nextCharacter, ['{', '}'], true) || \is_string($stringWrapperCharacter)); + } if ($functionNestingLevel !== 0) { throw new UnexpectedTokenException(')', $nextCharacter); } - $selectors[] = \implode('', $selectorParts); // add final or only selector - return $selectors; + return \implode('', $selectorParts); } } From dada8f8b0783522f6c347a4daf7b3f7a5f8abf61 Mon Sep 17 00:00:00 2001 From: JakeQZ Date: Sat, 13 Dec 2025 09:12:19 +0000 Subject: [PATCH 31/43] [BUGFIX] Improve recovery parsing upon a rogue `}` (#1425) (My IDE thinks that the test results now updated should be parsed as such.) Also remove a duplicated test. --- CHANGELOG.md | 1 + src/RuleSet/DeclarationBlock.php | 2 +- tests/ParserTest.php | 11 ++---- tests/Unit/RuleSet/DeclarationBlockTest.php | 39 +++++++++++++++++++++ 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffbd588f0..caf9aa4e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Please also have a look at our ### Fixed +- Improve recovery parsing when a rogue `}` is encountered (#1425) - Parse comment(s) immediately preceding a selector (#1421) - Parse consecutive comments (#1421) - Support attribute selectors with values containing commas in diff --git a/src/RuleSet/DeclarationBlock.php b/src/RuleSet/DeclarationBlock.php index 456dc33b2..7aa81f27f 100644 --- a/src/RuleSet/DeclarationBlock.php +++ b/src/RuleSet/DeclarationBlock.php @@ -74,7 +74,7 @@ public static function parse(ParserState $parserState, ?CSSList $list = null): ? } } catch (UnexpectedTokenException $e) { if ($parserState->getSettings()->usesLenientParsing()) { - if (!$parserState->comes('}')) { + if (!$parserState->consumeIfComes('}')) { $parserState->consumeUntil('}', false, true); } return null; diff --git a/tests/ParserTest.php b/tests/ParserTest.php index 5b360bdae..ffe278185 100644 --- a/tests/ParserTest.php +++ b/tests/ParserTest.php @@ -718,6 +718,7 @@ public function invalidSelectorsInFile(): void $document = self::parsedStructureForFile('invalid-selectors', Settings::create()->withMultibyteSupport(true)); $expected = '@keyframes mymove {from {top: 0px;}} #test {color: white;background: green;} +#test {display: block;background: red;color: white;} #test {display: block;background: white;color: black;}'; self::assertSame($expected, $document->render()); @@ -727,6 +728,7 @@ public function invalidSelectorsInFile(): void .super-menu > li:last-of-type {border-right-width: 0;} html[dir="rtl"] .super-menu > li:first-of-type {border-left-width: 1px;border-right-width: 0;} html[dir="rtl"] .super-menu > li:last-of-type {border-left-width: 0;}} +.super-menu.menu-floated {border-right-width: 1px;border-left-width: 1px;border-color: #5a4242;border-style: dotted;} body {background-color: red;}'; self::assertSame($expected, $document->render()); } @@ -740,15 +742,6 @@ public function selectorEscapesInFile(): void $expected = '#\\# {color: red;} .col-sm-1\\/5 {width: 20%;}'; self::assertSame($expected, $document->render()); - - $document = self::parsedStructureForFile('invalid-selectors-2', Settings::create()->withMultibyteSupport(true)); - $expected = '@media only screen and (max-width: 1215px) {.breadcrumb {padding-left: 10px;} - .super-menu > li:first-of-type {border-left-width: 0;} - .super-menu > li:last-of-type {border-right-width: 0;} - html[dir="rtl"] .super-menu > li:first-of-type {border-left-width: 1px;border-right-width: 0;} - html[dir="rtl"] .super-menu > li:last-of-type {border-left-width: 0;}} -body {background-color: red;}'; - self::assertSame($expected, $document->render()); } /** diff --git a/tests/Unit/RuleSet/DeclarationBlockTest.php b/tests/Unit/RuleSet/DeclarationBlockTest.php index 685112d55..caddbd134 100644 --- a/tests/Unit/RuleSet/DeclarationBlockTest.php +++ b/tests/Unit/RuleSet/DeclarationBlockTest.php @@ -192,6 +192,45 @@ public function parseSkipsBlockWithInvalidSelector(string $selector): void self::assertTrue($parserState->comes($nextCss)); } + /** + * @return array + */ + public static function provideClosingBrace(): array + { + return [ + 'as is' => ['}'], + 'with space before' => [' }'], + 'with newline before' => ["\n}"], + ]; + } + + /** + * @return DataProvider + */ + public static function provideInvalidSelectorAndClosingBrace(): DataProvider + { + return DataProvider::cross(self::provideInvalidSelector(), self::provideClosingBrace()); + } + + /** + * TODO: It's probably not the responsibility of `DeclarationBlock` to deal with this. + * + * @test + * + * @param non-empty-string $selector + * @param non-empty-string $closingBrace + * + * @dataProvider provideInvalidSelectorAndClosingBrace + */ + public function parseConsumesClosingBraceAfterInvalidSelector(string $selector, string $closingBrace): void + { + $parserState = new ParserState($selector . $closingBrace, Settings::create()); + + DeclarationBlock::parse($parserState); + + self::assertTrue($parserState->isEnd()); + } + /** * @return array */ From c7fb009174138e839dae360ac7e9489b12c638fb Mon Sep 17 00:00:00 2001 From: JakeQZ Date: Sat, 13 Dec 2025 19:43:00 +0000 Subject: [PATCH 32/43] [BUGFIX] Skip erroneous `}` when parsing `CSSList` (#1426) This allows parsing of the next item, if valid, rather than dropping it. --- CHANGELOG.md | 2 +- src/CSSList/CSSList.php | 3 ++- tests/Unit/CSSList/CSSListTest.php | 38 ++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index caf9aa4e8..421d9419f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ Please also have a look at our ### Fixed -- Improve recovery parsing when a rogue `}` is encountered (#1425) +- Improve recovery parsing when a rogue `}` is encountered (#1425, #1426) - Parse comment(s) immediately preceding a selector (#1421) - Parse consecutive comments (#1421) - Support attribute selectors with values containing commas in diff --git a/src/CSSList/CSSList.php b/src/CSSList/CSSList.php index 56495f388..84ece7f8e 100644 --- a/src/CSSList/CSSList.php +++ b/src/CSSList/CSSList.php @@ -133,7 +133,8 @@ private static function parseListItem(ParserState $parserState, CSSList $list) } elseif ($parserState->comes('}')) { if ($isRoot) { if ($parserState->getSettings()->usesLenientParsing()) { - return DeclarationBlock::parse($parserState) ?? false; + $parserState->consume(1); + return self::parseListItem($parserState, $list); } else { throw new SourceException('Unopened {', $parserState->currentLine()); } diff --git a/tests/Unit/CSSList/CSSListTest.php b/tests/Unit/CSSList/CSSListTest.php index 523ada093..548acbb77 100644 --- a/tests/Unit/CSSList/CSSListTest.php +++ b/tests/Unit/CSSList/CSSListTest.php @@ -7,10 +7,15 @@ use PHPUnit\Framework\TestCase; use Sabberworm\CSS\Comment\Commentable; use Sabberworm\CSS\CSSElement; +use Sabberworm\CSS\CSSList\CSSList; use Sabberworm\CSS\CSSList\CSSListItem; +use Sabberworm\CSS\CSSList\Document; +use Sabberworm\CSS\OutputFormat; +use Sabberworm\CSS\Parsing\ParserState; use Sabberworm\CSS\Property\Selector; use Sabberworm\CSS\Renderable; use Sabberworm\CSS\RuleSet\DeclarationBlock; +use Sabberworm\CSS\Settings; use Sabberworm\CSS\Tests\Unit\CSSList\Fixtures\ConcreteCSSList; /** @@ -342,4 +347,37 @@ public function removeDeclarationBlockBySelectorRemovesMultipleBlocksWithStringS self::assertSame([], $subject->getContents()); } + + /** + * The content provided must (currently) be in the same format as the expected rendering. + * + * @return array + */ + public function provideValidContentForParsing(): array + { + return [ + 'at-import rule' => ['@import url("foo.css");'], + 'rule with declaration block' => ['a {color: green;}'], + ]; + } + + /** + * @test + * + * @param non-empty-string $followingContent + * + * @dataProvider provideValidContentForParsing + */ + public function parseListAtRootLevelSkipsErroneousClosingBraceAndParsesFollowingContent( + string $followingContent + ): void { + $parserState = new ParserState('}' . $followingContent, Settings::create()); + // The subject needs to be a `Document`, as that is currently the test for 'root level'. + // Otherwise `}` will be treated as 'end of list'. + $subject = new Document(); + + CSSList::parseList($parserState, $subject); + + self::assertSame($followingContent, $subject->render(new OutputFormat())); + } } From f129f4cffa21ddde7d825b3a6c65dec7900ebb0a Mon Sep 17 00:00:00 2001 From: Oliver Klee Date: Sun, 14 Dec 2025 18:53:26 +0100 Subject: [PATCH 33/43] [CLEANUP] Avoid multiline strings in `CommentTest` (#1428) --- tests/Comment/CommentTest.php | 83 ++++++++++++++++------------------- 1 file changed, 38 insertions(+), 45 deletions(-) diff --git a/tests/Comment/CommentTest.php b/tests/Comment/CommentTest.php index 52c3de550..042a509b0 100644 --- a/tests/Comment/CommentTest.php +++ b/tests/Comment/CommentTest.php @@ -19,40 +19,35 @@ final class CommentTest extends TestCase public function keepCommentsInOutput(): void { $cssDocument = TestsParserTest::parsedStructureForFile('comments'); - self::assertSame('/** Number 11 **/ -/** - * Comments - */ - -/* Hell */ -@import url("some/url.css") screen; + $expected1 = "/** Number 11 **/\n\n" + . "/**\n" + . " * Comments\n" + . " */\n\n" + . "/* Hell */\n" + . "@import url(\"some/url.css\") screen;\n\n" + . "/* Number 4 */\n\n" + . "/* Number 5 */\n" + . ".foo, #bar {\n" + . "\t/* Number 6 */\n" + . "\tbackground-color: #000;\n" + . "}\n\n" + . "@media screen {\n" + . "\t/** Number 10 **/\n" + . "\t#foo.bar {\n" + . "\t\t/** Number 10b **/\n" + . "\t\tposition: absolute;\n" + . "\t}\n" + . "}\n"; + self::assertSame($expected1, $cssDocument->render(OutputFormat::createPretty())); -/* Number 4 */ - -/* Number 5 */ -.foo, #bar { - /* Number 6 */ - background-color: #000; -} - -@media screen { - /** Number 10 **/ - #foo.bar { - /** Number 10b **/ - position: absolute; - } -} -', $cssDocument->render(OutputFormat::createPretty())); - self::assertSame( - '/** Number 11 **//**' . "\n" - . ' * Comments' . "\n" + $expected2 = "/** Number 11 **//**\n" + . " * Comments\n" . ' *//* Hell */@import url("some/url.css") screen;' . '/* Number 4 *//* Number 5 */.foo,#bar{' . '/* Number 6 */background-color:#000}@media screen{' - . '/** Number 10 **/#foo.bar{/** Number 10b **/position:absolute}}', - $cssDocument->render(OutputFormat::createCompact()->setRenderComments(true)) - ); + . '/** Number 10 **/#foo.bar{/** Number 10b **/position:absolute}}'; + self::assertSame($expected2, $cssDocument->render(OutputFormat::createCompact()->setRenderComments(true))); } /** @@ -61,24 +56,22 @@ public function keepCommentsInOutput(): void public function stripCommentsFromOutput(): void { $css = TestsParserTest::parsedStructureForFile('comments'); - self::assertSame(' -@import url("some/url.css") screen; -.foo, #bar { - background-color: #000; -} + $expected1 = "\n" + . "@import url(\"some/url.css\") screen;\n\n" + . ".foo, #bar {\n" . + "\tbackground-color: #000;\n" + . "}\n\n" + . "@media screen {\n" + . "\t#foo.bar {\n" + . "\t\tposition: absolute;\n" + . "\t}\n" + . "}\n"; + self::assertSame($expected1, $css->render(OutputFormat::createPretty()->setRenderComments(false))); -@media screen { - #foo.bar { - position: absolute; - } -} -', $css->render(OutputFormat::createPretty()->setRenderComments(false))); - self::assertSame( - '@import url("some/url.css") screen;' + $expected2 = '@import url("some/url.css") screen;' . '.foo,#bar{background-color:#000}' - . '@media screen{#foo.bar{position:absolute}}', - $css->render(OutputFormat::createCompact()) - ); + . '@media screen{#foo.bar{position:absolute}}'; + self::assertSame($expected2, $css->render(OutputFormat::createCompact())); } } From 704c17f06ee6dff80fd4abb04c4f79de4068fa33 Mon Sep 17 00:00:00 2001 From: JakeQZ Date: Sun, 14 Dec 2025 20:05:29 +0000 Subject: [PATCH 34/43] [TASK] Guard against infinite loop in `parseList` (#1427) This can't be tested because there are currently no cases that would fail the test without the change. It is there as a preventative double-lock measure to make sure any future code changes do not introduce an infinite loop. It's a slight unoptimization, as it adds what should be a redundant runtime check, but at miniscule performance cost versus the value of preventing a waste of CPU power and memory in the case that something goes wrong. --- src/CSSList/CSSList.php | 6 ++++++ src/Parsing/ParserState.php | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/src/CSSList/CSSList.php b/src/CSSList/CSSList.php index 84ece7f8e..f18cd7725 100644 --- a/src/CSSList/CSSList.php +++ b/src/CSSList/CSSList.php @@ -72,9 +72,15 @@ public static function parseList(ParserState $parserState, CSSList $list): void $listItem = null; if ($usesLenientParsing) { try { + $positionBeforeParse = $parserState->currentColumn(); $listItem = self::parseListItem($parserState, $list); } catch (UnexpectedTokenException $e) { $listItem = false; + // If the failed parsing did not consume anything that was to come ... + if ($parserState->currentColumn() === $positionBeforeParse && !$parserState->isEnd()) { + // ... the unexpected token needs to be skipped, otherwise there'll be an infinite loop. + $parserState->consume(1); + } } } else { $listItem = self::parseListItem($parserState, $list); diff --git a/src/Parsing/ParserState.php b/src/Parsing/ParserState.php index df8253772..44f96a944 100644 --- a/src/Parsing/ParserState.php +++ b/src/Parsing/ParserState.php @@ -192,6 +192,10 @@ public function parseCharacter(bool $isForIdentifier): ?string * * @throws UnexpectedEOFException * @throws UnexpectedTokenException + * + * @phpstan-impure + * This method may change the state of the object by advancing the internal position; + * it does not simply 'get' a value. */ public function consumeWhiteSpace(): array { From 539c64caededf0ba25c7d03b5effd8bd862e6880 Mon Sep 17 00:00:00 2001 From: Oliver Klee Date: Sun, 14 Dec 2025 23:34:42 +0100 Subject: [PATCH 35/43] [CLEANUP] Avoid multiline strings in `OutputFormatTest` (#1429) Also avoid literal tabs. --- tests/OutputFormatTest.php | 129 ++++++++++++++++++++----------------- 1 file changed, 69 insertions(+), 60 deletions(-) diff --git a/tests/OutputFormatTest.php b/tests/OutputFormatTest.php index 104ad7b5c..6167e796b 100644 --- a/tests/OutputFormatTest.php +++ b/tests/OutputFormatTest.php @@ -51,8 +51,8 @@ protected function setUp(): void public function plain(): void { self::assertSame( - '.main, .test {font: italic normal bold 16px/1.2 "Helvetica",Verdana,sans-serif;background: white;} -@media screen {.main {background-size: 100% 100%;font-size: 1.3em;background-color: #fff;}}', + ".main, .test {font: italic normal bold 16px/1.2 \"Helvetica\",Verdana,sans-serif;background: white;}\n" + . '@media screen {.main {background-size: 100% 100%;font-size: 1.3em;background-color: #fff;}}', $this->document->render() ); } @@ -96,8 +96,8 @@ public function spaceAfterListArgumentSeparator(): void public function spaceAfterListArgumentSeparatorComplex(): void { self::assertSame( - '.main, .test {font: italic normal bold 16px/1.2 "Helvetica", Verdana, sans-serif;background: white;}' - . "\n@media screen {.main {background-size: 100% 100%;font-size: 1.3em;background-color: #fff;}}", + ".main, .test {font: italic normal bold 16px/1.2 \"Helvetica\",\tVerdana,\tsans-serif;background: white;}\n" + . '@media screen {.main {background-size: 100% 100%;font-size: 1.3em;background-color: #fff;}}', $this->document->render( OutputFormat::create() ->setSpaceAfterListArgumentSeparator(' ') @@ -116,9 +116,9 @@ public function spaceAfterListArgumentSeparatorComplex(): void public function spaceAfterSelectorSeparator(): void { self::assertSame( - '.main, -.test {font: italic normal bold 16px/1.2 "Helvetica",Verdana,sans-serif;background: white;} -@media screen {.main {background-size: 100% 100%;font-size: 1.3em;background-color: #fff;}}', + ".main,\n" + . ".test {font: italic normal bold 16px/1.2 \"Helvetica\",Verdana,sans-serif;background: white;}\n" + . '@media screen {.main {background-size: 100% 100%;font-size: 1.3em;background-color: #fff;}}', $this->document->render(OutputFormat::create()->setSpaceAfterSelectorSeparator("\n")) ); } @@ -129,8 +129,8 @@ public function spaceAfterSelectorSeparator(): void public function stringQuotingType(): void { self::assertSame( - '.main, .test {font: italic normal bold 16px/1.2 \'Helvetica\',Verdana,sans-serif;background: white;} -@media screen {.main {background-size: 100% 100%;font-size: 1.3em;background-color: #fff;}}', + ".main, .test {font: italic normal bold 16px/1.2 'Helvetica',Verdana,sans-serif;background: white;}\n" + . '@media screen {.main {background-size: 100% 100%;font-size: 1.3em;background-color: #fff;}}', $this->document->render(OutputFormat::create()->setStringQuotingType("'")) ); } @@ -141,8 +141,8 @@ public function stringQuotingType(): void public function rGBHashNotation(): void { self::assertSame( - '.main, .test {font: italic normal bold 16px/1.2 "Helvetica",Verdana,sans-serif;background: white;} -@media screen {.main {background-size: 100% 100%;font-size: 1.3em;background-color: rgb(255,255,255);}}', + ".main, .test {font: italic normal bold 16px/1.2 \"Helvetica\",Verdana,sans-serif;background: white;}\n" + . '@media screen {.main {background-size: 100% 100%;font-size: 1.3em;background-color: rgb(255,255,255);}}', $this->document->render(OutputFormat::create()->setRGBHashNotation(false)) ); } @@ -153,8 +153,8 @@ public function rGBHashNotation(): void public function semicolonAfterLastRule(): void { self::assertSame( - '.main, .test {font: italic normal bold 16px/1.2 "Helvetica",Verdana,sans-serif;background: white} -@media screen {.main {background-size: 100% 100%;font-size: 1.3em;background-color: #fff}}', + ".main, .test {font: italic normal bold 16px/1.2 \"Helvetica\",Verdana,sans-serif;background: white}\n" + . '@media screen {.main {background-size: 100% 100%;font-size: 1.3em;background-color: #fff}}', $this->document->render(OutputFormat::create()->setSemicolonAfterLastRule(false)) ); } @@ -165,8 +165,8 @@ public function semicolonAfterLastRule(): void public function spaceAfterRuleName(): void { self::assertSame( - '.main, .test {font: italic normal bold 16px/1.2 "Helvetica",Verdana,sans-serif;background: white;} -@media screen {.main {background-size: 100% 100%;font-size: 1.3em;background-color: #fff;}}', + ".main, .test {font:\titalic normal bold 16px/1.2 \"Helvetica\",Verdana,sans-serif;background:\twhite;}\n" + . "@media screen {.main {background-size:\t100% 100%;font-size:\t1.3em;background-color:\t#fff;}}", $this->document->render(OutputFormat::create()->setSpaceAfterRuleName("\t")) ); } @@ -181,15 +181,18 @@ public function spaceRules(): void ->setSpaceBetweenRules("\n") ->setSpaceAfterRules("\n"); - self::assertSame('.main, .test { - font: italic normal bold 16px/1.2 "Helvetica",Verdana,sans-serif; - background: white; -} -@media screen {.main { - background-size: 100% 100%; - font-size: 1.3em; - background-color: #fff; - }}', $this->document->render($outputFormat)); + self::assertSame( + ".main, .test {\n" + . "\tfont: italic normal bold 16px/1.2 \"Helvetica\",Verdana,sans-serif;\n" + . "\tbackground: white;\n" + . "}\n" + . "@media screen {.main {\n" + . "\t\tbackground-size: 100% 100%;\n" + . "\t\tfont-size: 1.3em;\n" + . "\t\tbackground-color: #fff;\n" + . "\t}}", + $this->document->render($outputFormat) + ); } /** @@ -202,12 +205,14 @@ public function spaceBlocks(): void ->setSpaceBetweenBlocks("\n") ->setSpaceAfterBlocks("\n"); - self::assertSame(' -.main, .test {font: italic normal bold 16px/1.2 "Helvetica",Verdana,sans-serif;background: white;} -@media screen { - .main {background-size: 100% 100%;font-size: 1.3em;background-color: #fff;} -} -', $this->document->render($outputFormat)); + self::assertSame( + "\n" + . ".main, .test {font: italic normal bold 16px/1.2 \"Helvetica\",Verdana,sans-serif;background: white;}\n" + . "@media screen {\n" + . "\t.main {background-size: 100% 100%;font-size: 1.3em;background-color: #fff;}\n" + . "}\n", + $this->document->render($outputFormat) + ); } /** @@ -223,19 +228,21 @@ public function spaceBoth(): void ->setSpaceBetweenBlocks("\n") ->setSpaceAfterBlocks("\n"); - self::assertSame(' -.main, .test { - font: italic normal bold 16px/1.2 "Helvetica",Verdana,sans-serif; - background: white; -} -@media screen { - .main { - background-size: 100% 100%; - font-size: 1.3em; - background-color: #fff; - } -} -', $this->document->render($outputFormat)); + self::assertSame( + "\n" + . ".main, .test {\n" + . "\tfont: italic normal bold 16px/1.2 \"Helvetica\",Verdana,sans-serif;\n" + . "\tbackground: white;\n" + . "}\n" + . "@media screen {\n" + . "\t.main {\n" + . "\t\tbackground-size: 100% 100%;\n" + . "\t\tfont-size: 1.3em;\n" + . "\t\tbackground-color: #fff;\n" + . "\t}\n" + . "}\n", + $this->document->render($outputFormat) + ); } /** @@ -267,19 +274,21 @@ public function indentation(): void ->setSpaceAfterBlocks("\n") ->setIndentation(''); - self::assertSame(' -.main, .test { -font: italic normal bold 16px/1.2 "Helvetica",Verdana,sans-serif; -background: white; -} -@media screen { -.main { -background-size: 100% 100%; -font-size: 1.3em; -background-color: #fff; -} -} -', $this->document->render($outputFormat)); + self::assertSame( + "\n" + . ".main, .test {\n" + . "font: italic normal bold 16px/1.2 \"Helvetica\",Verdana,sans-serif;\n" + . "background: white;\n" + . "}\n" + . "@media screen {\n" + . ".main {\n" + . "background-size: 100% 100%;\n" + . "font-size: 1.3em;\n" + . "background-color: #fff;\n" + . "}\n" + . "}\n", + $this->document->render($outputFormat) + ); } /** @@ -291,8 +300,8 @@ public function spaceBeforeBraces(): void ->setSpaceBeforeOpeningBrace(''); self::assertSame( - '.main, .test{font: italic normal bold 16px/1.2 "Helvetica",Verdana,sans-serif;background: white;} -@media screen{.main{background-size: 100% 100%;font-size: 1.3em;background-color: #fff;}}', + ".main, .test{font: italic normal bold 16px/1.2 \"Helvetica\",Verdana,sans-serif;background: white;}\n" + . '@media screen{.main{background-size: 100% 100%;font-size: 1.3em;background-color: #fff;}}', $this->document->render($outputFormat) ); } @@ -310,8 +319,8 @@ public function ignoreExceptionsOff(): void $firstDeclarationBlock = $declarationBlocks[0]; $firstDeclarationBlock->removeSelector('.main'); self::assertSame( - '.test {font: italic normal bold 16px/1.2 "Helvetica",Verdana,sans-serif;background: white;} -@media screen {.main {background-size: 100% 100%;font-size: 1.3em;background-color: #fff;}}', + ".test {font: italic normal bold 16px/1.2 \"Helvetica\",Verdana,sans-serif;background: white;}\n" + . '@media screen {.main {background-size: 100% 100%;font-size: 1.3em;background-color: #fff;}}', $this->document->render($outputFormat) ); $firstDeclarationBlock->removeSelector('.test'); From e94cb16b0cca8fdb4fd387dffe07c294d865dde4 Mon Sep 17 00:00:00 2001 From: JakeQZ Date: Mon, 15 Dec 2025 07:26:36 +0000 Subject: [PATCH 36/43] [TASK] Consume to EOF in `DeclarationBlock::parse()` upon failure (#1431) This is not the right long-term fix - see #1430. For now it is a sticking-plaster to help complete #1424. --- src/RuleSet/DeclarationBlock.php | 2 +- tests/Unit/RuleSet/DeclarationBlockTest.php | 40 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/RuleSet/DeclarationBlock.php b/src/RuleSet/DeclarationBlock.php index 7aa81f27f..6e2e1a60d 100644 --- a/src/RuleSet/DeclarationBlock.php +++ b/src/RuleSet/DeclarationBlock.php @@ -75,7 +75,7 @@ public static function parse(ParserState $parserState, ?CSSList $list = null): ? } catch (UnexpectedTokenException $e) { if ($parserState->getSettings()->usesLenientParsing()) { if (!$parserState->consumeIfComes('}')) { - $parserState->consumeUntil('}', false, true); + $parserState->consumeUntil(['}', ParserState::EOF], false, true); } return null; } else { diff --git a/tests/Unit/RuleSet/DeclarationBlockTest.php b/tests/Unit/RuleSet/DeclarationBlockTest.php index caddbd134..6b1b2865f 100644 --- a/tests/Unit/RuleSet/DeclarationBlockTest.php +++ b/tests/Unit/RuleSet/DeclarationBlockTest.php @@ -231,6 +231,46 @@ public function parseConsumesClosingBraceAfterInvalidSelector(string $selector, self::assertTrue($parserState->isEnd()); } + /** + * @return array + */ + public static function provideOptionalWhitespace(): array + { + return [ + 'none' => [''], + 'space' => [' '], + 'newline' => ["\n"], + ]; + } + + /** + * @return DataProvider + */ + public static function provideInvalidSelectorAndOptionalWhitespace(): DataProvider + { + return DataProvider::cross(self::provideInvalidSelector(), self::provideOptionalWhitespace()); + } + + /** + * TODO: It's probably not the responsibility of `DeclarationBlock` to deal with this. + * + * @test + * + * @param non-empty-string $selector + * + * @dataProvider provideInvalidSelectorAndOptionalWhitespace + */ + public function parseConsumesToEofIfNoClosingBraceAfterInvalidSelector( + string $selector, + string $optionalWhitespace + ): void { + $parserState = new ParserState($selector . $optionalWhitespace, Settings::create()); + + DeclarationBlock::parse($parserState); + + self::assertTrue($parserState->isEnd()); + } + /** * @return array */ From e58b67c8a85921f6e83530b76581dae6fe32523a Mon Sep 17 00:00:00 2001 From: Oliver Klee Date: Mon, 15 Dec 2025 17:32:20 +0100 Subject: [PATCH 37/43] [CLEANUP] Reformat the code with the PER-2 configuration (#1391) --- src/Comment/Comment.php | 2 +- src/RuleSet/DeclarationBlock.php | 12 ++++++------ tests/OutputFormatTest.php | 24 ++++++++++++------------ tests/Unit/Value/CalcFunctionTest.php | 1 + 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/Comment/Comment.php b/src/Comment/Comment.php index 33188988f..6f29682c2 100644 --- a/src/Comment/Comment.php +++ b/src/Comment/Comment.php @@ -5,9 +5,9 @@ namespace Sabberworm\CSS\Comment; use Sabberworm\CSS\OutputFormat; -use Sabberworm\CSS\Renderable; use Sabberworm\CSS\Position\Position; use Sabberworm\CSS\Position\Positionable; +use Sabberworm\CSS\Renderable; class Comment implements Positionable, Renderable { diff --git a/src/RuleSet/DeclarationBlock.php b/src/RuleSet/DeclarationBlock.php index 6e2e1a60d..2cd2ef997 100644 --- a/src/RuleSet/DeclarationBlock.php +++ b/src/RuleSet/DeclarationBlock.php @@ -190,9 +190,9 @@ public function addRule(Rule $ruleToAdd, ?Rule $sibling = null): void } /** - * @see RuleSet::getRules() - * * @return array, Rule> + * + * @see RuleSet::getRules() */ public function getRules(?string $searchPattern = null): array { @@ -200,9 +200,9 @@ public function getRules(?string $searchPattern = null): array } /** - * @see RuleSet::setRules() - * * @param array $rules + * + * @see RuleSet::setRules() */ public function setRules(array $rules): void { @@ -210,9 +210,9 @@ public function setRules(array $rules): void } /** - * @see RuleSet::getRulesAssoc() - * * @return array + * + * @see RuleSet::getRulesAssoc() */ public function getRulesAssoc(?string $searchPattern = null): array { diff --git a/tests/OutputFormatTest.php b/tests/OutputFormatTest.php index 6167e796b..20c463e14 100644 --- a/tests/OutputFormatTest.php +++ b/tests/OutputFormatTest.php @@ -16,18 +16,18 @@ final class OutputFormatTest extends TestCase { private const TEST_CSS = "\n" - . ".main, .test {\n" - . "\tfont: italic normal bold 16px/1.2 \"Helvetica\", Verdana, sans-serif;\n" - . "\tbackground: white;\n" - . "}\n" - . "\n" - . "@media screen {\n" - . "\t.main {\n" - . "\t\tbackground-size: 100% 100%;\n" - . "\t\tfont-size: 1.3em;\n" - . "\t\tbackground-color: #fff;\n" - . "\t}\n" - . "}\n"; + . ".main, .test {\n" + . "\tfont: italic normal bold 16px/1.2 \"Helvetica\", Verdana, sans-serif;\n" + . "\tbackground: white;\n" + . "}\n" + . "\n" + . "@media screen {\n" + . "\t.main {\n" + . "\t\tbackground-size: 100% 100%;\n" + . "\t\tfont-size: 1.3em;\n" + . "\t\tbackground-color: #fff;\n" + . "\t}\n" + . "}\n"; /** * @var Parser diff --git a/tests/Unit/Value/CalcFunctionTest.php b/tests/Unit/Value/CalcFunctionTest.php index f4907d477..65f23e131 100644 --- a/tests/Unit/Value/CalcFunctionTest.php +++ b/tests/Unit/Value/CalcFunctionTest.php @@ -47,6 +47,7 @@ public function parseSimpleCalc(): void self::assertSame(20.0, $components[2]->getSize()); self::assertSame('px', $components[2]->getUnit()); } + /** * @test */ From 18a6467625be4e4ef2de44ce7cd447d47b14fc50 Mon Sep 17 00:00:00 2001 From: Oliver Klee Date: Mon, 15 Dec 2025 18:05:53 +0100 Subject: [PATCH 38/43] [CLEANUP] Standardize escaped linefeeds and tabs in literals (#1432) Trailing linefeeds should always be part of the previous line, and leading tabs should be part of the following line. --- tests/OutputFormatTest.php | 7 +-- tests/ParserTest.php | 122 +++++++++++++------------------------ 2 files changed, 44 insertions(+), 85 deletions(-) diff --git a/tests/OutputFormatTest.php b/tests/OutputFormatTest.php index 20c463e14..dce67db92 100644 --- a/tests/OutputFormatTest.php +++ b/tests/OutputFormatTest.php @@ -19,8 +19,7 @@ final class OutputFormatTest extends TestCase . ".main, .test {\n" . "\tfont: italic normal bold 16px/1.2 \"Helvetica\", Verdana, sans-serif;\n" . "\tbackground: white;\n" - . "}\n" - . "\n" + . "}\n\n" . "@media screen {\n" . "\t.main {\n" . "\t\tbackground-size: 100% 100%;\n" @@ -84,8 +83,8 @@ public function spaceAfterListArgumentSeparator(): void { self::assertSame( '.main, .test {font: italic normal bold 16px/ 1.2 ' - . '"Helvetica", Verdana, sans-serif;background: white;}' - . "\n@media screen {.main {background-size: 100% 100%;font-size: 1.3em;background-color: #fff;}}", + . "\"Helvetica\", Verdana, sans-serif;background: white;}\n" + . '@media screen {.main {background-size: 100% 100%;font-size: 1.3em;background-color: #fff;}}', $this->document->render(OutputFormat::create()->setSpaceAfterListArgumentSeparator(' ')) ); } diff --git a/tests/ParserTest.php b/tests/ParserTest.php index ffe278185..97e0d09b5 100644 --- a/tests/ParserTest.php +++ b/tests/ParserTest.php @@ -156,14 +156,11 @@ public function colorParsing(): void } self::assertSame( '#mine {color: red;border-color: #0a64e6;border-color: rgba(10,100,231,.3);outline-color: #222;' - . 'background-color: #232323;}' - . "\n" - . '#yours {background-color: hsl(220,10%,220%);background-color: hsla(220,10%,220%,.3);}' - . "\n" + . "background-color: #232323;}\n" + . "#yours {background-color: hsl(220,10%,220%);background-color: hsla(220,10%,220%,.3);}\n" . '#variables {background-color: rgb(var(--some-rgb));background-color: rgb(var(--r),var(--g),var(--b));' . 'background-color: rgb(255,var(--g),var(--b));background-color: rgb(255,255,var(--b));' - . 'background-color: rgb(255,var(--rg));background-color: hsl(var(--some-hsl));}' - . "\n" + . "background-color: rgb(255,var(--rg));background-color: hsl(var(--some-hsl));}\n" . '#variables-alpha {background-color: rgba(var(--some-rgb),.1);' . 'background-color: rgba(var(--some-rg),255,.1);background-color: hsla(var(--some-hsl),.1);}', $document->render() @@ -265,35 +262,21 @@ public function manipulation(): void { $document = self::parsedStructureForFile('atrules'); self::assertSame( - '@charset "utf-8";' - . "\n" - . '@font-face {font-family: "CrassRoots";src: url("../media/cr.ttf");}' - . "\n" - . 'html, body {font-size: -.6em;}' - . "\n" - . '@keyframes mymove {from {top: 0px;}' - . "\n\t" - . 'to {top: 200px;}}' - . "\n" - . '@-moz-keyframes some-move {from {top: 0px;}' - . "\n\t" - . 'to {top: 200px;}}' - . "\n" + "@charset \"utf-8\";\n" + . "@font-face {font-family: \"CrassRoots\";src: url(\"../media/cr.ttf\");}\n" + . "html, body {font-size: -.6em;}\n" + . "@keyframes mymove {from {top: 0px;}\n" + . "\tto {top: 200px;}}\n" + . "@-moz-keyframes some-move {from {top: 0px;}\n" + . "\tto {top: 200px;}}\n" . '@supports ( (perspective: 10px) or (-moz-perspective: 10px) or (-webkit-perspective: 10px) or ' - . '(-ms-perspective: 10px) or (-o-perspective: 10px) ) {body {font-family: "Helvetica";}}' - . "\n" - . '@page :pseudo-class {margin: 2in;}' - . "\n" - . '@-moz-document url(https://www.w3.org/),' - . "\n" - . ' url-prefix(https://www.w3.org/Style/),' - . "\n" - . ' domain(mozilla.org),' - . "\n" - . ' regexp("https:.*") {body {color: purple;background: yellow;}}' - . "\n" - . '@media screen and (orientation: landscape) {@-ms-viewport {width: 1024px;height: 768px;}}' - . "\n" + . "(-ms-perspective: 10px) or (-o-perspective: 10px) ) {body {font-family: \"Helvetica\";}}\n" + . "@page :pseudo-class {margin: 2in;}\n" + . "@-moz-document url(https://www.w3.org/),\n" + . " url-prefix(https://www.w3.org/Style/),\n" + . " domain(mozilla.org),\n" + . " regexp(\"https:.*\") {body {color: purple;background: yellow;}}\n" + . "@media screen and (orientation: landscape) {@-ms-viewport {width: 1024px;height: 768px;}}\n" . '@region-style #intro {p {color: blue;}}', $document->render() ); @@ -304,35 +287,21 @@ public function manipulation(): void } } self::assertSame( - '@charset "utf-8";' - . "\n" - . '@font-face {font-family: "CrassRoots";src: url("../media/cr.ttf");}' - . "\n" - . '#my_id html, #my_id body {font-size: -.6em;}' - . "\n" - . '@keyframes mymove {from {top: 0px;}' - . "\n\t" - . 'to {top: 200px;}}' - . "\n" - . '@-moz-keyframes some-move {from {top: 0px;}' - . "\n\t" - . 'to {top: 200px;}}' - . "\n" + "@charset \"utf-8\";\n" + . "@font-face {font-family: \"CrassRoots\";src: url(\"../media/cr.ttf\");}\n" + . "#my_id html, #my_id body {font-size: -.6em;}\n" + . "@keyframes mymove {from {top: 0px;}\n" + . "\tto {top: 200px;}}\n" + . "@-moz-keyframes some-move {from {top: 0px;}\n" + . "\tto {top: 200px;}}\n" . '@supports ( (perspective: 10px) or (-moz-perspective: 10px) or (-webkit-perspective: 10px) ' - . 'or (-ms-perspective: 10px) or (-o-perspective: 10px) ) {#my_id body {font-family: "Helvetica";}}' - . "\n" - . '@page :pseudo-class {margin: 2in;}' - . "\n" - . '@-moz-document url(https://www.w3.org/),' - . "\n" - . ' url-prefix(https://www.w3.org/Style/),' - . "\n" - . ' domain(mozilla.org),' - . "\n" - . ' regexp("https:.*") {#my_id body {color: purple;background: yellow;}}' - . "\n" - . '@media screen and (orientation: landscape) {@-ms-viewport {width: 1024px;height: 768px;}}' - . "\n" + . "or (-ms-perspective: 10px) or (-o-perspective: 10px) ) {#my_id body {font-family: \"Helvetica\";}}\n" + . "@page :pseudo-class {margin: 2in;}\n" + . "@-moz-document url(https://www.w3.org/),\n" + . " url-prefix(https://www.w3.org/Style/),\n" + . " domain(mozilla.org),\n" + . " regexp(\"https:.*\") {#my_id body {color: purple;background: yellow;}}\n" + . "@media screen and (orientation: landscape) {@-ms-viewport {width: 1024px;height: 768px;}}\n" . '@region-style #intro {#my_id p {color: blue;}}', $document->render(OutputFormat::create()->setRenderComments(false)) ); @@ -438,18 +407,14 @@ public function slashedValues(): void public function functionSyntax(): void { $document = self::parsedStructureForFile('functions'); - $expected = 'div.main {background-image: linear-gradient(#000,#fff);}' - . "\n" + $expected = "div.main {background-image: linear-gradient(#000,#fff);}\n" . '.collapser::before, .collapser::-moz-before, .collapser::-webkit-before {content: "»";font-size: 1.2em;' . 'margin-right: .2em;-moz-transition-property: -moz-transform;-moz-transition-duration: .2s;' - . '-moz-transform-origin: center 60%;}' - . "\n" + . "-moz-transform-origin: center 60%;}\n" . '.collapser.expanded::before, .collapser.expanded::-moz-before,' - . ' .collapser.expanded::-webkit-before {-moz-transform: rotate(90deg);}' - . "\n" + . " .collapser.expanded::-webkit-before {-moz-transform: rotate(90deg);}\n" . '.collapser + * {height: 0;overflow: hidden;-moz-transition-property: height;' - . '-moz-transition-duration: .3s;}' - . "\n" + . "-moz-transition-duration: .3s;}\n" . '.collapser.expanded + * {height: auto;}'; self::assertSame($expected, $document->render()); @@ -761,10 +726,8 @@ public function identifierEscapesInFile(): void public function selectorIgnoresInFile(): void { $document = self::parsedStructureForFile('selector-ignores', Settings::create()->withMultibyteSupport(true)); - $expected = '.some[selectors-may=\'contain-a-{\'] {}' - . "\n" - . '.this-selector .valid {width: 100px;}' - . "\n" + $expected = ".some[selectors-may='contain-a-{'] {}\n" + . ".this-selector .valid {width: 100px;}\n" . '@media only screen and (min-width: 200px) {.test {prop: val;}}'; self::assertSame($expected, $document->render()); } @@ -778,11 +741,9 @@ public function keyframeSelectors(): void 'keyframe-selector-validation', Settings::create()->withMultibyteSupport(true) ); - $expected = '@-webkit-keyframes zoom {0% {-webkit-transform: scale(1,1);}' - . "\n\t" - . '50% {-webkit-transform: scale(1.2,1.2);}' - . "\n\t" - . '100% {-webkit-transform: scale(1,1);}}'; + $expected = "@-webkit-keyframes zoom {0% {-webkit-transform: scale(1,1);}\n" + . "\t50% {-webkit-transform: scale(1.2,1.2);}\n" + . "\t100% {-webkit-transform: scale(1,1);}}"; self::assertSame($expected, $document->render()); } @@ -812,8 +773,7 @@ public function calcFailure(): void public function urlInFileMbOff(): void { $document = self::parsedStructureForFile('url', Settings::create()->withMultibyteSupport(false)); - $expected = 'body {background: #fff url("https://somesite.com/images/someimage.gif") repeat top center;}' - . "\n" + $expected = "body {background: #fff url(\"https://somesite.com/images/someimage.gif\") repeat top center;}\n" . 'body {background-url: url("https://somesite.com/images/someimage.gif");}'; self::assertSame($expected, $document->render()); } From 515317db03ee09c69809b7b014422312df218650 Mon Sep 17 00:00:00 2001 From: JakeQZ Date: Tue, 16 Dec 2025 08:43:50 +0000 Subject: [PATCH 39/43] [BUGFIX] Reject selector comprising only whitespace (#1433) --- CHANGELOG.md | 1 + src/RuleSet/DeclarationBlock.php | 7 ++- tests/Unit/RuleSet/DeclarationBlockTest.php | 58 ++++++++++++++++++--- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 421d9419f..bcc114faf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Please also have a look at our ### Fixed +- Reject selector comprising only whitespace (#1433) - Improve recovery parsing when a rogue `}` is encountered (#1425, #1426) - Parse comment(s) immediately preceding a selector (#1421) - Parse consecutive comments (#1421) diff --git a/src/RuleSet/DeclarationBlock.php b/src/RuleSet/DeclarationBlock.php index 2cd2ef997..7d669ce8e 100644 --- a/src/RuleSet/DeclarationBlock.php +++ b/src/RuleSet/DeclarationBlock.php @@ -354,6 +354,11 @@ private static function parseSelector(ParserState $parserState, array &$comments throw new UnexpectedTokenException(')', $nextCharacter); } - return \implode('', $selectorParts); + $selector = \trim(\implode('', $selectorParts)); + if ($selector === '') { + throw new UnexpectedTokenException('selector', $nextCharacter, 'literal', $parserState->currentLine()); + } + + return $selector; } } diff --git a/tests/Unit/RuleSet/DeclarationBlockTest.php b/tests/Unit/RuleSet/DeclarationBlockTest.php index 6b1b2865f..97dcb2212 100644 --- a/tests/Unit/RuleSet/DeclarationBlockTest.php +++ b/tests/Unit/RuleSet/DeclarationBlockTest.php @@ -159,20 +159,43 @@ public function parsesTwoCommaSeparatedSelectors(string $firstSelector, string $ } /** - * @return array + * @return array */ - public static function provideInvalidSelector(): array + public static function provideInvalidSelectorAndExpectedExceptionMessage(): array { // TODO: the `parse` method consumes the first character without inspection, - // so the 'lone' test strings are prefixed with a space. + // so some of the test strings are prefixed with a space. return [ - 'lone `(`' => [' ('], - 'lone `)`' => [' )'], - 'unclosed `(`' => [':not(#your-mug'], - 'extra `)`' => [':not(#your-mug))'], + 'no selector' => [' ', 'Token “selector” (literal) not found. Got “{”. [line no: 1]'], + 'lone `(`' => [' (', 'Token “)” (literal) not found. Got “{”.'], + 'lone `)`' => [' )', 'Token “anything but” (literal) not found. Got “)”.'], + 'lone `,`' => [' ,', 'Token “selector” (literal) not found. Got “,”. [line no: 1]'], + 'unclosed `(`' => [':not(#your-mug', 'Token “)” (literal) not found. Got “{”.'], + 'extra `)`' => [':not(#your-mug))', 'Token “anything but” (literal) not found. Got “)”.'], + '`,` missing left operand' => [' , a', 'Token “selector” (literal) not found. Got “,”. [line no: 1]'], + '`,` missing right operand' => ['a,', 'Token “selector” (literal) not found. Got “{”. [line no: 1]'], ]; } + /** + * @return array + */ + public static function provideInvalidSelector(): array + { + // Re-use the set of invalid selectors, but remove the expected exception message for tests that don't need it. + return \array_map( + /** + * @param array{0: string, 1: non-empty-string} + * + * @return array<{0: string}> + */ + static function (array $testData): array { + return [$testData[0]]; + }, + self::provideInvalidSelectorAndExpectedExceptionMessage() + ); + } + /** * @test * @@ -192,6 +215,25 @@ public function parseSkipsBlockWithInvalidSelector(string $selector): void self::assertTrue($parserState->comes($nextCss)); } + /** + * @test + * + * @param non-empty-string $expectedExceptionMessage + * + * @dataProvider provideInvalidSelectorAndExpectedExceptionMessage + */ + public function parseInStrictModeThrowsExceptionWithInvalidSelector( + string $selector, + string $expectedExceptionMessage + ): void { + $this->expectException(UnexpectedTokenException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + + $parserState = new ParserState($selector . ' {}', Settings::create()->beStrict()); + + $subject = DeclarationBlock::parse($parserState); + } + /** * @return array */ @@ -436,7 +478,7 @@ public static function provideInvalidStandaloneSelector(): array public function setSelectorsThrowsExceptionWithInvalidSelector(string $selector): void { $this->expectException(UnexpectedTokenException::class); - $this->expectExceptionMessageMatches('/^Selector\\(s\\) string is not valid. /'); + $this->expectExceptionMessageMatches('/^Selector\\(s\\) string is not valid./'); $subject = new DeclarationBlock(); From ca51e51df5ccfe866b074db43dc0132dea147e22 Mon Sep 17 00:00:00 2001 From: JakeQZ Date: Tue, 16 Dec 2025 10:44:51 +0000 Subject: [PATCH 40/43] [BUGFIX] Parse comment(s) immediately preceding selector (part 2) (#1424) Now comments with no whitespace before the selector are parsed and extracted. `setSelectors()` also receives the same fix, for the unlikely case when someone calls it with a selector string beginning with a comment. --- CHANGELOG.md | 2 +- src/RuleSet/DeclarationBlock.php | 2 +- tests/Unit/RuleSet/DeclarationBlockTest.php | 94 +++++++++++++++++++-- 3 files changed, 87 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcc114faf..f60db312b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ Please also have a look at our - Reject selector comprising only whitespace (#1433) - Improve recovery parsing when a rogue `}` is encountered (#1425, #1426) -- Parse comment(s) immediately preceding a selector (#1421) +- Parse comment(s) immediately preceding a selector (#1421, #1424) - Parse consecutive comments (#1421) - Support attribute selectors with values containing commas in `DeclarationBlock::setSelectors()` (#1419) diff --git a/src/RuleSet/DeclarationBlock.php b/src/RuleSet/DeclarationBlock.php index 7d669ce8e..c8cdb6d48 100644 --- a/src/RuleSet/DeclarationBlock.php +++ b/src/RuleSet/DeclarationBlock.php @@ -307,7 +307,6 @@ private static function parseSelector(ParserState $parserState, array &$comments static $stopCharacters = ['{', '}', '\'', '"', '(', ')', ',']; while (true) { - $selectorParts[] = $parserState->consume(1); $selectorParts[] = $parserState->consumeUntil($stopCharacters, false, false, $comments); $nextCharacter = $parserState->peek(); switch ($nextCharacter) { @@ -348,6 +347,7 @@ private static function parseSelector(ParserState $parserState, array &$comments } break; } + $selectorParts[] = $parserState->consume(1); } if ($functionNestingLevel !== 0) { diff --git a/tests/Unit/RuleSet/DeclarationBlockTest.php b/tests/Unit/RuleSet/DeclarationBlockTest.php index 97dcb2212..9eaab9563 100644 --- a/tests/Unit/RuleSet/DeclarationBlockTest.php +++ b/tests/Unit/RuleSet/DeclarationBlockTest.php @@ -5,6 +5,7 @@ namespace Sabberworm\CSS\Tests\Unit\RuleSet; use PHPUnit\Framework\TestCase; +use Sabberworm\CSS\Comment\Comment; use Sabberworm\CSS\CSSElement; use Sabberworm\CSS\CSSList\CSSListItem; use Sabberworm\CSS\Parsing\ParserState; @@ -158,21 +159,85 @@ public function parsesTwoCommaSeparatedSelectors(string $firstSelector, string $ self::assertSame([$firstSelector, $secondSelector], self::getSelectorsAsStrings($subject)); } + /** + * @return array + */ + public static function provideSelectorWithAndWithoutComment(): array + { + return [ + 'comment before' => ['/*comment*/body', 'body'], + 'comment after' => ['body/*comment*/', 'body'], + 'comment within' => ['./*comment*/teapot', '.teapot'], + 'comment within function' => [':not(#your-mug,/*comment*/.their-mug)', ':not(#your-mug,.their-mug)'], + ]; + } + + /** + * @test + * + * @param non-empty-string $selectorWith + * @param non-empty-string $selectorWithout + * + * @dataProvider provideSelectorWithAndWithoutComment + */ + public function parsesSelectorWithComment(string $selectorWith, string $selectorWithout): void + { + $subject = DeclarationBlock::parse(new ParserState($selectorWith . ' {}', Settings::create())); + + self::assertInstanceOf(DeclarationBlock::class, $subject); + self::assertSame([$selectorWithout], self::getSelectorsAsStrings($subject)); + } + + /** + * @test + * + * @param non-empty-string $selector + * + * @dataProvider provideSelectorWithAndWithoutComment + */ + public function parseExtractsCommentFromSelector(string $selector): void + { + $subject = DeclarationBlock::parse(new ParserState($selector . ' {}', Settings::create())); + + self::assertInstanceOf(DeclarationBlock::class, $subject); + self::assertSame(['comment'], self::getCommentsAsStrings($subject)); + } + + /** + * @test + */ + public function parsesSelectorWithTwoComments(): void + { + $subject = DeclarationBlock::parse(new ParserState('/*comment1*/a/*comment2*/ {}', Settings::create())); + + self::assertInstanceOf(DeclarationBlock::class, $subject); + self::assertSame(['a'], self::getSelectorsAsStrings($subject)); + } + + /** + * @test + */ + public function parseExtractsTwoCommentsFromSelector(): void + { + $subject = DeclarationBlock::parse(new ParserState('/*comment1*/a/*comment2*/ {}', Settings::create())); + + self::assertInstanceOf(DeclarationBlock::class, $subject); + self::assertSame(['comment1', 'comment2'], self::getCommentsAsStrings($subject)); + } + /** * @return array */ public static function provideInvalidSelectorAndExpectedExceptionMessage(): array { - // TODO: the `parse` method consumes the first character without inspection, - // so some of the test strings are prefixed with a space. return [ - 'no selector' => [' ', 'Token “selector” (literal) not found. Got “{”. [line no: 1]'], - 'lone `(`' => [' (', 'Token “)” (literal) not found. Got “{”.'], - 'lone `)`' => [' )', 'Token “anything but” (literal) not found. Got “)”.'], - 'lone `,`' => [' ,', 'Token “selector” (literal) not found. Got “,”. [line no: 1]'], + 'no selector' => ['', 'Token “selector” (literal) not found. Got “{”. [line no: 1]'], + 'lone `(`' => ['(', 'Token “)” (literal) not found. Got “{”.'], + 'lone `)`' => [')', 'Token “anything but” (literal) not found. Got “)”.'], + 'lone `,`' => [',', 'Token “selector” (literal) not found. Got “,”. [line no: 1]'], 'unclosed `(`' => [':not(#your-mug', 'Token “)” (literal) not found. Got “{”.'], 'extra `)`' => [':not(#your-mug))', 'Token “anything but” (literal) not found. Got “)”.'], - '`,` missing left operand' => [' , a', 'Token “selector” (literal) not found. Got “,”. [line no: 1]'], + '`,` missing left operand' => [', a', 'Token “selector” (literal) not found. Got “,”. [line no: 1]'], '`,` missing right operand' => ['a,', 'Token “selector” (literal) not found. Got “{”. [line no: 1]'], ]; } @@ -199,8 +264,6 @@ static function (array $testData): array { /** * @test * - * @param non-empty-string $selector - * * @dataProvider provideInvalidSelector */ public function parseSkipsBlockWithInvalidSelector(string $selector): void @@ -326,6 +389,19 @@ static function (Selector $selectorObject): string { ); } + /** + * @return list + */ + private static function getCommentsAsStrings(DeclarationBlock $declarationBlock): array + { + return \array_map( + static function (Comment $comment): string { + return $comment->getComment(); + }, + $declarationBlock->getComments() + ); + } + /** * @test */ From fea9a1d43cdea2ba803c539bcb550e8040cdbdb3 Mon Sep 17 00:00:00 2001 From: JakeQZ Date: Wed, 17 Dec 2025 07:22:25 +0000 Subject: [PATCH 41/43] [CLEANUP] Reorder methods in `DeclarationBlockTest` (#1434) Move the private helper methods to the end, as they are used in multiple tests both above and below their current location. --- tests/Unit/RuleSet/DeclarationBlockTest.php | 52 ++++++++++----------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/tests/Unit/RuleSet/DeclarationBlockTest.php b/tests/Unit/RuleSet/DeclarationBlockTest.php index 9eaab9563..7800ba73e 100644 --- a/tests/Unit/RuleSet/DeclarationBlockTest.php +++ b/tests/Unit/RuleSet/DeclarationBlockTest.php @@ -376,32 +376,6 @@ public function parseConsumesToEofIfNoClosingBraceAfterInvalidSelector( self::assertTrue($parserState->isEnd()); } - /** - * @return array - */ - private static function getSelectorsAsStrings(DeclarationBlock $declarationBlock): array - { - return \array_map( - static function (Selector $selectorObject): string { - return $selectorObject->getSelector(); - }, - $declarationBlock->getSelectors() - ); - } - - /** - * @return list - */ - private static function getCommentsAsStrings(DeclarationBlock $declarationBlock): array - { - return \array_map( - static function (Comment $comment): string { - return $comment->getComment(); - }, - $declarationBlock->getComments() - ); - } - /** * @test */ @@ -560,4 +534,30 @@ public function setSelectorsThrowsExceptionWithInvalidSelector(string $selector) $subject->setSelectors($selector); } + + /** + * @return array + */ + private static function getSelectorsAsStrings(DeclarationBlock $declarationBlock): array + { + return \array_map( + static function (Selector $selectorObject): string { + return $selectorObject->getSelector(); + }, + $declarationBlock->getSelectors() + ); + } + + /** + * @return list + */ + private static function getCommentsAsStrings(DeclarationBlock $declarationBlock): array + { + return \array_map( + static function (Comment $comment): string { + return $comment->getComment(); + }, + $declarationBlock->getComments() + ); + } } From 3500d422bb98f4c16de87664eb116dec406d2421 Mon Sep 17 00:00:00 2001 From: JakeQZ Date: Wed, 17 Dec 2025 07:23:47 +0000 Subject: [PATCH 42/43] [FEATURE] Provide line number in exception message (#1435) ... for mismatched parentheses in selector. --- CHANGELOG.md | 2 ++ src/RuleSet/DeclarationBlock.php | 9 +++++++-- tests/Unit/RuleSet/DeclarationBlockTest.php | 8 ++++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f60db312b..d75a0e766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Please also have a look at our ### Added +- Provide line number in exception message for mismatched parentheses in + selector (#1435) - Add support for CSS container queries (#1400) ### Changed diff --git a/src/RuleSet/DeclarationBlock.php b/src/RuleSet/DeclarationBlock.php index c8cdb6d48..a3921815f 100644 --- a/src/RuleSet/DeclarationBlock.php +++ b/src/RuleSet/DeclarationBlock.php @@ -329,7 +329,12 @@ private static function parseSelector(ParserState $parserState, array &$comments case ')': if (!\is_string($stringWrapperCharacter)) { if ($functionNestingLevel <= 0) { - throw new UnexpectedTokenException('anything but', ')'); + throw new UnexpectedTokenException( + 'anything but', + ')', + 'literal', + $parserState->currentLine() + ); } --$functionNestingLevel; } @@ -351,7 +356,7 @@ private static function parseSelector(ParserState $parserState, array &$comments } if ($functionNestingLevel !== 0) { - throw new UnexpectedTokenException(')', $nextCharacter); + throw new UnexpectedTokenException(')', $nextCharacter, 'literal', $parserState->currentLine()); } $selector = \trim(\implode('', $selectorParts)); diff --git a/tests/Unit/RuleSet/DeclarationBlockTest.php b/tests/Unit/RuleSet/DeclarationBlockTest.php index 7800ba73e..d28e1195b 100644 --- a/tests/Unit/RuleSet/DeclarationBlockTest.php +++ b/tests/Unit/RuleSet/DeclarationBlockTest.php @@ -232,11 +232,11 @@ public static function provideInvalidSelectorAndExpectedExceptionMessage(): arra { return [ 'no selector' => ['', 'Token “selector” (literal) not found. Got “{”. [line no: 1]'], - 'lone `(`' => ['(', 'Token “)” (literal) not found. Got “{”.'], - 'lone `)`' => [')', 'Token “anything but” (literal) not found. Got “)”.'], + 'lone `(`' => ['(', 'Token “)” (literal) not found. Got “{”. [line no: 1]'], + 'lone `)`' => [')', 'Token “anything but” (literal) not found. Got “)”. [line no: 1]'], 'lone `,`' => [',', 'Token “selector” (literal) not found. Got “,”. [line no: 1]'], - 'unclosed `(`' => [':not(#your-mug', 'Token “)” (literal) not found. Got “{”.'], - 'extra `)`' => [':not(#your-mug))', 'Token “anything but” (literal) not found. Got “)”.'], + 'unclosed `(`' => [':not(#your-mug', 'Token “)” (literal) not found. Got “{”. [line no: 1]'], + 'extra `)`' => [':not(#your-mug))', 'Token “anything but” (literal) not found. Got “)”. [line no: 1]'], '`,` missing left operand' => [', a', 'Token “selector” (literal) not found. Got “,”. [line no: 1]'], '`,` missing right operand' => ['a,', 'Token “selector” (literal) not found. Got “{”. [line no: 1]'], ]; From 0ae1fde22bc2f66363ebdd77095e6f1d35118bc8 Mon Sep 17 00:00:00 2001 From: JakeQZ Date: Thu, 18 Dec 2025 08:34:14 +0000 Subject: [PATCH 43/43] [CLEANUP] Tighten a type annotation in `DeclarationBlockTest` (#1436) --- tests/Unit/RuleSet/DeclarationBlockTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/RuleSet/DeclarationBlockTest.php b/tests/Unit/RuleSet/DeclarationBlockTest.php index d28e1195b..980ea4004 100644 --- a/tests/Unit/RuleSet/DeclarationBlockTest.php +++ b/tests/Unit/RuleSet/DeclarationBlockTest.php @@ -536,7 +536,7 @@ public function setSelectorsThrowsExceptionWithInvalidSelector(string $selector) } /** - * @return array + * @return list */ private static function getSelectorsAsStrings(DeclarationBlock $declarationBlock): array {