diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 76a72c265..aaceaa779 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,7 +8,7 @@ updates: interval: "daily" commit-message: prefix: "[Dependabot] " - milestone: 10 + milestone: 12 - package-ecosystem: "composer" directory: "/" @@ -26,4 +26,4 @@ updates: versioning-strategy: "increase" commit-message: prefix: "[Dependabot] " - milestone: 10 + milestone: 12 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 251e8fc4d..72f25f4aa 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 @@ -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') }} @@ -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 @@ -63,7 +63,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install PHP uses: shivammathur/setup-php@v2 @@ -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') }} @@ -105,6 +105,7 @@ jobs: command: - composer:normalize - php:fixer + - php:codesniffer - php:stan - php:rector php-version: @@ -112,7 +113,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install PHP uses: shivammathur/setup-php@v2 @@ -126,7 +127,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') }} @@ -145,4 +146,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..a23d17eef 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 @@ -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') }} @@ -52,13 +52,15 @@ jobs: composer show; - name: Run Tests - run: composer ci:tests:coverage + run: composer check:tests:coverage - name: Show generated coverage files run: ls -lah - name: Upload coverage results to Coveralls uses: coverallsapp/github-action@v2 + with: + fail-on-error: false env: github-token: ${{ secrets.GITHUB_TOKEN }} file: coverage.xml 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/CHANGELOG.md b/CHANGELOG.md index 8978fbb71..00dc246d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,10 +18,73 @@ Please also have a look at our ### Fixed -- Use typesafe versions of PHP functions (#1379, #1380, #1382, #1383, #1384) +### Documentation + +## 9.3.0: Support for modern CSS at-rules and autoloading bugfix + +### Added + +- Add support for modern CSS at-rules: `@layer`, `@scope`, and `@starting-style` (#1549) + +### Fixed + +- Avoid double autoloading of class aliases (#1552) ### Documentation +## 9.2.0: New features and deprecations + +### Added + +- Add `OutputFormat::setSpaceAroundSelectorCombinator()` (#1504) +- Add support for escaped quotes in the selectors (#1485, #1489) +- Provide line number in exception message for mismatched parentheses in + selector (#1435) +- Add support for CSS container queries (#1400) + +### Changed + +- `RuleSet\RuleContainer` is renamed to `RuleSet\DeclarationList` (#1530, #1539) +- Methods like `setRule()` in `RuleSet` and `DeclarationBlock` have been renamed + to `setDeclaration()`, etc. (#1521) +- `Rule\Rule` class is renamed to `Property\Declaration` + (#1508, #1512, #1513, #1522) +- `Rule::setRule()` and `getRule()` are replaced with `setPropertyName()` and + `getPropertyName()` (#1506) +- `Selector` is now represented as a sequence of `Selector\Component` objects + which can be accessed via `getComponents()`, manipulated individually, or set + via `setComponents()` (#1478, #1486, #1487, #1488, #1494, #1496, #1536, #1537) +- `Selector::setSelector()` and `Selector` constructor will now throw exception + upon provision of an invalid selectior (#1498, #1502) +- Clean up extra whitespace in CSS selector (#1398) +- The array keys passed to `DeclarationBlock::setSelectors()` are no longer + preserved (#1407) + +### Deprecated + +- `RuleSet\RuleContainer` is deprecated; use `RuleSet\DeclarationList` instead + (#1530) +- Methods like `setRule()` in `RuleSet` and `DeclarationBlock` are deprecated; + there are direct replacements such as `setDeclaration()` (#1521) +- `Rule\Rule` class is deprecated; `Property\Declaration` is a direct + replacement (#1508) +- `Rule::setRule()` and `getRule()` are deprecated and replaced with + `setPropertyName()` and `getPropertyName()` (#1506, #1519) + +### Fixed + +- Do not escape characters that do not need escaping in CSS string (#1444) +- Reject selector comprising only whitespace (#1433) +- Improve recovery parsing when a rogue `}` is encountered (#1425, #1426) +- Parse comment(s) immediately preceding a selector (#1421, #1424) +- Parse consecutive comments (#1421) +- 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) +- Use typesafe versions of PHP functions (#1379, #1380, #1382, #1383, #1384) + ## 9.1.0: Add support for PHP 8.5 ### Added 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..44733d37c 100644 --- a/composer.json +++ b/composer.json @@ -25,18 +25,19 @@ "require": { "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", "ext-iconv": "*", - "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.3" + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" }, "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", - "phpunit/phpunit": "8.5.48", + "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.52", "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" }, "suggest": { @@ -45,7 +46,11 @@ "autoload": { "psr-4": { "Sabberworm\\CSS\\": "src/" - } + }, + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ] }, "autoload-dev": { "psr-4": { @@ -63,35 +68,37 @@ }, "extra": { "branch-alias": { - "dev-main": "9.2.x-dev" + "dev-main": "9.4.x-dev" } }, "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:codesniffer": "phpcs --standard=config/phpcs.xml bin 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 config src tests", + "check:php:lint": "parallel-lint bin config src tests", + "check:php:rector": "rector process --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:codesniffer", + "@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" ], @@ -99,30 +106,36 @@ "fix:php": [ "@fix:composer:normalize", "@fix:php:rector", + "@fix:php:codesniffer", "@fix:php:fixer" ], - "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" + "fix:php:codesniffer": "phpcbf --standard=config/phpcs.xml bin config src tests", + "fix:php:fixer": "\"./.phive/php-cs-fixer\" --config=config/php-cs-fixer.php fix bin config src tests", + "fix:php:rector": "rector process --config=config/rector.php", + "phpstan:baseline": "phpstan --configuration=config/phpstan.neon --generate-baseline=config/phpstan-baseline.neon --allow-empty-baseline", + "phpstan:clearcache": "phpstan clear-result-cache" }, "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: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.", + "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.", + "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." + "phpstan:baseline": "Updates the PHPStan baseline file to match the code.", + "phpstan:clearcache": "Clears the PHPStan cache." } } diff --git a/config/php-cs-fixer.php b/config/php-cs-fixer.php index c10bf59ae..807b376fd 100644 --- a/config/php-cs-fixer.php +++ b/config/php-cs-fixer.php @@ -2,22 +2,24 @@ declare(strict_types=1); -return (new \PhpCsFixer\Config()) +use PhpCsFixer\Config; + +return (new Config()) ->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']], diff --git a/config/phpcs.xml b/config/phpcs.xml new file mode 100644 index 000000000..f4a05b783 --- /dev/null +++ b/config/phpcs.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/config/phpstan-baseline.neon b/config/phpstan-baseline.neon index 6205096ae..ea84804b7 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 @@ -85,7 +79,7 @@ parameters: path: ../tests/ParserTest.php - - message: '#^Parameter \#1 \$value of method Sabberworm\\CSS\\Rule\\Rule\:\:setValue\(\) expects Sabberworm\\CSS\\Value\\RuleValueList\|string\|null, Sabberworm\\CSS\\Value\\Size given\.$#' + message: '#^Parameter \#1 \$value of method Sabberworm\\CSS\\Property\\Declaration\:\:setValue\(\) expects Sabberworm\\CSS\\Value\\RuleValueList\|string\|null, Sabberworm\\CSS\\Value\\Size given\.$#' identifier: argument.type count: 3 path: ../tests/RuleSet/DeclarationBlockTest.php @@ -97,13 +91,13 @@ parameters: path: ../tests/Unit/CSSList/AtRuleBlockListTest.php - - message: '#^Parameter \#1 \$value of method Sabberworm\\CSS\\Rule\\Rule\:\:setValue\(\) expects Sabberworm\\CSS\\Value\\RuleValueList\|string\|null, Sabberworm\\CSS\\Value\\CSSFunction given\.$#' + message: '#^Parameter \#1 \$value of method Sabberworm\\CSS\\Property\\Declaration\:\:setValue\(\) expects Sabberworm\\CSS\\Value\\RuleValueList\|string\|null, Sabberworm\\CSS\\Value\\CSSFunction given\.$#' identifier: argument.type count: 2 path: ../tests/Unit/CSSList/CSSBlockListTest.php - - message: '#^Parameter \#1 \$value of method Sabberworm\\CSS\\Rule\\Rule\:\:setValue\(\) expects Sabberworm\\CSS\\Value\\RuleValueList\|string\|null, Sabberworm\\CSS\\Value\\CSSString given\.$#' + message: '#^Parameter \#1 \$value of method Sabberworm\\CSS\\Property\\Declaration\:\:setValue\(\) expects Sabberworm\\CSS\\Value\\RuleValueList\|string\|null, Sabberworm\\CSS\\Value\\CSSString given\.$#' identifier: argument.type count: 10 path: ../tests/Unit/CSSList/CSSBlockListTest.php diff --git a/config/phpstan.neon b/config/phpstan.neon index 55ba682af..ec625a44a 100644 --- a/config/phpstan.neon +++ b/config/phpstan.neon @@ -2,10 +2,6 @@ includes: - phpstan-baseline.neon parameters: - parallel: - # Don't be overly greedy on machines with more CPU's to be a good neighbor especially on CI - maximumNumberOfProcesses: 5 - phpVersion: 70200 level: 6 diff --git a/config/rector.php b/config/rector.php index 61df84182..cf501b19c 100644 --- a/config/rector.php +++ b/config/rector.php @@ -10,6 +10,8 @@ return RectorConfig::configure() ->withPaths( [ + __DIR__ . '/../bin', + __DIR__ . '/../config', __DIR__ . '/../src', __DIR__ . '/../tests', ] 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 +``` diff --git a/src/CSSList/CSSBlockList.php b/src/CSSList/CSSBlockList.php index 122ac5d1d..b54a61d83 100644 --- a/src/CSSList/CSSBlockList.php +++ b/src/CSSList/CSSBlockList.php @@ -5,10 +5,10 @@ namespace Sabberworm\CSS\CSSList; use Sabberworm\CSS\CSSElement; +use Sabberworm\CSS\Property\Declaration; use Sabberworm\CSS\Property\Selector; -use Sabberworm\CSS\Rule\Rule; use Sabberworm\CSS\RuleSet\DeclarationBlock; -use Sabberworm\CSS\RuleSet\RuleContainer; +use Sabberworm\CSS\RuleSet\DeclarationList; use Sabberworm\CSS\RuleSet\RuleSet; use Sabberworm\CSS\Value\CSSFunction; use Sabberworm\CSS\Value\Value; @@ -65,7 +65,7 @@ public function getAllRuleSets(): array } /** - * Returns all `Value` objects found recursively in `Rule`s in the tree. + * Returns all `Value` objects found recursively in `Declaration`s in the tree. * * @param CSSElement|null $element * This is the `CSSList` or `RuleSet` to start the search from (defaults to the whole document). @@ -98,14 +98,14 @@ public function getAllValues( ); } } - } elseif ($element instanceof RuleContainer) { + } elseif ($element instanceof DeclarationList) { foreach ($element->getRules($ruleSearchPattern) as $rule) { $result = \array_merge( $result, $this->getAllValues($rule, $ruleSearchPattern, $searchInFunctionArguments) ); } - } elseif ($element instanceof Rule) { + } elseif ($element instanceof Declaration) { $value = $element->getValue(); // `string` values are discarded. if ($value instanceof CSSElement) { diff --git a/src/CSSList/CSSList.php b/src/CSSList/CSSList.php index e09a03a98..77af0c81b 100644 --- a/src/CSSList/CSSList.php +++ b/src/CSSList/CSSList.php @@ -68,13 +68,19 @@ public static function parseList(ParserState $parserState, CSSList $list): void $usesLenientParsing = $parserState->getSettings()->usesLenientParsing(); $comments = []; while (!$parserState->isEnd()) { - $comments = \array_merge($comments, $parserState->consumeWhiteSpace()); + $parserState->consumeWhiteSpace($comments); $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); @@ -87,7 +93,8 @@ public static function parseList(ParserState $parserState, CSSList $list): void $listItem->addComments($comments); $list->append($listItem); } - $comments = $parserState->consumeWhiteSpace(); + $comments = []; + $parserState->consumeWhiteSpace($comments); } $list->addComments($comments); if (!$isRoot && !$usesLenientParsing) { @@ -133,7 +140,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()); } @@ -214,7 +222,7 @@ private static function parseAtRule(ParserState $parserState): ?CSSListItem } } $useRuleSet = true; - foreach (\explode('/', AtRule::BLOCK_RULES) as $blockRuleName) { + foreach (AtRule::BLOCK_RULES as $blockRuleName) { if (self::identifierIs($identifier, $blockRuleName)) { $useRuleSet = false; break; @@ -370,7 +378,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 +435,44 @@ public function getContents(): array { return $this->contents; } + + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`'); + } + + /** + * @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/src/Comment/Comment.php b/src/Comment/Comment.php index 33188988f..cba49d925 100644 --- a/src/Comment/Comment.php +++ b/src/Comment/Comment.php @@ -5,13 +5,15 @@ 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; +use Sabberworm\CSS\ShortClassNameProvider; class Comment implements Positionable, Renderable { use Position; + use ShortClassNameProvider; /** * @var string @@ -46,4 +48,19 @@ public function render(OutputFormat $outputFormat): string { return '/*' . $this->commentText . '*/'; } + + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + return [ + 'class' => $this->getShortClassName(), + // "contents" is the term used in the W3C specs: + // https://www.w3.org/TR/CSS22/syndata.html#comments + 'contents' => $this->commentText, + ]; + } } diff --git a/src/OutputFormat.php b/src/OutputFormat.php index 5c493865a..4f7ba0b19 100644 --- a/src/OutputFormat.php +++ b/src/OutputFormat.php @@ -7,9 +7,7 @@ final class OutputFormat { /** - * Value format: `"` means double-quote, `'` means single-quote - * - * @var non-empty-string + * @var '"'|"'" */ private $stringQuotingType = '"'; @@ -94,6 +92,11 @@ final class OutputFormat */ private $spaceAfterSelectorSeparator = ' '; + /** + * @var string + */ + private $spaceAroundSelectorCombinator = ' '; + /** * This is what’s inserted before the separator in value lists, by default. * @@ -181,7 +184,7 @@ final class OutputFormat private $indentationLevel = 0; /** - * @return non-empty-string + * @return '"'|"'" * * @internal */ @@ -191,7 +194,7 @@ public function getStringQuotingType(): string } /** - * @param non-empty-string $quotingType + * @param '"'|"'" $quotingType * * @return $this fluent interface */ @@ -436,6 +439,27 @@ public function setSpaceAfterSelectorSeparator(string $whitespace): self return $this; } + /** + * @internal + */ + public function getSpaceAroundSelectorCombinator(): string + { + return $this->spaceAroundSelectorCombinator; + } + + /** + * The spacing set is also used for the descendent combinator, which is whitespace only, + * unless an empty string is set, in which case a space will be used. + * + * @return $this fluent interface + */ + public function setSpaceAroundSelectorCombinator(string $whitespace): self + { + $this->spaceAroundSelectorCombinator = $whitespace; + + return $this; + } + /** * @internal */ @@ -726,6 +750,7 @@ public static function createCompact(): self ->setSpaceAfterRuleName('') ->setSpaceBeforeOpeningBrace('') ->setSpaceAfterSelectorSeparator('') + ->setSpaceAroundSelectorCombinator('') ->setSemicolonAfterLastRule(false) ->setRenderComments(false); diff --git a/src/Parsing/ParserState.php b/src/Parsing/ParserState.php index cc69ed974..3451fe0c4 100644 --- a/src/Parsing/ParserState.php +++ b/src/Parsing/ParserState.php @@ -16,9 +16,6 @@ */ class ParserState { - /** - * @var null - */ public const EOF = null; /** @@ -191,17 +188,25 @@ public function parseCharacter(bool $isForIdentifier): ?string } /** - * @return list + * Consumes whitespace and/or comments until the next non-whitespace character that isn't a slash opening a comment. + * + * @param list $comments Any comments consumed will be appended to this array. + * + * @return string the whitespace consumed, without the comments * * @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 + public function consumeWhiteSpace(array &$comments = []): string { - $comments = []; + $consumed = ''; do { while (preg_match('/\\s/isSu', $this->peek()) === 1) { - $this->consume(1); + $consumed .= $this->consume(1); } if ($this->parserSettings->usesLenientParsing()) { try { @@ -218,7 +223,7 @@ public function consumeWhiteSpace(): array } } while ($comment instanceof Comment); - return $comments; + return $consumed; } /** @@ -282,6 +287,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 @@ -331,7 +357,7 @@ public function isEnd(): bool /** * @param list|string|self::EOF $stopCharacters - * @param array $comments + * @param list $comments * * @throws UnexpectedEOFException * @throws UnexpectedTokenException @@ -346,6 +372,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)) { @@ -357,10 +384,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)) { @@ -458,4 +482,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/src/Property/AtRule.php b/src/Property/AtRule.php index 49a160a1a..808df79f8 100644 --- a/src/Property/AtRule.php +++ b/src/Property/AtRule.php @@ -16,11 +16,19 @@ 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'; + public const BLOCK_RULES = [ + 'media', + 'document', + 'supports', + 'region-style', + 'font-feature-values', + 'container', + 'layer', + 'scope', + 'starting-style', + ]; /** * @return non-empty-string diff --git a/src/Property/CSSNamespace.php b/src/Property/CSSNamespace.php index 66164a318..6046bdf95 100644 --- a/src/Property/CSSNamespace.php +++ b/src/Property/CSSNamespace.php @@ -8,6 +8,7 @@ use Sabberworm\CSS\OutputFormat; use Sabberworm\CSS\Position\Position; use Sabberworm\CSS\Position\Positionable; +use Sabberworm\CSS\ShortClassNameProvider; use Sabberworm\CSS\Value\CSSString; use Sabberworm\CSS\Value\URL; @@ -18,6 +19,7 @@ class CSSNamespace implements AtRule, Positionable { use CommentContainer; use Position; + use ShortClassNameProvider; /** * @var CSSString|URL @@ -94,4 +96,19 @@ public function atRuleArgs(): array } return $result; } + + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + return [ + 'class' => $this->getShortClassName(), + // We're using `uri` here instead of `url` to better match the spec. + 'uri' => $this->url->getArrayRepresentation(), + 'prefix' => $this->prefix, + ]; + } } diff --git a/src/Property/Charset.php b/src/Property/Charset.php index c9488ad1e..107768081 100644 --- a/src/Property/Charset.php +++ b/src/Property/Charset.php @@ -8,6 +8,7 @@ use Sabberworm\CSS\OutputFormat; use Sabberworm\CSS\Position\Position; use Sabberworm\CSS\Position\Positionable; +use Sabberworm\CSS\ShortClassNameProvider; use Sabberworm\CSS\Value\CSSString; /** @@ -22,6 +23,7 @@ class Charset implements AtRule, Positionable { use CommentContainer; use Position; + use ShortClassNameProvider; /** * @var CSSString @@ -71,4 +73,17 @@ public function atRuleArgs(): CSSString { return $this->charset; } + + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + return [ + 'class' => $this->getShortClassName(), + 'charset' => $this->charset->getArrayRepresentation(), + ]; + } } diff --git a/src/Property/Declaration.php b/src/Property/Declaration.php new file mode 100644 index 000000000..079ab48db --- /dev/null +++ b/src/Property/Declaration.php @@ -0,0 +1,231 @@ +|null $lineNumber + * @param int<0, max>|null $columnNumber + */ + public function __construct(string $propertyName, ?int $lineNumber = null, ?int $columnNumber = null) + { + $this->propertyName = $propertyName; + $this->setPosition($lineNumber, $columnNumber); + } + + /** + * @param list $commentsBefore + * + * @throws UnexpectedEOFException + * @throws UnexpectedTokenException + * + * @internal since V8.8.0 + */ + public static function parse(ParserState $parserState, array $commentsBefore = []): self + { + $comments = $commentsBefore; + $parserState->consumeWhiteSpace($comments); + $declaration = new self( + $parserState->parseIdentifier(!$parserState->comes('--')), + $parserState->currentLine(), + $parserState->currentColumn() + ); + $parserState->consumeWhiteSpace($comments); + $declaration->setComments($comments); + $parserState->consume(':'); + $value = Value::parseValue($parserState, self::getDelimitersForPropertyValue($declaration->getPropertyName())); + $declaration->setValue($value); + $parserState->consumeWhiteSpace(); + if ($parserState->comes('!')) { + $parserState->consume('!'); + $parserState->consumeWhiteSpace(); + $parserState->consume('important'); + $declaration->setIsImportant(true); + } + $parserState->consumeWhiteSpace(); + while ($parserState->comes(';')) { + $parserState->consume(';'); + } + + return $declaration; + } + + /** + * Returns a list of delimiters (or separators). + * The first item is the innermost separator (or, put another way, the highest-precedence operator). + * The sequence continues to the outermost separator (or lowest-precedence operator). + * + * @param non-empty-string $propertyName + * + * @return list + */ + private static function getDelimitersForPropertyValue(string $propertyName): array + { + if (preg_match('/^font($|-)/', $propertyName) === 1) { + return [',', '/', ' ']; + } + + switch ($propertyName) { + case 'src': + return [' ', ',']; + default: + return [',', ' ', '/']; + } + } + + /** + * @param non-empty-string $propertyName + */ + public function setPropertyName(string $propertyName): void + { + $this->propertyName = $propertyName; + } + + /** + * @return non-empty-string + */ + public function getPropertyName(): string + { + return $this->propertyName; + } + + /** + * @param non-empty-string $propertyName + * + * @deprecated in v9.2, will be removed in v10.0; use `setPropertyName()` instead. + */ + public function setRule(string $propertyName): void + { + $this->propertyName = $propertyName; + } + + /** + * @return non-empty-string + * + * @deprecated in v9.2, will be removed in v10.0; use `getPropertyName()` instead. + */ + public function getRule(): string + { + return $this->propertyName; + } + + /** + * @return RuleValueList|string|null + */ + public function getValue() + { + return $this->value; + } + + /** + * @param RuleValueList|string|null $value + */ + public function setValue($value): void + { + $this->value = $value; + } + + /** + * Adds a value to the existing value. Value will be appended if a `RuleValueList` exists of the given type. + * Otherwise, the existing value will be wrapped by one. + * + * @param RuleValueList|array $value + */ + public function addValue($value, string $type = ' '): void + { + if (!\is_array($value)) { + $value = [$value]; + } + if (!($this->value instanceof RuleValueList) || $this->value->getListSeparator() !== $type) { + $currentValue = $this->value; + $this->value = new RuleValueList($type, $this->getLineNumber()); + if ($currentValue !== null && $currentValue !== '') { + $this->value->addListComponent($currentValue); + } + } + foreach ($value as $valueItem) { + $this->value->addListComponent($valueItem); + } + } + + public function setIsImportant(bool $isImportant): void + { + $this->isImportant = $isImportant; + } + + public function getIsImportant(): bool + { + return $this->isImportant; + } + + /** + * @return non-empty-string + */ + public function render(OutputFormat $outputFormat): string + { + $formatter = $outputFormat->getFormatter(); + $result = "{$formatter->comments($this)}{$this->propertyName}:{$formatter->spaceAfterRuleName()}"; + if ($this->value instanceof Value) { // Can also be a ValueList + $result .= $this->value->render($outputFormat); + } else { + $result .= $this->value; + } + if ($this->isImportant) { + $result .= ' !important'; + } + $result .= ';'; + return $result; + } + + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`'); + } +} diff --git a/src/Property/Import.php b/src/Property/Import.php index a9dabe979..b9075504f 100644 --- a/src/Property/Import.php +++ b/src/Property/Import.php @@ -8,6 +8,7 @@ use Sabberworm\CSS\OutputFormat; use Sabberworm\CSS\Position\Position; use Sabberworm\CSS\Position\Positionable; +use Sabberworm\CSS\ShortClassNameProvider; use Sabberworm\CSS\Value\URL; /** @@ -17,6 +18,7 @@ class Import implements AtRule, Positionable { use CommentContainer; use Position; + use ShortClassNameProvider; /** * @var URL @@ -82,4 +84,19 @@ public function getMediaQuery(): ?string { return $this->mediaQuery; } + + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + return [ + 'class' => $this->getShortClassName(), + // We're using the term "uri" here to match the wording used in the specs: + // https://www.w3.org/TR/CSS22/cascade.html#at-import + 'uri' => $this->location->getArrayRepresentation(), + ]; + } } 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..66c061ada 100644 --- a/src/Property/Selector.php +++ b/src/Property/Selector.php @@ -4,9 +4,16 @@ namespace Sabberworm\CSS\Property; +use Sabberworm\CSS\Comment\Comment; use Sabberworm\CSS\OutputFormat; -use Sabberworm\CSS\Property\Selector\SpecificityCalculator; +use Sabberworm\CSS\Parsing\ParserState; +use Sabberworm\CSS\Parsing\UnexpectedTokenException; +use Sabberworm\CSS\Property\Selector\Combinator; +use Sabberworm\CSS\Property\Selector\Component; +use Sabberworm\CSS\Property\Selector\CompoundSelector; use Sabberworm\CSS\Renderable; +use Sabberworm\CSS\Settings; +use Sabberworm\CSS\ShortClassNameProvider; use function Safe\preg_match; @@ -16,13 +23,15 @@ */ class Selector implements Renderable { + use ShortClassNameProvider; + /** - * @var non-empty-string - * * @internal since 8.5.2 */ public const SELECTOR_VALIDATION_RX = '/ ^( + # not whitespace only + (?!\\s*+$) (?: # any sequence of valid unescaped characters, except quotes [a-zA-Z0-9\\x{00A0}-\\x{FFFF}_^$|*=~\\[\\]()\\-\\s\\.:#+>,]++ @@ -49,9 +58,9 @@ class Selector implements Renderable /ux'; /** - * @var string + * @var non-empty-list */ - private $selector; + private $components; /** * @internal since V8.8.0 @@ -64,19 +73,137 @@ public static function isValid(string $selector): bool return $numberOfMatches === 1; } - public function __construct(string $selector) + /** + * @param non-empty-string|non-empty-list $selector + * Providing a string is deprecated in version 9.2 and will not work from v10.0 + * + * @throws UnexpectedTokenException if the selector is not valid + */ + final public function __construct($selector) + { + if (\is_string($selector)) { + $this->setSelector($selector); + } else { + $this->setComponents($selector); + } + } + + /** + * @param list $comments + * + * @return non-empty-list + * + * @throws UnexpectedTokenException + */ + private static function parseComponents(ParserState $parserState, array &$comments = []): array + { + // Whitespace is a descendent combinator, not allowed around a compound selector. + // (It is allowed within, e.g. as part of a string or within a function like `:not()`.) + // Gobble any up now to get a clean start. + $parserState->consumeWhiteSpace($comments); + + $selectorParts = []; + while (true) { + try { + $selectorParts[] = CompoundSelector::parse($parserState, $comments); + } catch (UnexpectedTokenException $e) { + if ($selectorParts !== [] && \end($selectorParts)->getValue() === ' ') { + // The whitespace was not a descendent combinator, and was, in fact, arbitrary, + // after the end of the selector. Discard it. + \array_pop($selectorParts); + break; + } else { + throw $e; + } + } + try { + $selectorParts[] = Combinator::parse($parserState, $comments); + } catch (UnexpectedTokenException $e) { + // End of selector has been reached. + break; + } + } + + return $selectorParts; + } + + /** + * @param list $comments + * + * @throws UnexpectedTokenException + * + * @internal + */ + public static function parse(ParserState $parserState, array &$comments = []): self + { + $selectorParts = self::parseComponents($parserState, $comments); + + // Check that the selector has been fully parsed: + if (!\in_array($parserState->peek(), ['{', '}', ',', ''], true)) { + throw new UnexpectedTokenException( + '`,`, `{`, `}` or EOF', + $parserState->peek(5), + 'literal', + $parserState->currentLine() + ); + } + + return new static($selectorParts); + } + + /** + * @return non-empty-list + */ + public function getComponents(): array { - $this->setSelector($selector); + return $this->components; } + /** + * @param non-empty-list $components + * This should be an alternating sequence of `CompoundSelector` and `Combinator`, starting and ending with a + * `CompoundSelector`, and may be a single `CompoundSelector`. + */ + public function setComponents(array $components): self + { + $this->components = $components; + + return $this; + } + + /** + * @return non-empty-string + * + * @deprecated in version 9.2, will be removed in v10.0. Use either `getComponents()` or `render()` instead. + */ public function getSelector(): string { - return $this->selector; + return $this->render(new OutputFormat()); } + /** + * @param non-empty-string $selector + * + * @throws UnexpectedTokenException if the selector is not valid + * + * @deprecated in version 9.2, will be removed in v10.0. Use `setComponents()` instead. + */ public function setSelector(string $selector): void { - $this->selector = \trim($selector); + $parserState = new ParserState($selector, Settings::create()); + + $components = self::parseComponents($parserState); + + // Check that the selector has been fully parsed: + if (!$parserState->isEnd()) { + throw new UnexpectedTokenException( + 'EOF', + $parserState->peek(5), + 'literal' + ); + } + + $this->components = $components; } /** @@ -84,11 +211,39 @@ public function setSelector(string $selector): void */ public function getSpecificity(): int { - return SpecificityCalculator::calculate($this->selector); + return \array_sum(\array_map( + static function (Component $component): int { + return $component->getSpecificity(); + }, + $this->components + )); } public function render(OutputFormat $outputFormat): string { - return $this->getSelector(); + return \implode('', \array_map( + static function (Component $component) use ($outputFormat): string { + return $component->render($outputFormat); + }, + $this->components + )); + } + + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + return [ + 'class' => $this->getShortClassName(), + 'components' => \array_map( + static function (Component $component): array { + return $component->getArrayRepresentation(); + }, + $this->components + ), + ]; } } diff --git a/src/Property/Selector/Combinator.php b/src/Property/Selector/Combinator.php new file mode 100644 index 000000000..15f63e2a7 --- /dev/null +++ b/src/Property/Selector/Combinator.php @@ -0,0 +1,119 @@ +`, `+`, or `~`). + * + * @phpstan-type ValidCombinatorValue ' '|'>'|'+'|'~' + */ +class Combinator implements Component +{ + use ShortClassNameProvider; + + /** + * @var ValidCombinatorValue + */ + private $value; + + /** + * @param ValidCombinatorValue $value + */ + public function __construct(string $value) + { + $this->setValue($value); + } + + /** + * @param list $comments + * + * @throws UnexpectedTokenException + * + * @internal + */ + public static function parse(ParserState $parserState, array &$comments = []): self + { + $consumedWhitespace = $parserState->consumeWhiteSpace($comments); + + $nextToken = $parserState->peek(); + if (\in_array($nextToken, ['>', '+', '~'], true)) { + $value = $nextToken; + $parserState->consume(1); + $parserState->consumeWhiteSpace($comments); + } elseif ($consumedWhitespace !== '') { + $value = ' '; + } else { + throw new UnexpectedTokenException( + 'combinator', + $nextToken, + 'literal', + $parserState->currentLine() + ); + } + + return new self($value); + } + + /** + * @return ValidCombinatorValue + */ + public function getValue(): string + { + return $this->value; + } + + /** + * @param non-empty-string $value + * + * @throws \UnexpectedValueException if `$value` is not either space, '>', '+' or '~' + */ + public function setValue(string $value): void + { + if (!\in_array($value, [' ', '>', '+', '~'], true)) { + throw new \UnexpectedValueException('`' . $value . '` is not a valid selector combinator.'); + } + + $this->value = $value; + } + + /** + * @return int<0, max> + */ + public function getSpecificity(): int + { + return 0; + } + + public function render(OutputFormat $outputFormat): string + { + $spacing = $outputFormat->getSpaceAroundSelectorCombinator(); + if ($this->value === ' ') { + $rendering = $spacing !== '' ? $spacing : ' '; + } else { + $rendering = $spacing . $this->value . $spacing; + } + + return $rendering; + } + + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + return [ + 'class' => $this->getShortClassName(), + 'value' => $this->value, + ]; + } +} diff --git a/src/Property/Selector/Component.php b/src/Property/Selector/Component.php new file mode 100644 index 000000000..faab5b9a2 --- /dev/null +++ b/src/Property/Selector/Component.php @@ -0,0 +1,41 @@ + + */ + public function getSpecificity(): int; +} diff --git a/src/Property/Selector/CompoundSelector.php b/src/Property/Selector/CompoundSelector.php new file mode 100644 index 000000000..80b1670cd --- /dev/null +++ b/src/Property/Selector/CompoundSelector.php @@ -0,0 +1,284 @@ +`, `+`, `~`) before being passed to this class. + */ +class CompoundSelector implements Component +{ + use ShortClassNameProvider; + + private const PARSER_STOP_CHARACTERS = [ + '{', + '}', + '\'', + '"', + '(', + ')', + '[', + ']', + ',', + ' ', + "\t", + "\n", + "\r", + '>', + '+', + '~', + ParserState::EOF, + '', // `ParserState::peek()` returns empty string rather than `ParserState::EOF` when end of string is reached + ]; + + private const SELECTOR_VALIDATION_RX = '/ + ^ + # not starting with whitespace + (?!\\s) + (?: + (?: + # any sequence of valid unescaped characters, except quotes + [a-zA-Z0-9\\x{00A0}-\\x{FFFF}_^$|*=~\\[\\]()\\-\\s\\.:#+>,]++ + | + # one or more escaped characters + (?:\\\\.)++ + | + # quoted text, like in `[id="example"]` + (?: + # opening quote + ([\'"]) + (?: + # sequence of characters except closing quote or backslash + (?:(?!\\g{-1}|\\\\).)++ + | + # one or more escaped characters + (?:\\\\.)++ + )*+ # zero or more times + # closing quote or end (unmatched quote is currently allowed) + (?:\\g{-1}|$) + ) + )++ # one or more times + | + # keyframe animation progress percentage (e.g. 50%) + (?:\\d++%) + ) + # not ending with whitespace + (?setValue($value); + } + + /** + * @param list $comments + * + * @throws UnexpectedTokenException + * + * @internal + */ + public static function parse(ParserState $parserState, array &$comments = []): self + { + $selectorParts = []; + $stringWrapperCharacter = null; + $functionNestingLevel = 0; + $isWithinAttribute = false; + + while (true) { + $selectorParts[] = $parserState->consumeUntil(self::PARSER_STOP_CHARACTERS, false, false, $comments); + $nextCharacter = $parserState->peek(); + switch ($nextCharacter) { + case '': + // EOF + break 2; + case '\'': + // The fallthrough is intentional. + case '"': + $lastPart = \end($selectorParts); + $backslashCount = \strspn(\strrev($lastPart), '\\'); + $quoteIsEscaped = ($backslashCount % 2 === 1); + if (!$quoteIsEscaped) { + if (!\is_string($stringWrapperCharacter)) { + $stringWrapperCharacter = $nextCharacter; + } elseif ($stringWrapperCharacter === $nextCharacter) { + $stringWrapperCharacter = null; + } + } + break; + case '(': + if (!\is_string($stringWrapperCharacter)) { + ++$functionNestingLevel; + } + break; + case ')': + if (!\is_string($stringWrapperCharacter)) { + if ($functionNestingLevel <= 0) { + throw new UnexpectedTokenException( + 'anything but', + ')', + 'literal', + $parserState->currentLine() + ); + } + --$functionNestingLevel; + } + break; + case '[': + if (!\is_string($stringWrapperCharacter)) { + if ($isWithinAttribute) { + throw new UnexpectedTokenException( + 'anything but', + '[', + 'literal', + $parserState->currentLine() + ); + } + $isWithinAttribute = true; + } + break; + case ']': + if (!\is_string($stringWrapperCharacter)) { + if (!$isWithinAttribute) { + throw new UnexpectedTokenException( + 'anything but', + ']', + 'literal', + $parserState->currentLine() + ); + } + $isWithinAttribute = false; + } + break; + case '{': + // The fallthrough is intentional. + case '}': + if (!\is_string($stringWrapperCharacter)) { + break 2; + } + break; + case ',': + // The fallthrough is intentional. + case ' ': + // The fallthrough is intentional. + case "\t": + // The fallthrough is intentional. + case "\n": + // The fallthrough is intentional. + case "\r": + // The fallthrough is intentional. + case '>': + // The fallthrough is intentional. + case '+': + // The fallthrough is intentional. + case '~': + if (!\is_string($stringWrapperCharacter) && $functionNestingLevel === 0 && !$isWithinAttribute) { + break 2; + } + break; + } + $selectorParts[] = $parserState->consume(1); + } + + if ($functionNestingLevel !== 0) { + throw new UnexpectedTokenException(')', $nextCharacter, 'literal', $parserState->currentLine()); + } + if (\is_string($stringWrapperCharacter)) { + throw new UnexpectedTokenException( + $stringWrapperCharacter, + $nextCharacter, + 'literal', + $parserState->currentLine() + ); + } + + $value = \implode('', $selectorParts); + if ($value === '') { + throw new UnexpectedTokenException('selector', $nextCharacter, 'literal', $parserState->currentLine()); + } + if (!self::isValid($value)) { + throw new UnexpectedTokenException( + 'Selector component is not valid:', + '`' . $value . '`', + 'custom', + $parserState->currentLine() + ); + } + + return new self($value); + } + + /** + * @return non-empty-string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * @param non-empty-string $value + * + * @throws \UnexpectedValueException if `$value` contains invalid characters or has surrounding whitespce + */ + public function setValue(string $value): void + { + if (!self::isValid($value)) { + throw new \UnexpectedValueException('`' . $value . '` is not a valid compound selector.'); + } + + $this->value = $value; + } + + /** + * @return int<0, max> + */ + public function getSpecificity(): int + { + return SpecificityCalculator::calculate($this->value); + } + + public function render(OutputFormat $outputFormat): string + { + return $this->getValue(); + } + + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + return [ + 'class' => $this->getShortClassName(), + 'value' => $this->value, + ]; + } + + private static function isValid(string $value): bool + { + $numberOfMatches = preg_match(self::SELECTOR_VALIDATION_RX, $value); + + return $numberOfMatches === 1; + } +} 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/Renderable.php b/src/Renderable.php index 9ebf9a9b9..2c3cf0635 100644 --- a/src/Renderable.php +++ b/src/Renderable.php @@ -7,4 +7,11 @@ interface Renderable { public function render(OutputFormat $outputFormat): string; + + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array; } diff --git a/src/Rule/Rule.php b/src/Rule/Rule.php index 96ef1b4da..82f837dc8 100644 --- a/src/Rule/Rule.php +++ b/src/Rule/Rule.php @@ -4,197 +4,20 @@ namespace Sabberworm\CSS\Rule; -use Sabberworm\CSS\Comment\Comment; -use Sabberworm\CSS\Comment\Commentable; -use Sabberworm\CSS\Comment\CommentContainer; -use Sabberworm\CSS\CSSElement; -use Sabberworm\CSS\OutputFormat; -use Sabberworm\CSS\Parsing\ParserState; -use Sabberworm\CSS\Parsing\UnexpectedEOFException; -use Sabberworm\CSS\Parsing\UnexpectedTokenException; -use Sabberworm\CSS\Position\Position; -use Sabberworm\CSS\Position\Positionable; -use Sabberworm\CSS\Value\RuleValueList; -use Sabberworm\CSS\Value\Value; - -use function Safe\preg_match; - -/** - * `Rule`s just have a string key (the rule) and a 'Value'. - * - * In CSS, `Rule`s are expressed as follows: “key: value[0][0] value[0][1], value[1][0] value[1][1];” - */ -class Rule implements Commentable, CSSElement, Positionable -{ - use CommentContainer; - use Position; - - /** - * @var non-empty-string - */ - private $rule; - - /** - * @var RuleValueList|string|null - */ - private $value; - - /** - * @var bool - */ - private $isImportant = false; - - /** - * @param non-empty-string $rule - * @param int<1, max>|null $lineNumber - * @param int<0, max>|null $columnNumber - */ - public function __construct(string $rule, ?int $lineNumber = null, ?int $columnNumber = null) - { - $this->rule = $rule; - $this->setPosition($lineNumber, $columnNumber); - } - - /** - * @param list $commentsBeforeRule - * - * @throws UnexpectedEOFException - * @throws UnexpectedTokenException - * - * @internal since V8.8.0 - */ - public static function parse(ParserState $parserState, array $commentsBeforeRule = []): Rule - { - $comments = \array_merge($commentsBeforeRule, $parserState->consumeWhiteSpace()); - $rule = new Rule( - $parserState->parseIdentifier(!$parserState->comes('--')), - $parserState->currentLine(), - $parserState->currentColumn() - ); - $rule->setComments($comments); - $rule->addComments($parserState->consumeWhiteSpace()); - $parserState->consume(':'); - $value = Value::parseValue($parserState, self::listDelimiterForRule($rule->getRule())); - $rule->setValue($value); - $parserState->consumeWhiteSpace(); - if ($parserState->comes('!')) { - $parserState->consume('!'); - $parserState->consumeWhiteSpace(); - $parserState->consume('important'); - $rule->setIsImportant(true); - } - $parserState->consumeWhiteSpace(); - while ($parserState->comes(';')) { - $parserState->consume(';'); - } - - return $rule; - } - - /** - * Returns a list of delimiters (or separators). - * The first item is the innermost separator (or, put another way, the highest-precedence operator). - * The sequence continues to the outermost separator (or lowest-precedence operator). - * - * @param non-empty-string $rule - * - * @return list - */ - private static function listDelimiterForRule(string $rule): array - { - if (preg_match('/^font($|-)/', $rule) === 1) { - return [',', '/', ' ']; - } - - switch ($rule) { - case 'src': - return [' ', ',']; - default: - return [',', ' ', '/']; - } - } - - /** - * @param non-empty-string $rule - */ - public function setRule(string $rule): void - { - $this->rule = $rule; - } - - /** - * @return non-empty-string - */ - public function getRule(): string - { - return $this->rule; - } - - /** - * @return RuleValueList|string|null - */ - public function getValue() - { - return $this->value; - } - - /** - * @param RuleValueList|string|null $value - */ - public function setValue($value): void - { - $this->value = $value; - } - - /** - * Adds a value to the existing value. Value will be appended if a `RuleValueList` exists of the given type. - * Otherwise, the existing value will be wrapped by one. - * - * @param RuleValueList|array $value - */ - public function addValue($value, string $type = ' '): void - { - if (!\is_array($value)) { - $value = [$value]; - } - if (!($this->value instanceof RuleValueList) || $this->value->getListSeparator() !== $type) { - $currentValue = $this->value; - $this->value = new RuleValueList($type, $this->getLineNumber()); - if ($currentValue !== null && $currentValue !== '') { - $this->value->addListComponent($currentValue); - } - } - foreach ($value as $valueItem) { - $this->value->addListComponent($valueItem); - } - } - - public function setIsImportant(bool $isImportant): void - { - $this->isImportant = $isImportant; - } - - public function getIsImportant(): bool - { - return $this->isImportant; - } - - /** - * @return non-empty-string - */ - public function render(OutputFormat $outputFormat): string - { - $formatter = $outputFormat->getFormatter(); - $result = "{$formatter->comments($this)}{$this->rule}:{$formatter->spaceAfterRuleName()}"; - if ($this->value instanceof Value) { // Can also be a ValueList - $result .= $this->value->render($outputFormat); - } else { - $result .= $this->value; - } - if ($this->isImportant) { - $result .= ' !important'; - } - $result .= ';'; - return $result; +use Sabberworm\CSS\Property\Declaration; + +use function Safe\class_alias; + +if (!\class_exists(Rule::class, false) && !\interface_exists(Rule::class, false)) { + class_alias(Declaration::class, Rule::class); + // The test is expected to evaluate to false, + // but allows for the deprecation notice to be picked up by IDEs like PHPStorm. + // @phpstan-ignore booleanNot.alwaysTrue, booleanNot.alwaysTrue, booleanAnd.alwaysTrue + if (!\class_exists(Rule::class, false) && !\interface_exists(Rule::class, false)) { + /** + * @deprecated in v9.2, will be removed in v10.0. Use `Property\Declaration` instead, which is a direct + * replacement. + */ + class Rule {} } } diff --git a/src/RuleSet/AtRuleSet.php b/src/RuleSet/AtRuleSet.php index 4cd5acc2a..7307193d4 100644 --- a/src/RuleSet/AtRuleSet.php +++ b/src/RuleSet/AtRuleSet.php @@ -61,7 +61,7 @@ public function render(OutputFormat $outputFormat): string $arguments = ' ' . $arguments; } $result .= "@{$this->type}$arguments{$formatter->spaceBeforeOpeningBrace()}{"; - $result .= $this->renderRules($outputFormat); + $result .= $this->renderDeclarations($outputFormat); $result .= '}'; return $result; } diff --git a/src/RuleSet/DeclarationBlock.php b/src/RuleSet/DeclarationBlock.php index 4d0775460..6812cb487 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; @@ -16,9 +17,10 @@ use Sabberworm\CSS\Parsing\UnexpectedTokenException; use Sabberworm\CSS\Position\Position; use Sabberworm\CSS\Position\Positionable; +use Sabberworm\CSS\Property\Declaration; 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`. @@ -30,13 +32,14 @@ * * Note that `CSSListItem` extends both `Commentable` and `Renderable`, so those interfaces must also be implemented. */ -class DeclarationBlock implements CSSElement, CSSListItem, Positionable, RuleContainer +class DeclarationBlock implements CSSElement, CSSListItem, Positionable, DeclarationList { use CommentContainer; + use LegacyDeclarationListMethods; use Position; /** - * @var array + * @var list */ private $selectors = []; @@ -65,66 +68,15 @@ 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, $list, $comments); $result->setSelectors($selectors, $list); if ($parserState->comes('{')) { $parserState->consume(1); } } catch (UnexpectedTokenException $e) { if ($parserState->getSettings()->usesLenientParsing()) { - if (!$parserState->comes('}')) { - $parserState->consumeUntil('}', false, true); + if (!$parserState->consumeIfComes('}')) { + $parserState->consumeUntil(['}', ParserState::EOF], false, true); } return null; } else { @@ -146,11 +98,29 @@ 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); + // A string of comma-separated selectors requires parsing. + try { + $parserState = new ParserState($selectors, Settings::create()); + $selectorsToSet = self::parseSelectors($parserState, $list); + 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' + ); + } } - 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 +130,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 +139,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 +168,7 @@ public function removeSelector($selectorToRemove): bool } /** - * @return array + * @return list */ public function getSelectors(): array { @@ -208,65 +181,65 @@ public function getRuleSet(): RuleSet } /** - * @see RuleSet::addRule() + * @see RuleSet::addDeclaration() */ - public function addRule(Rule $ruleToAdd, ?Rule $sibling = null): void + public function addDeclaration(Declaration $declarationToAdd, ?Declaration $sibling = null): void { - $this->ruleSet->addRule($ruleToAdd, $sibling); + $this->ruleSet->addDeclaration($declarationToAdd, $sibling); } /** - * @see RuleSet::getRules() + * @return array, Declaration> * - * @return array, Rule> + * @see RuleSet::getDeclarations() */ - public function getRules(?string $searchPattern = null): array + public function getDeclarations(?string $searchPattern = null): array { - return $this->ruleSet->getRules($searchPattern); + return $this->ruleSet->getDeclarations($searchPattern); } /** - * @see RuleSet::setRules() + * @param array $declarations * - * @param array $rules + * @see RuleSet::setDeclarations() */ - public function setRules(array $rules): void + public function setDeclarations(array $declarations): void { - $this->ruleSet->setRules($rules); + $this->ruleSet->setDeclarations($declarations); } /** - * @see RuleSet::getRulesAssoc() + * @return array * - * @return array + * @see RuleSet::getDeclarationsAssociative() */ - public function getRulesAssoc(?string $searchPattern = null): array + public function getDeclarationsAssociative(?string $searchPattern = null): array { - return $this->ruleSet->getRulesAssoc($searchPattern); + return $this->ruleSet->getDeclarationsAssociative($searchPattern); } /** - * @see RuleSet::removeRule() + * @see RuleSet::removeDeclaration() */ - public function removeRule(Rule $ruleToRemove): void + public function removeDeclaration(Declaration $declarationToRemove): void { - $this->ruleSet->removeRule($ruleToRemove); + $this->ruleSet->removeDeclaration($declarationToRemove); } /** - * @see RuleSet::removeMatchingRules() + * @see RuleSet::removeMatchingDeclarations() */ - public function removeMatchingRules(string $searchPattern): void + public function removeMatchingDeclarations(string $searchPattern): void { - $this->ruleSet->removeMatchingRules($searchPattern); + $this->ruleSet->removeMatchingDeclarations($searchPattern); } /** - * @see RuleSet::removeAllRules() + * @see RuleSet::removeAllDeclarations() */ - public function removeAllRules(): void + public function removeAllDeclarations(): void { - $this->ruleSet->removeAllRules(); + $this->ruleSet->removeAllDeclarations(); } /** @@ -298,4 +271,36 @@ public function render(OutputFormat $outputFormat): string return $result; } + + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`'); + } + + /** + * @param list $comments + * + * @return list + * + * @throws UnexpectedTokenException + */ + private static function parseSelectors(ParserState $parserState, ?CSSList $list, array &$comments = []): array + { + $selectorClass = $list instanceof KeyFrame ? KeyFrameSelector::class : Selector::class; + $selectors = []; + + while (true) { + $selectors[] = $selectorClass::parse($parserState, $comments); + if (!$parserState->consumeIfComes(',')) { + break; + } + } + + return $selectors; + } } diff --git a/src/RuleSet/DeclarationList.php b/src/RuleSet/DeclarationList.php new file mode 100644 index 000000000..b1e67915f --- /dev/null +++ b/src/RuleSet/DeclarationList.php @@ -0,0 +1,77 @@ + $declarations + */ + public function setDeclarations(array $declarations): void; + + /** + * @return array, Declaration> + */ + public function getDeclarations(?string $searchPattern = null): array; + + /** + * @return array + */ + public function getDeclarationsAssociative(?string $searchPattern = null): array; + + /** + * @deprecated in v9.2, will be removed in v10.0; use `addDeclaration()` instead. + */ + public function addRule(Declaration $declarationToAdd, ?Declaration $sibling = null): void; + + /** + * @deprecated in v9.2, will be removed in v10.0; use `removeDeclaration()` instead. + */ + public function removeRule(Declaration $declarationToRemove): void; + + /** + * @deprecated in v9.2, will be removed in v10.0; use `removeMatchingDeclarations()` instead. + */ + public function removeMatchingRules(string $searchPattern): void; + + /** + * @deprecated in v9.2, will be removed in v10.0; use `removeAllDeclarations()` instead. + */ + public function removeAllRules(): void; + + /** + * @param array $declarations + * + * @deprecated in v9.2, will be removed in v10.0; use `setDeclarations()` instead. + */ + public function setRules(array $declarations): void; + + /** + * @return array, Declaration> + * + * @deprecated in v9.2, will be removed in v10.0; use `getDeclarations()` instead. + */ + public function getRules(?string $searchPattern = null): array; + + /** + * @return array + * + * @deprecated in v9.2, will be removed in v10.0; use `getDeclarationsAssociative()` instead. + */ + public function getRulesAssoc(?string $searchPattern = null): array; +} diff --git a/src/RuleSet/LegacyDeclarationListMethods.php b/src/RuleSet/LegacyDeclarationListMethods.php new file mode 100644 index 000000000..2eb82692b --- /dev/null +++ b/src/RuleSet/LegacyDeclarationListMethods.php @@ -0,0 +1,75 @@ +addDeclaration($declarationToAdd, $sibling); + } + + /** + * @deprecated in v9.2, will be removed in v10.0; use `removeDeclaration()` instead. + */ + public function removeRule(Declaration $declarationToRemove): void + { + $this->removeDeclaration($declarationToRemove); + } + + /** + * @deprecated in v9.2, will be removed in v10.0; use `removeMatchingDeclarations()` instead. + */ + public function removeMatchingRules(string $searchPattern): void + { + $this->removeMatchingDeclarations($searchPattern); + } + + /** + * @deprecated in v9.2, will be removed in v10.0; use `removeAllDeclarations()` instead. + */ + public function removeAllRules(): void + { + $this->removeAllDeclarations(); + } + + /** + * @param array $declarations + * + * @deprecated in v9.2, will be removed in v10.0; use `setDeclarations()` instead. + */ + public function setRules(array $declarations): void + { + $this->setDeclarations($declarations); + } + + /** + * @return array, Declaration> + * + * @deprecated in v9.2, will be removed in v10.0; use `getDeclarations()` instead. + */ + public function getRules(?string $searchPattern = null): array + { + return $this->getDeclarations($searchPattern); + } + + /** + * @return array + * + * @deprecated in v9.2, will be removed in v10.0; use `getDeclarationsAssociative()` instead. + */ + public function getRulesAssoc(?string $searchPattern = null): array + { + return $this->getDeclarationsAssociative($searchPattern); + } +} diff --git a/src/RuleSet/RuleContainer.php b/src/RuleSet/RuleContainer.php index 0c6c5936c..01da5d3ce 100644 --- a/src/RuleSet/RuleContainer.php +++ b/src/RuleSet/RuleContainer.php @@ -4,33 +4,17 @@ namespace Sabberworm\CSS\RuleSet; -use Sabberworm\CSS\Rule\Rule; - -/** - * Represents a CSS item that contains `Rules`, defining the methods to manipulate them. - */ -interface RuleContainer -{ - public function addRule(Rule $ruleToAdd, ?Rule $sibling = null): void; - - public function removeRule(Rule $ruleToRemove): void; - - public function removeMatchingRules(string $searchPattern): void; - - public function removeAllRules(): void; - - /** - * @param array $rules - */ - public function setRules(array $rules): void; - - /** - * @return array, Rule> - */ - public function getRules(?string $searchPattern = null): array; - - /** - * @return array - */ - public function getRulesAssoc(?string $searchPattern = null): array; +use function Safe\class_alias; + +if (!\class_exists(RuleContainer::class, false) && !\interface_exists(RuleContainer::class, false)) { + class_alias(DeclarationList::class, RuleContainer::class); + // The test is expected to evaluate to false, + // but allows for the deprecation notice to be picked up by IDEs like PHPStorm. + // @phpstan-ignore booleanNot.alwaysTrue, booleanNot.alwaysTrue, booleanAnd.alwaysTrue + if (!\class_exists(RuleContainer::class, false) && !\interface_exists(RuleContainer::class, false)) { + /** + * @deprecated in v9.2, will be removed in v10.0. Use `DeclarationList` instead, which is a direct replacement. + */ + interface RuleContainer {} + } } diff --git a/src/RuleSet/RuleSet.php b/src/RuleSet/RuleSet.php index 521a6ae9b..137e8fd45 100644 --- a/src/RuleSet/RuleSet.php +++ b/src/RuleSet/RuleSet.php @@ -13,31 +13,32 @@ use Sabberworm\CSS\Parsing\UnexpectedTokenException; use Sabberworm\CSS\Position\Position; use Sabberworm\CSS\Position\Positionable; -use Sabberworm\CSS\Rule\Rule; +use Sabberworm\CSS\Property\Declaration; /** - * This class is a container for individual 'Rule's. + * This class is a container for individual `Declaration`s. * * The most common form of a rule set is one constrained by a selector, i.e., a `DeclarationBlock`. * However, unknown `AtRule`s (like `@font-face`) are rule sets as well. * - * If you want to manipulate a `RuleSet`, use the methods `addRule(Rule $rule)`, `getRules()` and `removeRule($rule)` - * (which accepts either a `Rule` or a rule name; optionally suffixed by a dash to remove all related rules). + * If you want to manipulate a `RuleSet`, + * use the methods `addDeclaration()`, `getDeclarations()`, `removeDeclaration()`, `removeMatchingDeclarations()`, etc. * * Note that `CSSListItem` extends both `Commentable` and `Renderable`, so those interfaces must also be implemented. */ -class RuleSet implements CSSElement, CSSListItem, Positionable, RuleContainer +class RuleSet implements CSSElement, CSSListItem, Positionable, DeclarationList { use CommentContainer; + use LegacyDeclarationListMethods; use Position; /** - * the rules in this rule set, using the property name as the key, - * with potentially multiple rules per property name. + * the declarations in this rule set, using the property name as the key, + * with potentially multiple declarations per property name. * - * @var array, Rule>> + * @var array, Declaration>> */ - private $rules = []; + private $declarations = []; /** * @param int<1, max>|null $lineNumber @@ -59,14 +60,15 @@ public static function parseRuleSet(ParserState $parserState, RuleSet $ruleSet): $parserState->consume(';'); } while (true) { - $commentsBeforeRule = $parserState->consumeWhiteSpace(); + $commentsBeforeDeclaration = []; + $parserState->consumeWhiteSpace($commentsBeforeDeclaration); if ($parserState->comes('}')) { break; } - $rule = null; + $declaration = null; if ($parserState->getSettings()->usesLenientParsing()) { try { - $rule = Rule::parse($parserState, $commentsBeforeRule); + $declaration = Declaration::parse($parserState, $commentsBeforeDeclaration); } catch (UnexpectedTokenException $e) { try { $consumedText = $parserState->consumeUntil(["\n", ';', '}'], true); @@ -84,10 +86,10 @@ public static function parseRuleSet(ParserState $parserState, RuleSet $ruleSet): } } } else { - $rule = Rule::parse($parserState, $commentsBeforeRule); + $declaration = Declaration::parse($parserState, $commentsBeforeDeclaration); } - if ($rule instanceof Rule) { - $ruleSet->addRule($rule); + if ($declaration instanceof Declaration) { + $ruleSet->addDeclaration($declaration); } } $parserState->consume('}'); @@ -95,31 +97,31 @@ public static function parseRuleSet(ParserState $parserState, RuleSet $ruleSet): /** * @throws \UnexpectedValueException - * if the last `Rule` is needed as a basis for setting position, but does not have a valid position, + * if the last `Declaration` is needed as a basis for setting position, but does not have a valid position, * which should never happen */ - public function addRule(Rule $ruleToAdd, ?Rule $sibling = null): void + public function addDeclaration(Declaration $declarationToAdd, ?Declaration $sibling = null): void { - $propertyName = $ruleToAdd->getRule(); - if (!isset($this->rules[$propertyName])) { - $this->rules[$propertyName] = []; + $propertyName = $declarationToAdd->getPropertyName(); + if (!isset($this->declarations[$propertyName])) { + $this->declarations[$propertyName] = []; } - $position = \count($this->rules[$propertyName]); + $position = \count($this->declarations[$propertyName]); if ($sibling !== null) { $siblingIsInSet = false; - $siblingPosition = \array_search($sibling, $this->rules[$propertyName], true); + $siblingPosition = \array_search($sibling, $this->declarations[$propertyName], true); if ($siblingPosition !== false) { $siblingIsInSet = true; $position = $siblingPosition; } else { - $siblingIsInSet = $this->hasRule($sibling); + $siblingIsInSet = $this->hasDeclaration($sibling); if ($siblingIsInSet) { - // Maintain ordering within `$this->rules[$propertyName]` - // by inserting before first `Rule` with a same-or-later position than the sibling. - foreach ($this->rules[$propertyName] as $index => $rule) { - if (self::comparePositionable($rule, $sibling) >= 0) { + // Maintain ordering within `$this->declarations[$propertyName]` + // by inserting before first `Declaration` with a same-or-later position than the sibling. + foreach ($this->declarations[$propertyName] as $index => $declaration) { + if (self::comparePositionable($declaration, $sibling) >= 0) { $position = $index; break; } @@ -127,69 +129,71 @@ public function addRule(Rule $ruleToAdd, ?Rule $sibling = null): void } } if ($siblingIsInSet) { - // Increment column number of all existing rules on same line, starting at sibling + // Increment column number of all existing declarations on same line, starting at sibling $siblingLineNumber = $sibling->getLineNumber(); $siblingColumnNumber = $sibling->getColumnNumber(); - foreach ($this->rules as $rulesForAProperty) { - foreach ($rulesForAProperty as $rule) { + foreach ($this->declarations as $declarationsForAProperty) { + foreach ($declarationsForAProperty as $declaration) { if ( - $rule->getLineNumber() === $siblingLineNumber && - $rule->getColumnNumber() >= $siblingColumnNumber + $declaration->getLineNumber() === $siblingLineNumber && + $declaration->getColumnNumber() >= $siblingColumnNumber ) { - $rule->setPosition($siblingLineNumber, $rule->getColumnNumber() + 1); + $declaration->setPosition($siblingLineNumber, $declaration->getColumnNumber() + 1); } } } - $ruleToAdd->setPosition($siblingLineNumber, $siblingColumnNumber); + $declarationToAdd->setPosition($siblingLineNumber, $siblingColumnNumber); } } - if ($ruleToAdd->getLineNumber() === null) { + if ($declarationToAdd->getLineNumber() === null) { //this node is added manually, give it the next best line - $columnNumber = $ruleToAdd->getColumnNumber() ?? 0; - $rules = $this->getRules(); - $rulesCount = \count($rules); - if ($rulesCount > 0) { - $last = $rules[$rulesCount - 1]; + $columnNumber = $declarationToAdd->getColumnNumber() ?? 0; + $declarations = $this->getDeclarations(); + $declarationsCount = \count($declarations); + if ($declarationsCount > 0) { + $last = $declarations[$declarationsCount - 1]; $lastsLineNumber = $last->getLineNumber(); if (!\is_int($lastsLineNumber)) { throw new \UnexpectedValueException( - 'A Rule without a line number was found during addRule', + 'A Declaration without a line number was found during addDeclaration', 1750718399 ); } - $ruleToAdd->setPosition($lastsLineNumber + 1, $columnNumber); + $declarationToAdd->setPosition($lastsLineNumber + 1, $columnNumber); } else { - $ruleToAdd->setPosition(1, $columnNumber); + $declarationToAdd->setPosition(1, $columnNumber); } - } elseif ($ruleToAdd->getColumnNumber() === null) { - $ruleToAdd->setPosition($ruleToAdd->getLineNumber(), 0); + } elseif ($declarationToAdd->getColumnNumber() === null) { + $declarationToAdd->setPosition($declarationToAdd->getLineNumber(), 0); } - \array_splice($this->rules[$propertyName], $position, 0, [$ruleToAdd]); + \array_splice($this->declarations[$propertyName], $position, 0, [$declarationToAdd]); } /** - * Returns all rules matching the given rule name + * Returns all declarations matching the given property name * - * @example $ruleSet->getRules('font') // returns array(0 => $rule, …) or array(). + * @example $ruleSet->getDeclarations('font') // returns array(0 => $declaration, …) or array(). * - * @example $ruleSet->getRules('font-') - * //returns an array of all rules either beginning with font- or matching font. + * @example $ruleSet->getDeclarations('font-') + * //returns an array of all declarations either beginning with font- or matching font. * * @param string|null $searchPattern - * Pattern to search for. If null, returns all rules. - * If the pattern ends with a dash, all rules starting with the pattern are returned + * Pattern to search for. If null, returns all declarations. + * If the pattern ends with a dash, all declarations starting with the pattern are returned * as well as one matching the pattern with the dash excluded. * - * @return array, Rule> + * @return array, Declaration> */ - public function getRules(?string $searchPattern = null): array + public function getDeclarations(?string $searchPattern = null): array { $result = []; - foreach ($this->rules as $propertyName => $rules) { - // Either no search rule is given or the search rule matches the found rule exactly - // or the search rule ends in “-” and the found rule starts with the search rule. + foreach ($this->declarations as $propertyName => $declarations) { + // Either no search pattern was given + // or the search pattern matches the found declaration's property name exactly + // or the search pattern ends in “-” + // ... and the found declaration's property name starts with the search pattern if ( $searchPattern === null || $propertyName === $searchPattern || ( @@ -198,7 +202,7 @@ public function getRules(?string $searchPattern = null): array || $propertyName === \substr($searchPattern, 0, -1)) ) ) { - $result = \array_merge($result, $rules); + $result = \array_merge($result, $declarations); } } \usort($result, [self::class, 'comparePositionable']); @@ -207,89 +211,90 @@ public function getRules(?string $searchPattern = null): array } /** - * Overrides all the rules of this set. + * Overrides all the declarations of this set. * - * @param array $rules The rules to override with. + * @param array $declarations */ - public function setRules(array $rules): void + public function setDeclarations(array $declarations): void { - $this->rules = []; - foreach ($rules as $rule) { - $this->addRule($rule); + $this->declarations = []; + foreach ($declarations as $declaration) { + $this->addDeclaration($declaration); } } /** - * Returns all rules matching the given pattern and returns them in an associative array with the rule’s name - * as keys. This method exists mainly for backwards-compatibility and is really only partially useful. + * Returns all declarations with property names matching the given pattern and returns them in an associative array + * with the property names as keys. + * This method exists mainly for backwards-compatibility and is really only partially useful. * * Note: This method loses some information: Calling this (with an argument of `background-`) on a declaration block * like `{ background-color: green; background-color; rgba(0, 127, 0, 0.7); }` will only yield an associative array - * containing the rgba-valued rule while `getRules()` would yield an indexed array containing both. + * containing the rgba-valued declaration while `getDeclarations()` would yield an indexed array containing both. * * @param string|null $searchPattern - * Pattern to search for. If null, returns all rules. If the pattern ends with a dash, - * all rules starting with the pattern are returned as well as one matching the pattern with the dash + * Pattern to search for. If null, returns all declarations. If the pattern ends with a dash, + * all declarations starting with the pattern are returned as well as one matching the pattern with the dash * excluded. * - * @return array + * @return array */ - public function getRulesAssoc(?string $searchPattern = null): array + public function getDeclarationsAssociative(?string $searchPattern = null): array { - /** @var array $result */ + /** @var array $result */ $result = []; - foreach ($this->getRules($searchPattern) as $rule) { - $result[$rule->getRule()] = $rule; + foreach ($this->getDeclarations($searchPattern) as $declaration) { + $result[$declaration->getPropertyName()] = $declaration; } return $result; } /** - * Removes a `Rule` from this `RuleSet` by identity. + * Removes a `Declaration` from this `RuleSet` by identity. */ - public function removeRule(Rule $ruleToRemove): void + public function removeDeclaration(Declaration $declarationToRemove): void { - $nameOfPropertyToRemove = $ruleToRemove->getRule(); - if (!isset($this->rules[$nameOfPropertyToRemove])) { + $nameOfPropertyToRemove = $declarationToRemove->getPropertyName(); + if (!isset($this->declarations[$nameOfPropertyToRemove])) { return; } - foreach ($this->rules[$nameOfPropertyToRemove] as $key => $rule) { - if ($rule === $ruleToRemove) { - unset($this->rules[$nameOfPropertyToRemove][$key]); + foreach ($this->declarations[$nameOfPropertyToRemove] as $key => $declaration) { + if ($declaration === $declarationToRemove) { + unset($this->declarations[$nameOfPropertyToRemove][$key]); } } } /** - * Removes rules by property name or search pattern. + * Removes declarations by property name or search pattern. * * @param string $searchPattern * pattern to remove. * If the pattern ends in a dash, - * all rules starting with the pattern are removed as well as one matching the pattern with the dash + * all declarations starting with the pattern are removed as well as one matching the pattern with the dash * excluded. */ - public function removeMatchingRules(string $searchPattern): void + public function removeMatchingDeclarations(string $searchPattern): void { - foreach ($this->rules as $propertyName => $rules) { - // Either the search rule matches the found rule exactly - // or the search rule ends in “-” and the found rule starts with the search rule or equals it - // (without the trailing dash). + foreach ($this->declarations as $propertyName => $declarations) { + // Either the search pattern matches the found declaration's property name exactly + // or the search pattern ends in “-” and the found declaration's property name starts with the search + // pattern or equals it (without the trailing dash). if ( $propertyName === $searchPattern || (\strrpos($searchPattern, '-') === \strlen($searchPattern) - \strlen('-') && (\strpos($propertyName, $searchPattern) === 0 || $propertyName === \substr($searchPattern, 0, -1))) ) { - unset($this->rules[$propertyName]); + unset($this->declarations[$propertyName]); } } } - public function removeAllRules(): void + public function removeAllDeclarations(): void { - $this->rules = []; + $this->declarations = []; } /** @@ -297,20 +302,22 @@ public function removeAllRules(): void */ public function render(OutputFormat $outputFormat): string { - return $this->renderRules($outputFormat); + return $this->renderDeclarations($outputFormat); } - protected function renderRules(OutputFormat $outputFormat): string + protected function renderDeclarations(OutputFormat $outputFormat): string { $result = ''; $isFirst = true; $nextLevelFormat = $outputFormat->nextLevel(); - foreach ($this->getRules() as $rule) { + foreach ($this->getDeclarations() as $declaration) { $nextLevelFormatter = $nextLevelFormat->getFormatter(); - $renderedRule = $nextLevelFormatter->safely(static function () use ($rule, $nextLevelFormat): string { - return $rule->render($nextLevelFormat); - }); - if ($renderedRule === null) { + $renderedDeclaration = $nextLevelFormatter->safely( + static function () use ($declaration, $nextLevelFormat): string { + return $declaration->render($nextLevelFormat); + } + ); + if ($renderedDeclaration === null) { continue; } if ($isFirst) { @@ -319,7 +326,7 @@ protected function renderRules(OutputFormat $outputFormat): string } else { $result .= $nextLevelFormatter->spaceBetweenRules(); } - $result .= $renderedRule; + $result .= $renderedDeclaration; } $formatter = $outputFormat->getFormatter(); @@ -331,6 +338,16 @@ protected function renderRules(OutputFormat $outputFormat): string return $formatter->removeLastSemicolon($result); } + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`'); + } + /** * @return int negative if `$first` is before `$second`; zero if they have the same position; positive otherwise * @@ -342,7 +359,7 @@ private static function comparePositionable(Positionable $first, Positionable $s $secondsLineNumber = $second->getLineNumber(); if (!\is_int($firstsLineNumber) || !\is_int($secondsLineNumber)) { throw new \UnexpectedValueException( - 'A Rule without a line number was passed to comparePositionable', + 'A Declaration without a line number was passed to comparePositionable', 1750637683 ); } @@ -352,7 +369,7 @@ private static function comparePositionable(Positionable $first, Positionable $s $secondsColumnNumber = $second->getColumnNumber(); if (!\is_int($firstsColumnNumber) || !\is_int($secondsColumnNumber)) { throw new \UnexpectedValueException( - 'A Rule without a column number was passed to comparePositionable', + 'A Declaration without a column number was passed to comparePositionable', 1750637761 ); } @@ -362,10 +379,10 @@ private static function comparePositionable(Positionable $first, Positionable $s return $firstsLineNumber - $secondsLineNumber; } - private function hasRule(Rule $rule): bool + private function hasDeclaration(Declaration $declaration): bool { - foreach ($this->rules as $rulesForAProperty) { - if (\in_array($rule, $rulesForAProperty, true)) { + foreach ($this->declarations as $declarationsForAProperty) { + if (\in_array($declaration, $declarationsForAProperty, true)) { return true; } } diff --git a/src/ShortClassNameProvider.php b/src/ShortClassNameProvider.php new file mode 100644 index 000000000..f305e1326 --- /dev/null +++ b/src/ShortClassNameProvider.php @@ -0,0 +1,21 @@ +getShortName(); + } +} diff --git a/src/Value/CSSFunction.php b/src/Value/CSSFunction.php index 86b56d9b1..30228340a 100644 --- a/src/Value/CSSFunction.php +++ b/src/Value/CSSFunction.php @@ -113,4 +113,20 @@ public function render(OutputFormat $outputFormat): string $arguments = parent::render($outputFormat); return "{$this->name}({$arguments})"; } + + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + return \array_merge( + [ + 'class' => 'placeholder', + 'name' => $this->name, + ], + parent::getArrayRepresentation() + ); + } } diff --git a/src/Value/CSSString.php b/src/Value/CSSString.php index 569311d77..8271671f2 100644 --- a/src/Value/CSSString.php +++ b/src/Value/CSSString.php @@ -9,6 +9,7 @@ use Sabberworm\CSS\Parsing\SourceException; use Sabberworm\CSS\Parsing\UnexpectedEOFException; use Sabberworm\CSS\Parsing\UnexpectedTokenException; +use Sabberworm\CSS\ShortClassNameProvider; use function Safe\preg_match; @@ -19,6 +20,8 @@ */ class CSSString extends PrimitiveValue { + use ShortClassNameProvider; + /** * @var string */ @@ -90,8 +93,33 @@ public function getString(): string */ public function render(OutputFormat $outputFormat): string { - $string = \addslashes($this->string); - $string = \str_replace("\n", '\\A', $string); - return $outputFormat->getStringQuotingType() . $string . $outputFormat->getStringQuotingType(); + return $outputFormat->getStringQuotingType() + . $this->escape($this->string, $outputFormat) + . $outputFormat->getStringQuotingType(); + } + + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + return [ + 'class' => $this->getShortClassName(), + // We're using the term "contents" here to make the difference to the class more clear. + 'contents' => $this->string, + ]; + } + + private function escape(string $string, OutputFormat $outputFormat): string + { + $charactersToEscape = '\\'; + $charactersToEscape .= ($outputFormat->getStringQuotingType() === '"' ? '"' : "'"); + $withEscapedQuotes = \addcslashes($string, $charactersToEscape); + + $withNewlineEncoded = \str_replace("\n", '\\A', $withEscapedQuotes); + + return $withNewlineEncoded; } } diff --git a/src/Value/CalcFunction.php b/src/Value/CalcFunction.php index dba6e1dd9..3d85b22a7 100644 --- a/src/Value/CalcFunction.php +++ b/src/Value/CalcFunction.php @@ -8,16 +8,11 @@ use Sabberworm\CSS\Parsing\UnexpectedEOFException; use Sabberworm\CSS\Parsing\UnexpectedTokenException; +use function Safe\preg_match; + class CalcFunction extends CSSFunction { - /** - * @var int - */ private const T_OPERAND = 1; - - /** - * @var int - */ private const T_OPERATOR = 2; /** @@ -67,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()} ", @@ -102,4 +96,14 @@ public static function parse(ParserState $parserState, bool $ignoreCase = false) } return new CalcFunction($function, $list, ',', $parserState->currentLine()); } + + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`'); + } } diff --git a/src/Value/CalcRuleValueList.php b/src/Value/CalcRuleValueList.php index f904f12c7..5396dfdd5 100644 --- a/src/Value/CalcRuleValueList.php +++ b/src/Value/CalcRuleValueList.php @@ -20,4 +20,14 @@ public function render(OutputFormat $outputFormat): string { return $outputFormat->getFormatter()->implode(' ', $this->components); } + + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`'); + } } diff --git a/src/Value/Color.php b/src/Value/Color.php index 63bccab8b..df491692c 100644 --- a/src/Value/Color.php +++ b/src/Value/Color.php @@ -239,6 +239,16 @@ public function render(OutputFormat $outputFormat): string return parent::render($outputFormat); } + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`'); + } + private function shouldRenderAsHex(OutputFormat $outputFormat): bool { return diff --git a/src/Value/LineName.php b/src/Value/LineName.php index 763cc48ea..7b7690f88 100644 --- a/src/Value/LineName.php +++ b/src/Value/LineName.php @@ -56,4 +56,14 @@ public function render(OutputFormat $outputFormat): string { return '[' . parent::render(OutputFormat::createCompact()) . ']'; } + + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`'); + } } diff --git a/src/Value/Size.php b/src/Value/Size.php index eac736d79..ad6d794cc 100644 --- a/src/Value/Size.php +++ b/src/Value/Size.php @@ -8,6 +8,7 @@ use Sabberworm\CSS\Parsing\ParserState; use Sabberworm\CSS\Parsing\UnexpectedEOFException; use Sabberworm\CSS\Parsing\UnexpectedTokenException; +use Sabberworm\CSS\ShortClassNameProvider; use function Safe\preg_match; use function Safe\preg_replace; @@ -17,10 +18,10 @@ */ class Size extends PrimitiveValue { + use ShortClassNameProvider; + /** * 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 +41,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']; /** @@ -210,4 +205,19 @@ public function render(OutputFormat $outputFormat): string return preg_replace(["/$decimalPoint/", '/^(-?)0\\./'], ['.', '$1.'], $size) . ($this->unit ?? ''); } + + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + return [ + 'class' => $this->getShortClassName(), + // 'number' is the official W3C terminology (not 'size') + 'number' => $this->size, + 'unit' => $this->unit, + ]; + } } diff --git a/src/Value/URL.php b/src/Value/URL.php index f6e7b974a..55ee04f17 100644 --- a/src/Value/URL.php +++ b/src/Value/URL.php @@ -9,12 +9,15 @@ use Sabberworm\CSS\Parsing\SourceException; use Sabberworm\CSS\Parsing\UnexpectedEOFException; use Sabberworm\CSS\Parsing\UnexpectedTokenException; +use Sabberworm\CSS\ShortClassNameProvider; /** * This class represents URLs in CSS. `URL`s always output in `URL("")` notation. */ class URL extends PrimitiveValue { + use ShortClassNameProvider; + /** * @var CSSString */ @@ -80,4 +83,19 @@ public function render(OutputFormat $outputFormat): string { return "url({$this->url->render($outputFormat)})"; } + + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + return [ + 'class' => $this->getShortClassName(), + // We're using the term "uri" here to match the wording used in the specs: + // https://www.w3.org/TR/CSS22/syndata.html#uri + 'uri' => $this->url->getArrayRepresentation(), + ]; + } } diff --git a/src/Value/Value.php b/src/Value/Value.php index 263c420da..67065d370 100644 --- a/src/Value/Value.php +++ b/src/Value/Value.php @@ -11,6 +11,7 @@ use Sabberworm\CSS\Parsing\UnexpectedTokenException; use Sabberworm\CSS\Position\Position; use Sabberworm\CSS\Position\Positionable; +use Sabberworm\CSS\ShortClassNameProvider; use function Safe\preg_match; @@ -21,6 +22,7 @@ abstract class Value implements CSSElement, Positionable { use Position; + use ShortClassNameProvider; /** * @param int<1, max>|null $lineNumber @@ -181,6 +183,18 @@ public static function parsePrimitiveValue(ParserState $parserState) return $value; } + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + return [ + 'class' => $this->getShortClassName(), + ]; + } + /** * @throws UnexpectedEOFException * @throws UnexpectedTokenException diff --git a/src/Value/ValueList.php b/src/Value/ValueList.php index 6f85f895e..9490a8bd1 100644 --- a/src/Value/ValueList.php +++ b/src/Value/ValueList.php @@ -5,6 +5,7 @@ namespace Sabberworm\CSS\Value; use Sabberworm\CSS\OutputFormat; +use Sabberworm\CSS\ShortClassNameProvider; /** * A `ValueList` represents a lists of `Value`s, separated by some separation character @@ -14,6 +15,8 @@ */ abstract class ValueList extends Value { + use ShortClassNameProvider; + /** * @var array * @@ -93,4 +96,29 @@ public function render(OutputFormat $outputFormat): string $this->components ); } + + /** + * @return array|null> + * + * @internal + */ + public function getArrayRepresentation(): array + { + return [ + 'class' => $this->getShortClassName(), + 'components' => \array_map( + /** + * @parm Value|string $component + */ + function ($component): array { + if (\is_string($component)) { + return ['class' => 'string', 'value' => $component]; + } + return $component->getArrayRepresentation(); + }, + $this->components + ), + 'separator' => $this->separator, + ]; + } } diff --git a/tests/CSSList/AtRuleBlockListTest.php b/tests/CSSList/AtRuleBlockListTest.php index 9a725b212..10f8b2556 100644 --- a/tests/CSSList/AtRuleBlockListTest.php +++ b/tests/CSSList/AtRuleBlockListTest.php @@ -17,7 +17,7 @@ final class AtRuleBlockListTest extends TestCase { /** - * @return array + * @return array */ public static function provideMinWidthMediaRule(): array { @@ -28,7 +28,7 @@ public static function provideMinWidthMediaRule(): array } /** - * @return array + * @return array */ public static function provideSyntacticallyCorrectAtRule(): array { @@ -48,12 +48,35 @@ public static function provideSyntacticallyCorrectAtRule(): array } ', ], + 'container' => [ + '@container (min-width: 60rem) { .items { background: blue; } }', + ], + 'layer named' => [ + '@layer theme { .button { color: blue; } }', + ], + 'layer anonymous' => [ + '@layer { .card { padding: 1rem; } }', + ], + 'scope with selector' => [ + '@scope (.card) { .title { font-size: 2rem; } }', + ], + 'scope root only' => [ + '@scope { .content { margin: 0; } }', + ], + 'scope with limit' => [ + '@scope (.article-body) to (figure) { h2 { color: red; } }', + ], + 'starting-style' => [ + '@starting-style { .dialog { opacity: 0; transform: translateY(-10px); } }', + ], ]; } /** * @test * + * @param non-empty-string $css + * * @dataProvider provideMinWidthMediaRule */ public function parsesRuleNameOfMediaQueries(string $css): void @@ -68,6 +91,8 @@ public function parsesRuleNameOfMediaQueries(string $css): void /** * @test * + * @param non-empty-string $css + * * @dataProvider provideMinWidthMediaRule */ public function parsesArgumentsOfMediaQueries(string $css): void @@ -82,6 +107,8 @@ public function parsesArgumentsOfMediaQueries(string $css): void /** * @test * + * @param non-empty-string $css + * * @dataProvider provideMinWidthMediaRule * @dataProvider provideSyntacticallyCorrectAtRule */ @@ -91,4 +118,115 @@ public function parsesSyntacticallyCorrectAtRuleInStrictMode(string $css): void self::assertNotEmpty($contents, 'Failing CSS: `' . $css . '`'); } + + /** + * @return array}> + */ + public static function provideAtRuleParsingData(): array + { + return [ + 'layer with named argument' => [ + '@layer theme { .button { color: blue; } }', + 'layer', + 'theme', + 1, + ], + 'layer without arguments' => [ + '@layer { .card { padding: 1rem; } }', + 'layer', + '', + 1, + ], + 'scope with selector' => [ + '@scope (.card) { .title { font-size: 2rem; } }', + 'scope', + '(.card)', + 1, + ], + 'scope without selector' => [ + '@scope { .content { margin: 0; } }', + 'scope', + '', + 1, + ], + 'scope with limit' => [ + '@scope (.article-body) to (figure) { h2 { color: red; } }', + 'scope', + '(.article-body) to (figure)', + 1, + ], + 'starting-style' => [ + '@starting-style { .dialog { opacity: 0; transform: translateY(-10px); } }', + 'starting-style', + '', + 1, + ], + ]; + } + + /** + * @return array}> + */ + public static function provideAtRuleRenderingData(): array + { + return [ + 'layer with named argument' => [ + '@layer theme { .button { color: blue; } }', + ['@layer theme', '.button', 'color: blue'], + ], + 'scope with selector' => [ + '@scope (.card) { .title { font-size: 2rem; } }', + ['@scope (.card)', '.title', 'font-size: 2rem'], + ], + 'scope with limit' => [ + '@scope (.article-body) to (figure) { h2 { color: red; } }', + ['@scope (.article-body) to (figure)', 'h2', 'color: red'], + ], + 'starting-style' => [ + '@starting-style { .dialog { opacity: 0; } }', + ['@starting-style', '.dialog', 'opacity: 0'], + ], + ]; + } + + /** + * @test + * + * @param non-empty-string $css + * @param non-empty-string $expectedName + * @param int<0, max> $expectedContentCount + * + * @dataProvider provideAtRuleParsingData + */ + public function parsesAtRuleBlockList( + string $css, + string $expectedName, + string $expectedArgs, + int $expectedContentCount + ): void { + $contents = (new Parser($css))->parse()->getContents(); + $atRuleBlockList = $contents[0]; + + self::assertInstanceOf(AtRuleBlockList::class, $atRuleBlockList); + self::assertSame($expectedName, $atRuleBlockList->atRuleName()); + self::assertSame($expectedArgs, $atRuleBlockList->atRuleArgs()); + self::assertCount($expectedContentCount, $atRuleBlockList->getContents()); + } + + /** + * @test + * + * @dataProvider provideAtRuleRenderingData + * + * @param non-empty-string $css + * @param non-empty-list $expectedSubstrings + */ + public function rendersAtRuleBlockListCorrectly(string $css, array $expectedSubstrings): void + { + $rendered = (new Parser($css))->parse()->render(); + + foreach ($expectedSubstrings as $expected) { + self::assertStringContainsString($expected, $rendered); + } + } } 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())); } } diff --git a/tests/Functional/RuleSet/DeclarationBlockTest.php b/tests/Functional/RuleSet/DeclarationBlockTest.php index fe6b9e51c..4960c4d8a 100644 --- a/tests/Functional/RuleSet/DeclarationBlockTest.php +++ b/tests/Functional/RuleSet/DeclarationBlockTest.php @@ -7,8 +7,8 @@ use PHPUnit\Framework\TestCase; use Sabberworm\CSS\OutputFormat; use Sabberworm\CSS\Parsing\ParserState; +use Sabberworm\CSS\Property\Declaration; use Sabberworm\CSS\Property\Selector; -use Sabberworm\CSS\Rule\Rule; use Sabberworm\CSS\RuleSet\DeclarationBlock; use Sabberworm\CSS\Settings; @@ -46,22 +46,22 @@ public function parseReturnsNullForInvalidDeclarationBlock(string $invalidDeclar /** * @test */ - public function rendersRulesInOrderProvided(): void + public function rendersDeclarationsInOrderProvided(): void { $declarationBlock = new DeclarationBlock(); $declarationBlock->setSelectors([new Selector('.test')]); - $rule1 = new Rule('background-color'); - $rule1->setValue('transparent'); - $declarationBlock->addRule($rule1); + $declaration1 = new Declaration('background-color'); + $declaration1->setValue('transparent'); + $declarationBlock->addDeclaration($declaration1); - $rule2 = new Rule('background'); - $rule2->setValue('#222'); - $declarationBlock->addRule($rule2); + $declaration2 = new Declaration('background'); + $declaration2->setValue('#222'); + $declarationBlock->addDeclaration($declaration2); - $rule3 = new Rule('background-color'); - $rule3->setValue('#fff'); - $declarationBlock->addRule($rule3); + $declaration3 = new Declaration('background-color'); + $declaration3->setValue('#fff'); + $declarationBlock->addDeclaration($declaration3); $expectedRendering = 'background-color: transparent;background: #222;background-color: #fff'; self::assertStringContainsString($expectedRendering, $declarationBlock->render(new OutputFormat())); diff --git a/tests/Functional/RuleSet/RuleSetTest.php b/tests/Functional/RuleSet/RuleSetTest.php index b0d976055..5d637b62f 100644 --- a/tests/Functional/RuleSet/RuleSetTest.php +++ b/tests/Functional/RuleSet/RuleSetTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\TestCase; use Sabberworm\CSS\OutputFormat; -use Sabberworm\CSS\Rule\Rule; +use Sabberworm\CSS\Property\Declaration; use Sabberworm\CSS\RuleSet\RuleSet; /** @@ -59,9 +59,9 @@ public static function providePropertyNamesAndValuesAndExpectedCss(): array * * @dataProvider providePropertyNamesAndValuesAndExpectedCss */ - public function renderReturnsCssForRulesSet(array $propertyNamesAndValuesToSet, string $expectedCss): void + public function renderReturnsCssForDeclarationsSet(array $propertyNamesAndValuesToSet, string $expectedCss): void { - $this->setRulesFromPropertyNamesAndValues($propertyNamesAndValuesToSet); + $this->setDeclarationsFromPropertyNamesAndValues($propertyNamesAndValuesToSet); $result = $this->subject->render(OutputFormat::create()); @@ -73,7 +73,7 @@ public function renderReturnsCssForRulesSet(array $propertyNamesAndValuesToSet, */ public function renderWithCompactOutputFormatReturnsCssWithoutWhitespaceOrTrailingSemicolon(): void { - $this->setRulesFromPropertyNamesAndValues([ + $this->setDeclarationsFromPropertyNamesAndValues([ ['name' => 'color', 'value' => 'green'], ['name' => 'display', 'value' => 'block'], ]); @@ -88,7 +88,7 @@ public function renderWithCompactOutputFormatReturnsCssWithoutWhitespaceOrTraili */ public function renderWithPrettyOutputFormatReturnsCssWithNewlinesAroundIndentedDeclarations(): void { - $this->setRulesFromPropertyNamesAndValues([ + $this->setDeclarationsFromPropertyNamesAndValues([ ['name' => 'color', 'value' => 'green'], ['name' => 'display', 'value' => 'block'], ]); @@ -101,19 +101,19 @@ public function renderWithPrettyOutputFormatReturnsCssWithNewlinesAroundIndented /** * @param list $propertyNamesAndValues */ - private function setRulesFromPropertyNamesAndValues(array $propertyNamesAndValues): void + private function setDeclarationsFromPropertyNamesAndValues(array $propertyNamesAndValues): void { - $rulesToSet = \array_map( + $declarationsToSet = \array_map( /** * @param array{name: string, value: string} $nameAndValue */ - static function (array $nameAndValue): Rule { - $rule = new Rule($nameAndValue['name']); - $rule->setValue($nameAndValue['value']); - return $rule; + static function (array $nameAndValue): Declaration { + $declaration = new Declaration($nameAndValue['name']); + $declaration->setValue($nameAndValue['value']); + return $declaration; }, $propertyNamesAndValues ); - $this->subject->setRules($rulesToSet); + $this->subject->setDeclarations($declarationsToSet); } } diff --git a/tests/Functional/Value/ValueTest.php b/tests/Functional/Value/ValueTest.php index 4d65ee2a8..aa9ab037b 100644 --- a/tests/Functional/Value/ValueTest.php +++ b/tests/Functional/Value/ValueTest.php @@ -19,9 +19,7 @@ final class ValueTest extends TestCase /** * the default set of delimiters for parsing most values * - * @see \Sabberworm\CSS\Rule\Rule::listDelimiterForRule - * - * @var list + * @see \Sabberworm\CSS\Property\Declaration::getDelimitersForPropertyValue */ private const DEFAULT_DELIMITERS = [',', ' ', '/']; diff --git a/tests/OutputFormatTest.php b/tests/OutputFormatTest.php index 3a8deb30e..dce67db92 100644 --- a/tests/OutputFormatTest.php +++ b/tests/OutputFormatTest.php @@ -15,25 +15,18 @@ */ final class OutputFormatTest extends TestCase { - /** - * @var string - */ - private const TEST_CSS = <<document->render() ); } @@ -90,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(' ')) ); } @@ -102,8 +95,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(' ') @@ -122,9 +115,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")) ); } @@ -135,8 +128,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("'")) ); } @@ -147,8 +140,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)) ); } @@ -159,8 +152,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)) ); } @@ -171,8 +164,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")) ); } @@ -187,15 +180,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) + ); } /** @@ -208,12 +204,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) + ); } /** @@ -229,19 +227,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) + ); } /** @@ -273,19 +273,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) + ); } /** @@ -297,8 +299,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) ); } @@ -316,8 +318,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'); diff --git a/tests/ParserTest.php b/tests/ParserTest.php index b80280a77..af3b1ae4f 100644 --- a/tests/ParserTest.php +++ b/tests/ParserTest.php @@ -95,60 +95,60 @@ public function colorParsing(): void $selectors = $declarationBlock->getSelectors(); $selector = $selectors[0]->getSelector(); if ($selector === '#mine') { - $colorRules = $declarationBlock->getRules('color'); - $colorRuleValue = $colorRules[0]->getValue(); - self::assertSame('red', $colorRuleValue); - $colorRules = $declarationBlock->getRules('background-'); - $colorRuleValue = $colorRules[0]->getValue(); - self::assertInstanceOf(Color::class, $colorRuleValue); + $colorDeclarations = $declarationBlock->getDeclarations('color'); + $colorDeclarationValue = $colorDeclarations[0]->getValue(); + self::assertSame('red', $colorDeclarationValue); + $colorDeclarations = $declarationBlock->getDeclarations('background-'); + $colorDeclarationValue = $colorDeclarations[0]->getValue(); + self::assertInstanceOf(Color::class, $colorDeclarationValue); self::assertEquals([ - 'r' => new Size(35.0, null, true, $colorRuleValue->getLineNumber()), - 'g' => new Size(35.0, null, true, $colorRuleValue->getLineNumber()), - 'b' => new Size(35.0, null, true, $colorRuleValue->getLineNumber()), - ], $colorRuleValue->getColor()); - $colorRules = $declarationBlock->getRules('border-color'); - $colorRuleValue = $colorRules[0]->getValue(); - self::assertInstanceOf(Color::class, $colorRuleValue); + 'r' => new Size(35.0, null, true, $colorDeclarationValue->getLineNumber()), + 'g' => new Size(35.0, null, true, $colorDeclarationValue->getLineNumber()), + 'b' => new Size(35.0, null, true, $colorDeclarationValue->getLineNumber()), + ], $colorDeclarationValue->getColor()); + $colorDeclarations = $declarationBlock->getDeclarations('border-color'); + $colorDeclarationValue = $colorDeclarations[0]->getValue(); + self::assertInstanceOf(Color::class, $colorDeclarationValue); self::assertEquals([ - 'r' => new Size(10.0, null, true, $colorRuleValue->getLineNumber()), - 'g' => new Size(100.0, null, true, $colorRuleValue->getLineNumber()), - 'b' => new Size(230.0, null, true, $colorRuleValue->getLineNumber()), - ], $colorRuleValue->getColor()); - $colorRuleValue = $colorRules[1]->getValue(); - self::assertInstanceOf(Color::class, $colorRuleValue); + 'r' => new Size(10.0, null, true, $colorDeclarationValue->getLineNumber()), + 'g' => new Size(100.0, null, true, $colorDeclarationValue->getLineNumber()), + 'b' => new Size(230.0, null, true, $colorDeclarationValue->getLineNumber()), + ], $colorDeclarationValue->getColor()); + $colorDeclarationValue = $colorDeclarations[1]->getValue(); + self::assertInstanceOf(Color::class, $colorDeclarationValue); self::assertEquals([ - 'r' => new Size(10.0, null, true, $colorRuleValue->getLineNumber()), - 'g' => new Size(100.0, null, true, $colorRuleValue->getLineNumber()), - 'b' => new Size(231.0, null, true, $colorRuleValue->getLineNumber()), - 'a' => new Size('0000.3', null, true, $colorRuleValue->getLineNumber()), - ], $colorRuleValue->getColor()); - $colorRules = $declarationBlock->getRules('outline-color'); - $colorRuleValue = $colorRules[0]->getValue(); - self::assertInstanceOf(Color::class, $colorRuleValue); + 'r' => new Size(10.0, null, true, $colorDeclarationValue->getLineNumber()), + 'g' => new Size(100.0, null, true, $colorDeclarationValue->getLineNumber()), + 'b' => new Size(231.0, null, true, $colorDeclarationValue->getLineNumber()), + 'a' => new Size('0000.3', null, true, $colorDeclarationValue->getLineNumber()), + ], $colorDeclarationValue->getColor()); + $colorDeclarations = $declarationBlock->getDeclarations('outline-color'); + $colorDeclarationValue = $colorDeclarations[0]->getValue(); + self::assertInstanceOf(Color::class, $colorDeclarationValue); self::assertEquals([ - 'r' => new Size(34.0, null, true, $colorRuleValue->getLineNumber()), - 'g' => new Size(34.0, null, true, $colorRuleValue->getLineNumber()), - 'b' => new Size(34.0, null, true, $colorRuleValue->getLineNumber()), - ], $colorRuleValue->getColor()); + 'r' => new Size(34.0, null, true, $colorDeclarationValue->getLineNumber()), + 'g' => new Size(34.0, null, true, $colorDeclarationValue->getLineNumber()), + 'b' => new Size(34.0, null, true, $colorDeclarationValue->getLineNumber()), + ], $colorDeclarationValue->getColor()); } elseif ($selector === '#yours') { - $colorRules = $declarationBlock->getRules('background-color'); - $colorRuleValue = $colorRules[0]->getValue(); - self::assertInstanceOf(Color::class, $colorRuleValue); + $colorDeclarations = $declarationBlock->getDeclarations('background-color'); + $colorDeclarationValue = $colorDeclarations[0]->getValue(); + self::assertInstanceOf(Color::class, $colorDeclarationValue); self::assertEquals([ - 'h' => new Size(220.0, null, true, $colorRuleValue->getLineNumber()), - 's' => new Size(10.0, '%', true, $colorRuleValue->getLineNumber()), - 'l' => new Size(220.0, '%', true, $colorRuleValue->getLineNumber()), - ], $colorRuleValue->getColor()); - $colorRuleValue = $colorRules[1]->getValue(); - self::assertInstanceOf(Color::class, $colorRuleValue); + 'h' => new Size(220.0, null, true, $colorDeclarationValue->getLineNumber()), + 's' => new Size(10.0, '%', true, $colorDeclarationValue->getLineNumber()), + 'l' => new Size(220.0, '%', true, $colorDeclarationValue->getLineNumber()), + ], $colorDeclarationValue->getColor()); + $colorDeclarationValue = $colorDeclarations[1]->getValue(); + self::assertInstanceOf(Color::class, $colorDeclarationValue); self::assertEquals([ - 'h' => new Size(220.0, null, true, $colorRuleValue->getLineNumber()), - 's' => new Size(10.0, '%', true, $colorRuleValue->getLineNumber()), - 'l' => new Size(220.0, '%', true, $colorRuleValue->getLineNumber()), - 'a' => new Size(0000.3, null, true, $colorRuleValue->getLineNumber()), - ], $colorRuleValue->getColor()); - $colorRules = $declarationBlock->getRules('outline-color'); - self::assertEmpty($colorRules); + 'h' => new Size(220.0, null, true, $colorDeclarationValue->getLineNumber()), + 's' => new Size(10.0, '%', true, $colorDeclarationValue->getLineNumber()), + 'l' => new Size(220.0, '%', true, $colorDeclarationValue->getLineNumber()), + 'a' => new Size(0000.3, null, true, $colorDeclarationValue->getLineNumber()), + ], $colorDeclarationValue->getColor()); + $colorDeclarations = $declarationBlock->getDeclarations('outline-color'); + self::assertEmpty($colorDeclarations); } } foreach ($document->getAllValues(null, 'color') as $colorValue) { @@ -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() @@ -182,40 +179,40 @@ public function unicodeParsing(): void if (\substr($selector, 0, \strlen('.test-')) !== '.test-') { continue; } - $contentRules = $declarationBlock->getRules('content'); - $firstContentRuleAsString = $contentRules[0]->getValue()->render(OutputFormat::create()); + $contentDeclarations = $declarationBlock->getDeclarations('content'); + $firstContentDeclarationAsString = $contentDeclarations[0]->getValue()->render(OutputFormat::create()); if ($selector === '.test-1') { - self::assertSame('" "', $firstContentRuleAsString); + self::assertSame('" "', $firstContentDeclarationAsString); } if ($selector === '.test-2') { - self::assertSame('"é"', $firstContentRuleAsString); + self::assertSame('"é"', $firstContentDeclarationAsString); } if ($selector === '.test-3') { - self::assertSame('" "', $firstContentRuleAsString); + self::assertSame('" "', $firstContentDeclarationAsString); } if ($selector === '.test-4') { - self::assertSame('"𝄞"', $firstContentRuleAsString); + self::assertSame('"𝄞"', $firstContentDeclarationAsString); } if ($selector === '.test-5') { - self::assertSame('"水"', $firstContentRuleAsString); + self::assertSame('"水"', $firstContentDeclarationAsString); } if ($selector === '.test-6') { - self::assertSame('"¥"', $firstContentRuleAsString); + self::assertSame('"¥"', $firstContentDeclarationAsString); } if ($selector === '.test-7') { - self::assertSame('"\\A"', $firstContentRuleAsString); + self::assertSame('"\\A"', $firstContentDeclarationAsString); } if ($selector === '.test-8') { - self::assertSame('"\\"\\""', $firstContentRuleAsString); + self::assertSame('"\\"\\""', $firstContentDeclarationAsString); } if ($selector === '.test-9') { - self::assertSame('"\\"\\\'"', $firstContentRuleAsString); + self::assertSame('"\\"\'"', $firstContentDeclarationAsString); } if ($selector === '.test-10') { - self::assertSame('"\\\'\\\\"', $firstContentRuleAsString); + self::assertSame('"\'\\\\"', $firstContentDeclarationAsString); } if ($selector === '.test-11') { - self::assertSame('"test"', $firstContentRuleAsString); + self::assertSame('"test"', $firstContentDeclarationAsString); } } } @@ -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)) ); @@ -346,7 +315,7 @@ public function manipulation(): void $document->render() ); foreach ($document->getAllRuleSets() as $ruleSet) { - $ruleSet->removeMatchingRules('font-'); + $ruleSet->removeMatchingDeclarations('font-'); } self::assertSame( '#header {margin: 10px 2em 1cm 2%;color: red !important;background-color: green;' @@ -355,7 +324,7 @@ public function manipulation(): void $document->render() ); foreach ($document->getAllRuleSets() as $ruleSet) { - $ruleSet->removeMatchingRules('background-'); + $ruleSet->removeMatchingDeclarations('background-'); } self::assertSame( '#header {margin: 10px 2em 1cm 2%;color: red !important;frequency: 30Hz;transform: rotate(1turn);} @@ -367,24 +336,24 @@ public function manipulation(): void /** * @test */ - public function ruleGetters(): void + public function declarationGetters(): void { $document = self::parsedStructureForFile('values'); $declarationBlocks = $document->getAllDeclarationBlocks(); $headerBlock = $declarationBlocks[0]; $bodyBlock = $declarationBlocks[1]; - $backgroundHeaderRules = $headerBlock->getRules('background-'); - self::assertCount(2, $backgroundHeaderRules); - self::assertSame('background-color', $backgroundHeaderRules[0]->getRule()); - self::assertSame('background-color', $backgroundHeaderRules[1]->getRule()); - $backgroundHeaderRules = $headerBlock->getRulesAssoc('background-'); - self::assertCount(1, $backgroundHeaderRules); - self::assertInstanceOf(Color::class, $backgroundHeaderRules['background-color']->getValue()); - self::assertSame('rgba', $backgroundHeaderRules['background-color']->getValue()->getColorDescription()); - $headerBlock->removeRule($backgroundHeaderRules['background-color']); - $backgroundHeaderRules = $headerBlock->getRules('background-'); - self::assertCount(1, $backgroundHeaderRules); - self::assertSame('green', $backgroundHeaderRules[0]->getValue()); + $backgroundHeaderDeclarations = $headerBlock->getDeclarations('background-'); + self::assertCount(2, $backgroundHeaderDeclarations); + self::assertSame('background-color', $backgroundHeaderDeclarations[0]->getPropertyName()); + self::assertSame('background-color', $backgroundHeaderDeclarations[1]->getPropertyName()); + $backgroundHeaderDeclarations = $headerBlock->getDeclarationsAssociative('background-'); + self::assertCount(1, $backgroundHeaderDeclarations); + self::assertInstanceOf(Color::class, $backgroundHeaderDeclarations['background-color']->getValue()); + self::assertSame('rgba', $backgroundHeaderDeclarations['background-color']->getValue()->getColorDescription()); + $headerBlock->removeDeclaration($backgroundHeaderDeclarations['background-color']); + $backgroundHeaderDeclarations = $headerBlock->getDeclarations('background-'); + self::assertCount(1, $backgroundHeaderDeclarations); + self::assertSame('green', $backgroundHeaderDeclarations[0]->getValue()); } /** @@ -403,20 +372,20 @@ public function slashedValues(): void } } foreach ($document->getAllDeclarationBlocks() as $declarationBlock) { - $fontRules = $declarationBlock->getRules('font'); - $fontRule = $fontRules[0]; - $fontRuleValue = $fontRule->getValue(); - self::assertSame(' ', $fontRuleValue->getListSeparator()); - $fontRuleValueComponents = $fontRuleValue->getListComponents(); - $commaList = $fontRuleValueComponents[1]; + $fontDeclarations = $declarationBlock->getDeclarations('font'); + $fontDeclaration = $fontDeclarations[0]; + $fontDeclarationValue = $fontDeclaration->getValue(); + self::assertSame(' ', $fontDeclarationValue->getListSeparator()); + $fontDeclarationValueComponents = $fontDeclarationValue->getListComponents(); + $commaList = $fontDeclarationValueComponents[1]; self::assertInstanceOf(ValueList::class, $commaList); - $slashList = $fontRuleValueComponents[0]; + $slashList = $fontDeclarationValueComponents[0]; self::assertInstanceOf(ValueList::class, $slashList); self::assertSame(',', $commaList->getListSeparator()); self::assertSame('/', $slashList->getListSeparator()); - $borderRadiusRules = $declarationBlock->getRules('border-radius'); - $borderRadiusRule = $borderRadiusRules[0]; - $slashList = $borderRadiusRule->getValue(); + $borderRadiusDeclarations = $declarationBlock->getDeclarations('border-radius'); + $borderRadiusDeclaration = $borderRadiusDeclarations[0]; + $slashList = $borderRadiusDeclaration->getValue(); self::assertSame('/', $slashList->getListSeparator()); $slashListComponents = $slashList->getListComponents(); $secondSlashListComponent = $slashListComponents[1]; @@ -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()); @@ -570,11 +535,10 @@ public function selectorRemoval(): void public function comments(): void { $document = self::parsedStructureForFile('comments'); - $expected = <<render()); } @@ -719,6 +683,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()); @@ -728,6 +693,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()); } @@ -741,15 +707,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()); } /** @@ -769,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()); } @@ -786,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()); } @@ -820,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()); } @@ -931,11 +883,11 @@ public function missingPropertyValueLenient(): void $block = $declarationBlocks[0]; self::assertInstanceOf(DeclarationBlock::class, $block); self::assertEquals([new Selector('div')], $block->getSelectors()); - $rules = $block->getRules(); - self::assertCount(1, $rules); - $rule = $rules[0]; - self::assertSame('display', $rule->getRule()); - self::assertSame('inline-block', $rule->getValue()); + $declarations = $block->getDeclarations(); + self::assertCount(1, $declarations); + $declaration = $declarations[0]; + self::assertSame('display', $declaration->getPropertyName()); + self::assertSame('inline-block', $declaration->getValue()); } /** @@ -991,19 +943,19 @@ public function lineNumbersParsing(): void } } - // Checking for the multiline color rule lines 27-31 + // Checking for the multiline color declaration lines 27-31 $expectedColorLineNumbers = [28, 29, 30]; $declarationBlocks = $document->getAllDeclarationBlocks(); // Choose the 2nd one $secondDeclarationBlock = $declarationBlocks[1]; - $rules = $secondDeclarationBlock->getRules(); + $declarations = $secondDeclarationBlock->getDeclarations(); // Choose the 2nd one - $valueOfSecondRule = $rules[1]->getValue(); - self::assertInstanceOf(Color::class, $valueOfSecondRule); - self::assertSame(27, $rules[1]->getLineNumber()); + $valueOfSecondDeclaration = $declarations[1]->getValue(); + self::assertInstanceOf(Color::class, $valueOfSecondDeclaration); + self::assertSame(27, $declarations[1]->getLineNumber()); $actualColorLineNumbers = []; - foreach ($valueOfSecondRule->getColor() as $size) { + foreach ($valueOfSecondDeclaration->getColor() as $size) { $actualColorLineNumbers[] = $size->getLineNumber(); } @@ -1049,18 +1001,17 @@ 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. + // Declarations. self::assertInstanceOf(DeclarationBlock::class, $fooBarBlock); - $fooBarRules = $fooBarBlock->getRules(); - $fooBarRule = $fooBarRules[0]; - $fooBarRuleComments = $fooBarRule->getComments(); - self::assertCount(1, $fooBarRuleComments); - self::assertSame(' Number 6 ', $fooBarRuleComments[0]->getComment()); + $fooBarDeclarations = $fooBarBlock->getDeclarations(); + $fooBarDeclaration = $fooBarDeclarations[0]; + $fooBarDeclarationComments = $fooBarDeclaration->getComments(); + self::assertCount(1, $fooBarDeclarationComments); + self::assertSame(' Number 6 ', $fooBarDeclarationComments[0]->getComment()); // Media property. self::assertInstanceOf(Commentable::class, $nodes[2]); @@ -1075,10 +1026,10 @@ public function commentExtracting(): void self::assertCount(1, $fooBarComments); self::assertSame('* Number 10 *', $fooBarComments[0]->getComment()); - // Media -> declaration -> rule. + // Media -> rule -> declaration. self::assertInstanceOf(DeclarationBlock::class, $mediaRules[0]); - $fooBarRules = $mediaRules[0]->getRules(); - $fooBarChildComments = $fooBarRules[0]->getComments(); + $fooBarDeclarations = $mediaRules[0]->getDeclarations(); + $fooBarChildComments = $fooBarDeclarations[0]->getComments(); self::assertCount(1, $fooBarChildComments); self::assertSame('* Number 10b *', $fooBarChildComments[0]->getComment()); } @@ -1093,8 +1044,8 @@ public function flatCommentExtractingOneComment(): void $contents = $document->getContents(); self::assertInstanceOf(DeclarationBlock::class, $contents[0]); - $divRules = $contents[0]->getRules(); - $comments = $divRules[0]->getComments(); + $divDeclarations = $contents[0]->getDeclarations(); + $comments = $divDeclarations[0]->getComments(); self::assertCount(1, $comments); self::assertSame('Find Me!', $comments[0]->getComment()); @@ -1103,15 +1054,15 @@ public function flatCommentExtractingOneComment(): void /** * @test */ - public function flatCommentExtractingTwoConjoinedCommentsForOneRule(): void + public function flatCommentExtractingTwoConjoinedCommentsForOneDeclaration(): void { $parser = new Parser('div {/*Find Me!*//*Find Me Too!*/left:10px; text-align:left;}'); $document = $parser->parse(); $contents = $document->getContents(); self::assertInstanceOf(DeclarationBlock::class, $contents[0]); - $divRules = $contents[0]->getRules(); - $comments = $divRules[0]->getComments(); + $divDeclarations = $contents[0]->getDeclarations(); + $comments = $divDeclarations[0]->getComments(); self::assertCount(2, $comments); self::assertSame('Find Me!', $comments[0]->getComment()); @@ -1121,15 +1072,15 @@ public function flatCommentExtractingTwoConjoinedCommentsForOneRule(): void /** * @test */ - public function flatCommentExtractingTwoSpaceSeparatedCommentsForOneRule(): void + public function flatCommentExtractingTwoSpaceSeparatedCommentsForOneDeclaration(): void { $parser = new Parser('div { /*Find Me!*/ /*Find Me Too!*/ left:10px; text-align:left;}'); $document = $parser->parse(); $contents = $document->getContents(); self::assertInstanceOf(DeclarationBlock::class, $contents[0]); - $divRules = $contents[0]->getRules(); - $comments = $divRules[0]->getComments(); + $divDeclarations = $contents[0]->getDeclarations(); + $comments = $divDeclarations[0]->getComments(); self::assertCount(2, $comments); self::assertSame('Find Me!', $comments[0]->getComment()); @@ -1139,21 +1090,21 @@ public function flatCommentExtractingTwoSpaceSeparatedCommentsForOneRule(): void /** * @test */ - public function flatCommentExtractingCommentsForTwoRules(): void + public function flatCommentExtractingCommentsForTwoDeclarations(): void { $parser = new Parser('div {/*Find Me!*/left:10px; /*Find Me Too!*/text-align:left;}'); $document = $parser->parse(); $contents = $document->getContents(); self::assertInstanceOf(DeclarationBlock::class, $contents[0]); - $divRules = $contents[0]->getRules(); - $rule1Comments = $divRules[0]->getComments(); - $rule2Comments = $divRules[1]->getComments(); - - self::assertCount(1, $rule1Comments); - self::assertCount(1, $rule2Comments); - self::assertSame('Find Me!', $rule1Comments[0]->getComment()); - self::assertSame('Find Me Too!', $rule2Comments[0]->getComment()); + $divDeclarations = $contents[0]->getDeclarations(); + $declaration1Comments = $divDeclarations[0]->getComments(); + $declaration2Comments = $divDeclarations[1]->getComments(); + + self::assertCount(1, $declaration1Comments); + self::assertCount(1, $declaration2Comments); + self::assertSame('Find Me!', $declaration1Comments[0]->getComment()); + self::assertSame('Find Me Too!', $declaration2Comments[0]->getComment()); } /** @@ -1231,10 +1182,10 @@ public function escapedSpecialCaseTokens(): void $document = self::parsedStructureForFile('escaped-tokens'); $contents = $document->getContents(); self::assertInstanceOf(RuleSet::class, $contents[0]); - $rules = $contents[0]->getRules(); - $urlRule = $rules[0]; - $calcRule = $rules[1]; - self::assertInstanceOf(URL::class, $urlRule->getValue()); - self::assertInstanceOf(CalcFunction::class, $calcRule->getValue()); + $declarations = $contents[0]->getDeclarations(); + $urlDeclaration = $declarations[0]; + $calcDeclaration = $declarations[1]; + self::assertInstanceOf(URL::class, $urlDeclaration->getValue()); + self::assertInstanceOf(CalcFunction::class, $calcDeclaration->getValue()); } } diff --git a/tests/RuleSet/DeclarationBlockTest.php b/tests/RuleSet/DeclarationBlockTest.php index 63411a646..f3e4a9bf3 100644 --- a/tests/RuleSet/DeclarationBlockTest.php +++ b/tests/RuleSet/DeclarationBlockTest.php @@ -7,7 +7,7 @@ use PHPUnit\Framework\TestCase; use Sabberworm\CSS\OutputFormat; use Sabberworm\CSS\Parser; -use Sabberworm\CSS\Rule\Rule; +use Sabberworm\CSS\Property\Declaration; use Sabberworm\CSS\RuleSet\DeclarationBlock; use Sabberworm\CSS\Settings as ParserSettings; use Sabberworm\CSS\Value\Size; @@ -20,30 +20,30 @@ final class DeclarationBlockTest extends TestCase /** * @test */ - public function overrideRules(): void + public function overrideDeclarations(): void { $css = '.wrapper { left: 10px; text-align: left; }'; $parser = new Parser($css); $document = $parser->parse(); - $rule = new Rule('right'); - $rule->setValue('-10px'); + $declaration = new Declaration('right'); + $declaration->setValue('-10px'); $contents = $document->getContents(); $wrapper = $contents[0]; self::assertInstanceOf(DeclarationBlock::class, $wrapper); - self::assertCount(2, $wrapper->getRules()); - $wrapper->setRules([$rule]); + self::assertCount(2, $wrapper->getDeclarations()); + $wrapper->setDeclarations([$declaration]); - $rules = $wrapper->getRules(); - self::assertCount(1, $rules); - self::assertSame('right', $rules[0]->getRule()); - self::assertSame('-10px', $rules[0]->getValue()); + $declarations = $wrapper->getDeclarations(); + self::assertCount(1, $declarations); + self::assertSame('right', $declarations[0]->getPropertyName()); + self::assertSame('-10px', $declarations[0]->getValue()); } /** * @test */ - public function ruleInsertion(): void + public function declarationInsertion(): void { $css = '.wrapper { left: 10px; text-align: left; }'; $parser = new Parser($css); @@ -53,34 +53,34 @@ public function ruleInsertion(): void self::assertInstanceOf(DeclarationBlock::class, $wrapper); - $leftRules = $wrapper->getRules('left'); - self::assertCount(1, $leftRules); - $firstLeftRule = $leftRules[0]; + $leftDeclarations = $wrapper->getDeclarations('left'); + self::assertCount(1, $leftDeclarations); + $firstLeftDeclaration = $leftDeclarations[0]; - $textRules = $wrapper->getRules('text-'); - self::assertCount(1, $textRules); - $firstTextRule = $textRules[0]; + $textDeclarations = $wrapper->getDeclarations('text-'); + self::assertCount(1, $textDeclarations); + $firstTextDeclaration = $textDeclarations[0]; - $leftPrefixRule = new Rule('left'); - $leftPrefixRule->setValue(new Size(16, 'em')); + $leftPrefixDeclaration = new Declaration('left'); + $leftPrefixDeclaration->setValue(new Size(16, 'em')); - $textAlignRule = new Rule('text-align'); - $textAlignRule->setValue(new Size(1)); + $textAlignDeclaration = new Declaration('text-align'); + $textAlignDeclaration->setValue(new Size(1)); - $borderBottomRule = new Rule('border-bottom-width'); - $borderBottomRule->setValue(new Size(1, 'px')); + $borderBottomDeclaration = new Declaration('border-bottom-width'); + $borderBottomDeclaration->setValue(new Size(1, 'px')); - $wrapper->addRule($borderBottomRule); - $wrapper->addRule($leftPrefixRule, $firstLeftRule); - $wrapper->addRule($textAlignRule, $firstTextRule); + $wrapper->addDeclaration($borderBottomDeclaration); + $wrapper->addDeclaration($leftPrefixDeclaration, $firstLeftDeclaration); + $wrapper->addDeclaration($textAlignDeclaration, $firstTextDeclaration); - $rules = $wrapper->getRules(); + $declarations = $wrapper->getDeclarations(); - self::assertSame($leftPrefixRule, $rules[0]); - self::assertSame($firstLeftRule, $rules[1]); - self::assertSame($textAlignRule, $rules[2]); - self::assertSame($firstTextRule, $rules[3]); - self::assertSame($borderBottomRule, $rules[4]); + self::assertSame($leftPrefixDeclaration, $declarations[0]); + self::assertSame($firstLeftDeclaration, $declarations[1]); + self::assertSame($textAlignDeclaration, $declarations[2]); + self::assertSame($firstTextDeclaration, $declarations[3]); + self::assertSame($borderBottomDeclaration, $declarations[4]); self::assertSame( '.wrapper {left: 16em;left: 10px;text-align: 1;text-align: left;border-bottom-width: 1px;}', @@ -103,7 +103,7 @@ public static function declarationBlocksWithCommentsProvider(): array * @test * @dataProvider declarationBlocksWithCommentsProvider */ - public function canRemoveCommentsFromRulesUsingLenientParsing( + public function canRemoveCommentsFromDeclarationsUsingLenientParsing( string $cssWithComments, string $cssWithoutComments ): void { @@ -120,7 +120,7 @@ public function canRemoveCommentsFromRulesUsingLenientParsing( * @test * @dataProvider declarationBlocksWithCommentsProvider */ - public function canRemoveCommentsFromRulesUsingStrictParsing( + public function canRemoveCommentsFromDeclarationsUsingStrictParsing( string $cssWithComments, string $cssWithoutComments ): void { diff --git a/tests/Unit/CSSList/AtRuleBlockListTest.php b/tests/Unit/CSSList/AtRuleBlockListTest.php index 3b61e437e..416c8819f 100644 --- a/tests/Unit/CSSList/AtRuleBlockListTest.php +++ b/tests/Unit/CSSList/AtRuleBlockListTest.php @@ -144,4 +144,16 @@ public function isRootListAlwaysReturnsFalse(): void self::assertFalse($subject->isRootList()); } + + /** + * @test + */ + public function getArrayRepresentationThrowsException(): void + { + $this->expectException(\BadMethodCallException::class); + + $subject = new AtRuleBlockList('media'); + + $subject->getArrayRepresentation(); + } } diff --git a/tests/Unit/CSSList/CSSBlockListTest.php b/tests/Unit/CSSList/CSSBlockListTest.php index ce7e54477..fc584541b 100644 --- a/tests/Unit/CSSList/CSSBlockListTest.php +++ b/tests/Unit/CSSList/CSSBlockListTest.php @@ -9,9 +9,9 @@ use Sabberworm\CSS\CSSList\AtRuleBlockList; use Sabberworm\CSS\CSSList\CSSList; use Sabberworm\CSS\Property\Charset; +use Sabberworm\CSS\Property\Declaration; use Sabberworm\CSS\Property\Import; use Sabberworm\CSS\Renderable; -use Sabberworm\CSS\Rule\Rule; use Sabberworm\CSS\RuleSet\AtRuleSet; use Sabberworm\CSS\RuleSet\DeclarationBlock; use Sabberworm\CSS\Tests\Unit\CSSList\Fixtures\ConcreteCSSBlockList; @@ -300,9 +300,9 @@ public function getAllValuesReturnsOneValueDirectlySetAsContent(): void $value = new CSSString('Superfont'); $declarationBlock = new DeclarationBlock(); - $rule = new Rule('font-family'); - $rule->setValue($value); - $declarationBlock->addRule($rule); + $declaration = new Declaration('font-family'); + $declaration->setValue($value); + $declarationBlock->addDeclaration($declaration); $subject->setContents([$declarationBlock]); $result = $subject->getAllValues(); @@ -321,12 +321,12 @@ public function getAllValuesReturnsMultipleValuesDirectlySetAsContentInOneDeclar $value2 = new CSSString('aquamarine'); $declarationBlock = new DeclarationBlock(); - $rule1 = new Rule('font-family'); - $rule1->setValue($value1); - $declarationBlock->addRule($rule1); - $rule2 = new Rule('color'); - $rule2->setValue($value2); - $declarationBlock->addRule($rule2); + $declaration1 = new Declaration('font-family'); + $declaration1->setValue($value1); + $declarationBlock->addDeclaration($declaration1); + $declaration2 = new Declaration('color'); + $declaration2->setValue($value2); + $declarationBlock->addDeclaration($declaration2); $subject->setContents([$declarationBlock]); $result = $subject->getAllValues(); @@ -345,13 +345,13 @@ public function getAllValuesReturnsMultipleValuesDirectlySetAsContentInMultipleD $value2 = new CSSString('aquamarine'); $declarationBlock1 = new DeclarationBlock(); - $rule1 = new Rule('font-family'); - $rule1->setValue($value1); - $declarationBlock1->addRule($rule1); + $declaration1 = new Declaration('font-family'); + $declaration1->setValue($value1); + $declarationBlock1->addDeclaration($declaration1); $declarationBlock2 = new DeclarationBlock(); - $rule2 = new Rule('color'); - $rule2->setValue($value2); - $declarationBlock2->addRule($rule2); + $declaration2 = new Declaration('color'); + $declaration2->setValue($value2); + $declarationBlock2->addDeclaration($declaration2); $subject->setContents([$declarationBlock1, $declarationBlock2]); $result = $subject->getAllValues(); @@ -369,9 +369,9 @@ public function getAllValuesReturnsValuesWithinAtRuleBlockList(): void $value = new CSSString('Superfont'); $declarationBlock = new DeclarationBlock(); - $rule = new Rule('font-family'); - $rule->setValue($value); - $declarationBlock->addRule($rule); + $declaration = new Declaration('font-family'); + $declaration->setValue($value); + $declarationBlock->addDeclaration($declaration); $atRuleBlockList = new AtRuleBlockList('media'); $atRuleBlockList->setContents([$declarationBlock]); $subject->setContents([$atRuleBlockList]); @@ -392,13 +392,13 @@ public function getAllValuesWithElementProvidedReturnsOnlyValuesWithinThatElemen $value2 = new CSSString('aquamarine'); $declarationBlock1 = new DeclarationBlock(); - $rule1 = new Rule('font-family'); - $rule1->setValue($value1); - $declarationBlock1->addRule($rule1); + $declaration1 = new Declaration('font-family'); + $declaration1->setValue($value1); + $declarationBlock1->addDeclaration($declaration1); $declarationBlock2 = new DeclarationBlock(); - $rule2 = new Rule('color'); - $rule2->setValue($value2); - $declarationBlock2->addRule($rule2); + $declaration2 = new Declaration('color'); + $declaration2->setValue($value2); + $declarationBlock2->addDeclaration($declaration2); $subject->setContents([$declarationBlock1, $declarationBlock2]); $result = $subject->getAllValues($declarationBlock1); @@ -409,7 +409,7 @@ public function getAllValuesWithElementProvidedReturnsOnlyValuesWithinThatElemen /** * @test */ - public function getAllValuesWithSearchStringProvidedReturnsOnlyValuesFromMatchingRules(): void + public function getAllValuesWithSearchStringProvidedReturnsOnlyValuesFromMatchingDeclarations(): void { $subject = new ConcreteCSSBlockList(); @@ -417,12 +417,12 @@ public function getAllValuesWithSearchStringProvidedReturnsOnlyValuesFromMatchin $value2 = new CSSString('aquamarine'); $declarationBlock = new DeclarationBlock(); - $rule1 = new Rule('font-family'); - $rule1->setValue($value1); - $declarationBlock->addRule($rule1); - $rule2 = new Rule('color'); - $rule2->setValue($value2); - $declarationBlock->addRule($rule2); + $declaration1 = new Declaration('font-family'); + $declaration1->setValue($value1); + $declarationBlock->addDeclaration($declaration1); + $declaration2 = new Declaration('color'); + $declaration2->setValue($value2); + $declarationBlock->addDeclaration($declaration2); $subject->setContents([$declarationBlock]); $result = $subject->getAllValues(null, 'font-'); @@ -441,9 +441,9 @@ public function getAllValuesByDefaultDoesNotReturnValuesInFunctionArguments(): v $value2 = new Size(2, '%'); $declarationBlock = new DeclarationBlock(); - $rule = new Rule('margin'); - $rule->setValue(new CSSFunction('max', [$value1, $value2])); - $declarationBlock->addRule($rule); + $declaration = new Declaration('margin'); + $declaration->setValue(new CSSFunction('max', [$value1, $value2])); + $declarationBlock->addDeclaration($declaration); $subject->setContents([$declarationBlock]); $result = $subject->getAllValues(); @@ -462,13 +462,25 @@ public function getAllValuesWithSearchInFunctionArgumentsReturnsValuesInFunction $value2 = new Size(2, '%'); $declarationBlock = new DeclarationBlock(); - $rule = new Rule('margin'); - $rule->setValue(new CSSFunction('max', [$value1, $value2])); - $declarationBlock->addRule($rule); + $declaration = new Declaration('margin'); + $declaration->setValue(new CSSFunction('max', [$value1, $value2])); + $declarationBlock->addDeclaration($declaration); $subject->setContents([$declarationBlock]); $result = $subject->getAllValues(null, null, true); self::assertSame([$value1, $value2], $result); } + + /** + * @test + */ + public function getArrayRepresentationThrowsException(): void + { + $this->expectException(\BadMethodCallException::class); + + $subject = new ConcreteCSSBlockList(); + + $subject->getArrayRepresentation(); + } } diff --git a/tests/Unit/CSSList/CSSListTest.php b/tests/Unit/CSSList/CSSListTest.php index ada176e9a..3ab8d19f1 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; /** @@ -239,6 +244,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 */ @@ -326,4 +347,49 @@ 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())); + } + + /** + * @test + */ + public function getArrayRepresentationThrowsException(): void + { + $this->expectException(\BadMethodCallException::class); + + $subject = new ConcreteCSSList(); + + $subject->getArrayRepresentation(); + } } diff --git a/tests/Unit/CSSList/DocumentTest.php b/tests/Unit/CSSList/DocumentTest.php index 77e8aa81c..c1d0f614a 100644 --- a/tests/Unit/CSSList/DocumentTest.php +++ b/tests/Unit/CSSList/DocumentTest.php @@ -63,4 +63,16 @@ public function isRootListAlwaysReturnsTrue(): void self::assertTrue($subject->isRootList()); } + + /** + * @test + */ + public function getArrayRepresentationThrowsException(): void + { + $this->expectException(\BadMethodCallException::class); + + $subject = new Document(); + + $subject->getArrayRepresentation(); + } } diff --git a/tests/Unit/CSSList/KeyFrameTest.php b/tests/Unit/CSSList/KeyFrameTest.php index e191a440e..b77f295db 100644 --- a/tests/Unit/CSSList/KeyFrameTest.php +++ b/tests/Unit/CSSList/KeyFrameTest.php @@ -108,4 +108,16 @@ public function getVendorKeyFrameByDefaultReturnsKeyframes(): void self::assertSame('keyframes', $subject->getVendorKeyFrame()); } + + /** + * @test + */ + public function getArrayRepresentationThrowsException(): void + { + $this->expectException(\BadMethodCallException::class); + + $subject = new KeyFrame(); + + $subject->getArrayRepresentation(); + } } diff --git a/tests/Unit/Comment/CommentTest.php b/tests/Unit/Comment/CommentTest.php index 69572e903..9c2d1e9a1 100644 --- a/tests/Unit/Comment/CommentTest.php +++ b/tests/Unit/Comment/CommentTest.php @@ -47,7 +47,7 @@ public function getCommentInitiallyReturnsCommentPassedToConstructor(): void /** * @test */ - public function setCommentSetsComments(): void + public function setCommentSetsComment(): void { $comment = 'There is no spoon.'; $subject = new Comment(); @@ -77,4 +77,29 @@ public function getLineNumberReturnsLineNumberProvidedToConstructor(): void self::assertSame($lineNumber, $subject->getLineNumber()); } + + /** + * @test + */ + public function getArrayRepresentationIncludesClassName(): void + { + $subject = new Comment(); + + $result = $subject->getArrayRepresentation(); + + self::assertSame('Comment', $result['class']); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesContents(): void + { + $contents = 'War. War never changes.'; + $subject = new Comment($contents); + + $result = $subject->getArrayRepresentation(); + + self::assertSame($contents, $result['contents']); + } } diff --git a/tests/Unit/Fixtures/ConcreteShortClassNameProvider.php b/tests/Unit/Fixtures/ConcreteShortClassNameProvider.php new file mode 100644 index 000000000..70dd10fd6 --- /dev/null +++ b/tests/Unit/Fixtures/ConcreteShortClassNameProvider.php @@ -0,0 +1,22 @@ +getShortClassName(); + } +} diff --git a/tests/Unit/OutputFormatTest.php b/tests/Unit/OutputFormatTest.php index 1a043e1da..145ae49d0 100644 --- a/tests/Unit/OutputFormatTest.php +++ b/tests/Unit/OutputFormatTest.php @@ -414,6 +414,33 @@ public function setSpaceAfterSelectorSeparatorProvidesFluentInterface(): void self::assertSame($this->subject, $this->subject->setSpaceAfterSelectorSeparator(' ')); } + /** + * @test + */ + public function getSpaceAroundSelectorCombinatorInitiallyReturnsSpace(): void + { + self::assertSame(' ', $this->subject->getSpaceAroundSelectorCombinator()); + } + + /** + * @test + */ + public function setSpaceAroundSelectorCombinatorSetsSpaceAroundSelectorCombinator(): void + { + $value = ' '; + $this->subject->setSpaceAroundSelectorCombinator($value); + + self::assertSame($value, $this->subject->getSpaceAroundSelectorCombinator()); + } + + /** + * @test + */ + public function setSpaceAroundSelectorCombinatorProvidesFluentInterface(): void + { + self::assertSame($this->subject, $this->subject->setSpaceAroundSelectorCombinator(' ')); + } + /** * @test */ @@ -1024,6 +1051,16 @@ public function createCompactReturnsInstanceWithSpaceAfterSelectorSeparatorSetTo self::assertSame('', $newInstance->getSpaceAfterSelectorSeparator()); } + /** + * @test + */ + public function createCompactReturnsInstanceWithSpaceAroundSelectorCombinatorSetToEmptyString(): void + { + $newInstance = OutputFormat::createCompact(); + + self::assertSame('', $newInstance->getSpaceAroundSelectorCombinator()); + } + /** * @test */ @@ -1163,6 +1200,16 @@ public function createPrettyReturnsInstanceWithSpaceAfterSelectorSeparatorSetToS self::assertSame(' ', $newInstance->getSpaceAfterSelectorSeparator()); } + /** + * @test + */ + public function createPrettyReturnsInstanceWithSpaceAroundSelectorCombinatorSetToSpace(): void + { + $newInstance = OutputFormat::createPretty(); + + self::assertSame(' ', $newInstance->getSpaceAroundSelectorCombinator()); + } + /** * @test */ diff --git a/tests/Unit/Parsing/ParserStateTest.php b/tests/Unit/Parsing/ParserStateTest.php new file mode 100644 index 000000000..ebc3c7fa7 --- /dev/null +++ b/tests/Unit/Parsing/ParserStateTest.php @@ -0,0 +1,306 @@ + + * } + * > + */ + 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); + } + + /** + * @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()); + } + + /** + * @return array + */ + public static function provideContentWhichMayHaveWhitespaceOrCommentsAndExpectedConsumption(): array + { + return [ + 'nothing' => ['', ''], + 'space' => [' ', ' '], + 'tab' => ["\t", "\t"], + 'line feed' => ["\n", "\n"], + 'carriage return' => ["\r", "\r"], + 'two spaces' => [' ', ' '], + 'comment' => ['/*hello*/', ''], + 'comment with space to the left' => [' /*hello*/', ' '], + 'comment with space to the right' => ['/*hello*/ ', ' '], + 'two comments' => ['/*hello*//*bye*/', ''], + 'two comments with space between' => ['/*hello*/ /*bye*/', ' '], + 'two comments with line feed between' => ["/*hello*/\n/*bye*/", "\n"], + ]; + } + + /** + * @test + * + * @dataProvider provideContentWhichMayHaveWhitespaceOrCommentsAndExpectedConsumption + */ + public function consumeWhiteSpaceReturnsTheConsumed( + string $whitespaceMaybeWithComments, + string $expectedConsumption + ): void { + $subject = new ParserState($whitespaceMaybeWithComments, Settings::create()); + + $result = $subject->consumeWhiteSpace(); + + self::assertSame($expectedConsumption, $result); + } + + /** + * @test + */ + public function consumeWhiteSpaceExtractsComment(): void + { + $commentText = 'Did they get you to trade your heroes for ghosts?'; + $subject = new ParserState('/*' . $commentText . '*/', Settings::create()); + + $result = []; + $subject->consumeWhiteSpace($result); + + self::assertInstanceOf(Comment::class, $result[0]); + self::assertSame($commentText, $result[0]->getComment()); + } + + /** + * @test + */ + public function consumeWhiteSpaceExtractsTwoComments(): void + { + $commentText1 = 'Hot ashes for trees? Hot air for a cool breeze?'; + $commentText2 = 'Cold comfort for change? Did you exchange'; + $subject = new ParserState('/*' . $commentText1 . '*//*' . $commentText2 . '*/', Settings::create()); + + $result = []; + $subject->consumeWhiteSpace($result); + + self::assertInstanceOf(Comment::class, $result[0]); + self::assertSame($commentText1, $result[0]->getComment()); + self::assertInstanceOf(Comment::class, $result[1]); + self::assertSame($commentText2, $result[1]->getComment()); + } + + /** + * @return array + */ + public static function provideWhitespace(): array + { + return [ + 'space' => [' '], + 'tab' => ["\t"], + 'line feed' => ["\n"], + 'carriage return' => ["\r"], + 'two spaces' => [' '], + ]; + } + + /** + * @test + * + * @param non-empty-string $whitespace + * + * @dataProvider provideWhitespace + */ + public function consumeWhiteSpaceExtractsCommentWithSurroundingWhitespace(string $whitespace): void + { + $commentText = 'A walk-on part in the war for a lead role in a cage?'; + $subject = new ParserState($whitespace . '/*' . $commentText . '*/' . $whitespace, Settings::create()); + + $result = []; + $subject->consumeWhiteSpace($result); + + self::assertInstanceOf(Comment::class, $result[0]); + self::assertSame($commentText, $result[0]->getComment()); + } + + /** + * @return array + */ + public static function provideNonWhitespace(): array + { + return [ + 'number' => ['7'], + 'uppercase letter' => ['B'], + 'lowercase letter' => ['c'], + 'symbol' => ['@'], + 'sequence of non-whitespace characters' => ['Hello!'], + 'sequence of characters including space which isn\'t first' => ['Oh no!'], + ]; + } + + /** + * @return DataProvider + */ + public function provideNonWhitespaceAndContentWithPossibleWhitespaceOrComments(): DataProvider + { + return DataProvider::cross( + self::provideNonWhitespace(), + self::provideContentWhichMayHaveWhitespaceOrCommentsAndExpectedConsumption() + ); + } + + /** + * @test + * + * @param non-empty-string $nonWhitespace + * + * @dataProvider provideNonWhitespaceAndContentWithPossibleWhitespaceOrComments + */ + public function consumeWhiteSpaceStopsAtNonWhitespace(string $nonWhitespace, string $whitespace): void + { + $subject = new ParserState($whitespace . $nonWhitespace, Settings::create()); + + $subject->consumeWhiteSpace(); + + self::assertTrue($subject->comes($nonWhitespace)); + } +} diff --git a/tests/Unit/Property/AtRuleTest.php b/tests/Unit/Property/AtRuleTest.php new file mode 100644 index 000000000..85407745f --- /dev/null +++ b/tests/Unit/Property/AtRuleTest.php @@ -0,0 +1,35 @@ +subject); } + + /** + * @test + */ + public function getArrayRepresentationIncludesClassName(): void + { + $subject = new CSSNamespace(new CSSString('http://www.w3.org/2000/svg')); + + $result = $subject->getArrayRepresentation(); + + self::assertSame('CSSNamespace', $result['class']); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesUrlProvidedAsCssString(): void + { + $uri = 'http://www.w3.org/2000/svg'; + $subject = new CSSNamespace(new CSSString($uri)); + + $result = $subject->getArrayRepresentation(); + + self::assertSame( + [ + 'class' => 'CSSString', + 'contents' => $uri, + ], + $result['uri'] + ); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesUrlProvidedAsUrl(): void + { + $uri = 'http://www.w3.org/2000/svg'; + $subject = new CSSNamespace(new URL(new CSSString($uri))); + + $result = $subject->getArrayRepresentation(); + + self::assertSame( + [ + 'class' => 'URL', + 'uri' => [ + 'class' => 'CSSString', + 'contents' => $uri, + ], + ], + $result['uri'] + ); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesPrefixForNullPrefix(): void + { + $subject = new CSSNamespace(new CSSString('http://www.w3.org/2000/svg'), null); + + $result = $subject->getArrayRepresentation(); + + self::assertNull($result['prefix']); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesPrefixForStringPrefix(): void + { + $prefix = 'hello'; + + $subject = new CSSNamespace(new CSSString('http://www.w3.org/2000/svg'), $prefix); + + $result = $subject->getArrayRepresentation(); + + self::assertSame($prefix, $result['prefix']); + } } diff --git a/tests/Unit/Property/CharsetTest.php b/tests/Unit/Property/CharsetTest.php index e0645f5ef..bdbc1994a 100644 --- a/tests/Unit/Property/CharsetTest.php +++ b/tests/Unit/Property/CharsetTest.php @@ -31,4 +31,33 @@ public function implementsCSSListItem(): void { self::assertInstanceOf(CSSListItem::class, $this->subject); } + + /** + * @test + */ + public function getArrayRepresentationIncludesClassName(): void + { + $result = $this->subject->getArrayRepresentation(); + + self::assertSame('Charset', $result['class']); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesCharset(): void + { + $charset = 'iso-8859-15'; + $subject = new Charset(new CSSString($charset)); + + $result = $subject->getArrayRepresentation(); + + self::assertSame( + [ + 'class' => 'CSSString', + 'contents' => $charset, + ], + $result['charset'] + ); + } } diff --git a/tests/Unit/Rule/RuleTest.php b/tests/Unit/Property/DeclarationTest.php similarity index 63% rename from tests/Unit/Rule/RuleTest.php rename to tests/Unit/Property/DeclarationTest.php index 008bcfc18..8344bb879 100644 --- a/tests/Unit/Rule/RuleTest.php +++ b/tests/Unit/Property/DeclarationTest.php @@ -2,28 +2,28 @@ declare(strict_types=1); -namespace Sabberworm\CSS\Tests\Unit\Rule; +namespace Sabberworm\CSS\Tests\Unit\Property; use PHPUnit\Framework\TestCase; use Sabberworm\CSS\CSSElement; use Sabberworm\CSS\Parsing\ParserState; -use Sabberworm\CSS\Rule\Rule; +use Sabberworm\CSS\Property\Declaration; use Sabberworm\CSS\Settings; use Sabberworm\CSS\Value\RuleValueList; use Sabberworm\CSS\Value\Value; use Sabberworm\CSS\Value\ValueList; /** - * @covers \Sabberworm\CSS\Rule\Rule + * @covers \Sabberworm\CSS\Property\Declaration */ -final class RuleTest extends TestCase +final class DeclarationTest extends TestCase { /** * @test */ public function implementsCSSElement(): void { - $subject = new Rule('beverage-container'); + $subject = new Declaration('beverage-container'); self::assertInstanceOf(CSSElement::class, $subject); } @@ -31,7 +31,7 @@ public function implementsCSSElement(): void /** * @return array}> */ - public static function provideRulesAndExpectedParsedValueListTypes(): array + public static function provideDeclarationsAndExpectedParsedValueListTypes(): array { return [ 'src (e.g. in @font-face)' => [ @@ -49,11 +49,11 @@ public static function provideRulesAndExpectedParsedValueListTypes(): array * * @param list $expectedTypeClassnames * - * @dataProvider provideRulesAndExpectedParsedValueListTypes + * @dataProvider provideDeclarationsAndExpectedParsedValueListTypes */ - public function parsesValuesIntoExpectedTypeList(string $rule, array $expectedTypeClassnames): void + public function parsesValuesIntoExpectedTypeList(string $declaration, array $expectedTypeClassnames): void { - $subject = Rule::parse(new ParserState($rule, Settings::create())); + $subject = Declaration::parse(new ParserState($declaration, Settings::create())); $value = $subject->getValue(); self::assertInstanceOf(ValueList::class, $value); @@ -70,4 +70,16 @@ static function ($component): string { self::assertSame($expectedTypeClassnames, $actualClassnames); } + + /** + * @test + */ + public function getArrayRepresentationThrowsException(): void + { + $this->expectException(\BadMethodCallException::class); + + $subject = new Declaration('todo'); + + $subject->getArrayRepresentation(); + } } diff --git a/tests/Unit/Property/ImportTest.php b/tests/Unit/Property/ImportTest.php index 4ec028e3f..77a4f1362 100644 --- a/tests/Unit/Property/ImportTest.php +++ b/tests/Unit/Property/ImportTest.php @@ -32,4 +32,40 @@ public function implementsCSSListItem(): void { self::assertInstanceOf(CSSListItem::class, $this->subject); } + + /** + * @test + */ + public function getArrayRepresentationIncludesClassName(): void + { + $subject = new Import(new URL(new CSSString('https://example.org/')), null); + + $result = $subject->getArrayRepresentation(); + + self::assertSame('Import', $result['class']); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesUri(): void + { + $uri = 'https://example.com'; + $url = new URL(new CSSString($uri)); + + $subject = new Import($url, null); + + $result = $subject->getArrayRepresentation(); + + self::assertSame( + [ + 'class' => 'URL', + 'uri' => [ + 'class' => 'CSSString', + 'contents' => $uri, + ], + ], + $result['uri'] + ); + } } diff --git a/tests/Unit/Property/KeyframeSelectorTest.php b/tests/Unit/Property/KeyframeSelectorTest.php new file mode 100644 index 000000000..e73bf2323 --- /dev/null +++ b/tests/Unit/Property/KeyframeSelectorTest.php @@ -0,0 +1,40 @@ +getArrayRepresentation(); + + self::assertSame('KeyframeSelector', $result['class']); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesComponent(): void + { + $subject = new KeyframeSelector([new CompoundSelector('50%')]); + + $result = $subject->getArrayRepresentation(); + + self::assertSame('50%', $result['components'][0]['value']); + } +} diff --git a/tests/Unit/Property/Selector/CombinatorTest.php b/tests/Unit/Property/Selector/CombinatorTest.php new file mode 100644 index 000000000..e416678d1 --- /dev/null +++ b/tests/Unit/Property/Selector/CombinatorTest.php @@ -0,0 +1,347 @@ +')); + } + + /** + * @test + */ + public function implementsSelectorComponent(): void + { + self::assertInstanceOf(Component::class, new Combinator('>')); + } + + /** + * @return array + */ + public static function provideValidValueAndDefaultRendering(): array + { + return [ + 'descendent' => [' ', ' '], + 'child' => ['>', ' > '], + 'next sibling' => ['+', ' + '], + 'subsequent sibling' => ['~', ' ~ '], + ]; + } + + /** + * @return array + */ + public static function provideInvalidValue(): array + { + return [ + 'other symbol' => ['@'], + 'uppercase letter' => ['Z'], + 'lowercase letter' => ['j'], + 'number' => ['1'], + 'sequence' => ['abc?123'], + ]; + } + + /** + * @test + * + * @param ValidCombinatorValue $combinator + * + * @dataProvider provideValidValueAndDefaultRendering + */ + public function parsesValidCombinator(string $combinator): void + { + $result = Combinator::parse(new ParserState($combinator, Settings::create())); + + self::assertSame($combinator, $result->getArrayRepresentation()['value']); + } + + /** + * @test + * + * @param non-empty-string $selectorComponent + * + * @dataProvider provideInvalidValue + */ + public function parseThrowsExceptionWithInvalidCombinator(string $selectorComponent): void + { + $this->expectException(UnexpectedTokenException::class); + + Combinator::parse(new ParserState($selectorComponent, Settings::create())); + } + + /** + * @test + * + * @param ValidCombinatorValue $combinator + * + * @dataProvider provideValidValueAndDefaultRendering + */ + public function parsesCombinatorWithCommentBefore(string $combinator): void + { + $result = Combinator::parse(new ParserState('/*comment*/' . $combinator, Settings::create())); + + self::assertSame($combinator, $result->getArrayRepresentation()['value']); + } + + /** + * @test + * + * @param ValidCombinatorValue $combinator + * + * @dataProvider provideValidValueAndDefaultRendering + */ + public function parsesCombinatorWithCommentAfter(string $combinator): void + { + $result = Combinator::parse(new ParserState($combinator . '/*comment*/', Settings::create())); + + self::assertSame($combinator, $result->getArrayRepresentation()['value']); + } + + /** + * @test + * + * @param ValidCombinatorValue $combinator + * + * @dataProvider provideValidValueAndDefaultRendering + */ + public function parseExtractsCommentBefore(string $combinator): void + { + $result = []; + Combinator::parse(new ParserState('/*comment*/' . $combinator, Settings::create()), $result); + + self::assertSame('comment', $result[0]->getArrayRepresentation()['contents']); + } + + /** + * @test + * + * @param ValidCombinatorValue $combinator + * + * @dataProvider provideValidValueAndDefaultRendering + */ + public function parseExtractsCommentAfter(string $combinator): void + { + $result = []; + Combinator::parse(new ParserState($combinator . '/*comment*/', Settings::create()), $result); + + self::assertSame('comment', $result[0]->getArrayRepresentation()['contents']); + } + + /** + * @test + * + * @param ValidCombinatorValue $value + * + * @dataProvider provideValidValueAndDefaultRendering + */ + public function constructsWithValueProvided(string $value): void + { + $subject = new Combinator($value); + + self::assertSame($value, $subject->getArrayRepresentation()['value']); + } + + /** + * @test + * + * @param non-empty-string $value + * + * @dataProvider provideInvalidValue + */ + public function constructorThrowsExceptionWithInvalidValue(string $value): void + { + $this->expectException(\UnexpectedValueException::class); + + new Combinator($value); + } + + /** + * @test + * + * @param ValidCombinatorValue $value + * + * @dataProvider provideValidValueAndDefaultRendering + */ + public function setValueSetsValueProvided(string $value): void + { + $subject = new Combinator('>'); + + $subject->setValue($value); + + self::assertSame($value, $subject->getArrayRepresentation()['value']); + } + + /** + * @test + * + * @param non-empty-string $value + * + * @dataProvider provideInvalidValue + */ + public function setValueThrowsExceptionWithInvalidValue(string $value): void + { + $this->expectException(\UnexpectedValueException::class); + + $subject = new Combinator('>'); + + $subject->setValue($value); + } + + /** + * @test + * + * @param ValidCombinatorValue $value + * + * @dataProvider provideValidValueAndDefaultRendering + */ + public function getValueReturnsValueProvidedToConstructor(string $value): void + { + $subject = new Combinator($value); + + $result = $subject->getValue(); + + self::assertSame($value, $result); + } + + /** + * @test + * + * @param ValidCombinatorValue $value + * + * @dataProvider provideValidValueAndDefaultRendering + */ + public function getValueReturnsValueProvidedToSetValue(string $value): void + { + $subject = new Combinator('>'); + $subject->setValue($value); + + $result = $subject->getValue(); + + self::assertSame($value, $result); + } + + /** + * @test + * + * @param ValidCombinatorValue $value + * + * @dataProvider provideValidValueAndDefaultRendering + */ + public function hasNoSpecificity(string $value): void + { + $subject = new Combinator($value); + + self::assertSame(0, $subject->getSpecificity()); + } + + /** + * @test + * + * @param ValidCombinatorValue $value + * @param non-empty-string $expectedRendering + * + * @dataProvider provideValidValueAndDefaultRendering + */ + public function renderReturnsDefaultRenderingForValueProvided(string $value, string $expectedRendering): void + { + $subject = new Combinator($value); + + self::assertSame($expectedRendering, $subject->render(OutputFormat::create())); + } + + /** + * @return array + */ + public static function provideSpacing(): array + { + return [ + 'empty string' => [''], + 'space' => [' '], + 'newline' => ["\n"], + 'carriage return' => ["\r"], + 'tab' => ["\t"], + ]; + } + + /** + * @test + * + * @dataProvider provideSpacing + */ + public function renderIncludesSpacingSetInOutputFormat(string $spacing): void + { + $subject = new Combinator('>'); + $outputFormat = (OutputFormat::create())->setSpaceAroundSelectorCombinator($spacing); + + $result = $subject->render($outputFormat); + + $expectedRendering = $spacing . '>' . $spacing; + self::assertSame($expectedRendering, $result); + } + + /** + * @test + * + * @dataProvider provideSpacing + */ + public function renderReturnsSpacingSetInOutputFormatForDescendentCombinatorOrSpaceIfNeeded(string $spacing): void + { + $subject = new Combinator(' '); + $outputFormat = (OutputFormat::create())->setSpaceAroundSelectorCombinator($spacing); + + $result = $subject->render($outputFormat); + + $expectedRendering = $spacing !== '' ? $spacing : ' '; + self::assertSame($expectedRendering, $result); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesClassName(): void + { + $subject = new Combinator('>'); + + $result = $subject->getArrayRepresentation(); + + self::assertSame('Combinator', $result['class']); + } + + /** + * @test + * + * @param ValidCombinatorValue $value + * + * @dataProvider provideValidValueAndDefaultRendering + */ + public function getArrayRepresentationIncludesValue(string $value): void + { + $subject = new Combinator($value); + + $result = $subject->getArrayRepresentation(); + + self::assertSame($value, $result['value']); + } +} diff --git a/tests/Unit/Property/Selector/CompoundSelectorTest.php b/tests/Unit/Property/Selector/CompoundSelectorTest.php new file mode 100644 index 000000000..b42c76f15 --- /dev/null +++ b/tests/Unit/Property/Selector/CompoundSelectorTest.php @@ -0,0 +1,517 @@ +}> + */ + public static function provideCompoundSelectorAndSpecificity(): array + { + return [ + 'type' => ['a', 1], + 'class' => ['.highlighted', 10], + 'type with class' => ['li.green', 11], + 'pseudo-class' => [':hover', 10], + 'type with pseudo-class' => ['a:hover', 11], + 'class with pseudo-class' => ['.help:hover', 20], + 'ID' => ['#file', 100], + 'type with ID' => ['h2#my-mug', 101], + 'pseudo-element' => ['::before', 1], + 'type with pseudo-element' => ['li::before', 2], + '`not`' => [':not(#your-mug)', 100], + // TODO, broken: The specificity should be the highest of the `:not` arguments, not the sum. + '`not` with multiple arguments' => [':not(#your-mug, .their-mug)', 110], + 'attribute with `"`' => ['[alt="{}()[]\\"\',"]', 10], + 'attribute with `\'`' => ['[alt=\'{}()[]"\\\',\']', 10], + // TODO, broken: specificity should be 11, but the calculator doesn't realize the `#` is in a string. + 'attribute with `^=`' => ['a[href^="#"]', 111], + 'attribute with `*=`' => ['a[href*="example"]', 11], + // TODO, broken: specificity should be 11, but the calculator doesn't realize the `.` is in a string. + 'attribute with `$=`' => ['a[href$=".org"]', 21], + 'attribute with `~=`' => ['span[title~="bonjour"]', 11], + 'attribute with `|=`' => ['[lang|="en"]', 10], + // TODO, broken: specificity should be 11, but the calculator doesn't realize the `i` is in an attribute. + 'attribute with case insensitive modifier' => ['a[href*="insensitive" i]', 12], + // TODO, broken: specificity should be 21, but the calculator doesn't realize the `.` is in a string. + 'multiple attributes' => ['a[href^="https://"][href$=".org"]', 31], + // TODO, broken: specificity should be 11, but the calculator is treating the `n` as a type selector. + 'nth-last-child' => ['li:nth-last-child(2n+3)', 12], + // TODO, maybe broken: specificity should probably be 2 (1 for `p` and 1 for `span`). + '`not` with descendent combinator' => [':not(p span)', 1], + // TODO, maybe broken: specificity should probably be 2 (1 for `p` and 1 for `span`). + '`not` with child combinator' => [':not(p > span)', 1], + // TODO, maybe broken: specificity should probably be 2 (1 for `h1` and 1 for `p`). + '`not` with next-sibling combinator' => [':not(h1 + p)', 1], + // TODO, maybe broken: specificity should probably be 2 (1 for `h1` and 1 for `p`). + '`not` with subsequent-sibling combinator' => [':not(h1 ~ p)', 1], + ]; + } + + /** + * @return array + */ + public static function provideCompoundSelectorWithEscapedQuotes(): array + { + return [ + 'escaped double quote in double-quoted attribute' => ['a[href="test\\"value"]'], + 'escaped single quote in single-quoted attribute' => ["a[href='test\\'value']"], + 'multiple escaped double quotes in double-quoted attribute' => ['a[title="say \\"hello\\" world"]'], + 'multiple escaped single quotes in single-quoted attribute' => ["a[title='say \\'hello\\' world']"], + 'escaped quote at start of attribute value' => ['a[data-test="\\"start"]'], + 'escaped quote at end of attribute value' => ['a[data-test="end\\""]'], + 'escaped backslash followed by quote' => ['a[data-test="test\\\\"]'], + 'escaped backslash before escaped quote' => ['a[data-test="test\\\\\\"value"]'], + 'triple backslash before quote' => ['a[data-test="test\\\\\\""]'], + 'escaped single quotes in selector itself, with other escaped characters' + => [".before\\:content-\\[\\'\\'\\]:before"], + 'escaped double quotes in selector itself, with other escaped characters' + => ['.before\\:content-\\[\\"\\"\\]:before'], + ]; + } + + /** + * @test + * + * @param non-empty-string $compoundSelector + * + * @dataProvider provideCompoundSelectorAndSpecificity + * @dataProvider provideCompoundSelectorWithEscapedQuotes + */ + public function parsesValidCompoundSelector(string $compoundSelector): void + { + $result = CompoundSelector::parse(new ParserState($compoundSelector, Settings::create())); + + self::assertInstanceOf(CompoundSelector::class, $result); + self::assertSame($compoundSelector, $result->getValue()); + } + + /** + * @test + */ + public function parsingAttributeWithEscapedQuoteDoesNotPrematurelyCloseString(): void + { + $compoundSelector = 'input[placeholder="Enter \\"quoted\\" text here"]'; + + $result = CompoundSelector::parse(new ParserState($compoundSelector, Settings::create())); + + self::assertInstanceOf(CompoundSelector::class, $result); + self::assertSame($compoundSelector, $result->getValue()); + } + + /** + * @test + */ + public function parseDistinguishesEscapedFromUnescapedQuotes(): void + { + // One backslash = escaped quote (should not close string) + $compoundSelector = 'a[data-value="test\\"more"]'; + + $result = CompoundSelector::parse(new ParserState($compoundSelector, Settings::create())); + + self::assertSame($compoundSelector, $result->getValue()); + } + + /** + * @test + */ + public function parseHandlesEvenNumberOfBackslashesBeforeQuote(): void + { + // Two backslashes = escaped backslash + unescaped quote (should close string) + $compoundSelector = 'a[data-value="test\\\\"]'; + + $result = CompoundSelector::parse(new ParserState($compoundSelector, Settings::create())); + + self::assertSame($compoundSelector, $result->getValue()); + } + + /** + * @return array + */ + public static function provideInvalidCompoundSelector(): array + { + return [ + 'empty string' => [''], + 'space' => [' '], + 'tab' => ["\t"], + 'line feed' => ["\n"], + 'carriage return' => ["\r"], + 'percent sign' => ['%'], + // This is currently broken. + // 'hash only' => ['#'], + // This is currently broken. + // 'dot only' => ['.'], + 'slash' => ['/'], + 'less-than sign' => ['<'], + 'with space before' => [' a:hover'], + ]; + } + + /** + * The validation checks on `setValue()` are not able to pick up these, because it does not perform any parsing. + * + * @return array + */ + public static function provideInvalidCompoundSelectorForParse(): array + { + return [ + 'a `:not` missing the closing brace' => [':not(a'], + 'a `:not` missing the opening brace' => [':nota)'], + 'attribute value missing closing single quote' => ["a[href='#top]"], + 'attribute value missing closing double quote' => ['a[href="#top]'], + 'attribute value with mismatched quotes, single quote opening' => ['a[href=\'#top"]'], + 'attribute value with mismatched quotes, double quote opening' => ['a[href="#top\']'], + 'attribute value with extra `[`' => ['a[[href="#top"]'], + 'attribute value with extra `]`' => ['a[href="#top"]]'], + ]; + } + + /** + * This is valid in a parsing context, but not when passed to `setValue()` or the constructor. + * + * @return array + */ + public static function provideInvalidCompoundSelectorForSetValue(): array + { + return [ + 'with space after' => ['a:hover '], + ]; + } + + /** + * @test + * + * @param non-empty-string $compoundSelector + * + * @dataProvider provideInvalidCompoundSelector + * @dataProvider provideInvalidCompoundSelectorForParse + */ + public function parseThrowsExceptionWithInvalidCompoundSelector(string $compoundSelector): void + { + $this->expectException(UnexpectedTokenException::class); + + CompoundSelector::parse(new ParserState($compoundSelector, Settings::create())); + } + + /** + * @return array + */ + public static function provideStopCharacters(): array + { + return [ + ',' => [','], + '{' => ['{'], + '}' => ['}'], + 'space' => [' '], + 'tab' => ["\t"], + 'line feed' => ["\n"], + 'carriage return' => ["\r"], + 'child combinator' => ['>'], + 'next-sibling combinator' => ['+'], + 'subsequent-sibling combinator' => ['~'], + ]; + } + + /** + * @return DataProvider + */ + public function provideStopCharactersAndValidCompoundSelector(): DataProvider + { + return DataProvider::cross(self::provideStopCharacters(), self::provideCompoundSelectorAndSpecificity()); + } + + /** + * @test + * + * @param non-empty-string $stopCharacter + * @param non-empty-string $compoundSelector + * + * @dataProvider provideStopCharactersAndValidCompoundSelector + */ + public function parseDoesNotConsumeStopCharacter(string $stopCharacter, string $compoundSelector): void + { + $subject = new ParserState($compoundSelector . $stopCharacter, Settings::create()); + + CompoundSelector::parse($subject); + + self::assertSame($stopCharacter, $subject->peek()); + } + + /** + * @return array + */ + public static function provideCompoundSelectorWithAndWithoutComment(): array + { + return [ + 'comment before' => ['/*comment*/body.page', 'body.page'], + 'comment after' => ['body.page/*comment*/', 'body.page'], + 'comment within' => ['p./*comment*/teapot', 'p.teapot'], + 'comment within function' => ['p:not(#your-mug,/*comment*/.their-mug)', 'p:not(#your-mug,.their-mug)'], + ]; + } + + /** + * @test + * + * @param non-empty-string $compoundSelectorWith + * @param non-empty-string $compoundSelectorWithout + * + * @dataProvider provideCompoundSelectorWithAndWithoutComment + */ + public function parsesCompoundSelectorWithComment( + string $compoundSelectorWith, + string $compoundSelectorWithout + ): void { + $result = CompoundSelector::parse(new ParserState($compoundSelectorWith, Settings::create())); + + self::assertInstanceOf(CompoundSelector::class, $result); + self::assertSame($compoundSelectorWithout, $result->getValue()); + } + + /** + * @test + * + * @param non-empty-string $compoundSelector + * + * @dataProvider provideCompoundSelectorWithAndWithoutComment + */ + public function parseExtractsCommentFromCompoundSelector(string $compoundSelector): void + { + $result = []; + CompoundSelector::parse(new ParserState($compoundSelector, Settings::create()), $result); + + self::assertInstanceOf(Comment::class, $result[0]); + self::assertSame('comment', $result[0]->getComment()); + } + + /** + * @test + */ + public function parsesCompoundSelectorWithTwoComments(): void + { + $result = CompoundSelector::parse(new ParserState('/*comment1*/a:hover/*comment2*/', Settings::create())); + + self::assertInstanceOf(CompoundSelector::class, $result); + self::assertSame('a:hover', $result->getValue()); + } + + /** + * @test + */ + public function parseExtractsTwoCommentsFromCompoundSelector(): void + { + $result = []; + CompoundSelector::parse(new ParserState('/*comment1*/a:hover/*comment2*/', Settings::create()), $result); + + self::assertInstanceOf(Comment::class, $result[0]); + self::assertSame('comment1', $result[0]->getComment()); + self::assertInstanceOf(Comment::class, $result[1]); + self::assertSame('comment2', $result[1]->getComment()); + } + + /** + * @test + * + * @param non-empty-string $value + * + * @dataProvider provideCompoundSelectorAndSpecificity + */ + public function constructsWithValueProvided(string $value): void + { + $subject = new CompoundSelector($value); + + self::assertSame($value, $subject->getArrayRepresentation()['value']); + } + + /** + * @test + * + * @param non-empty-string $value + * + * @dataProvider provideInvalidCompoundSelector + * @dataProvider provideInvalidCompoundSelectorForSetValue + */ + public function constructorThrowsExceptionWithInvalidValue(string $value): void + { + $this->expectException(\UnexpectedValueException::class); + + new CompoundSelector($value); + } + + /** + * @test + * + * @param non-empty-string $value + * + * @dataProvider provideCompoundSelectorAndSpecificity + */ + public function setValueSetsValueProvided(string $value): void + { + $subject = new CompoundSelector('p.intro'); + + $subject->setValue($value); + + self::assertSame($value, $subject->getArrayRepresentation()['value']); + } + + /** + * @test + * + * @param non-empty-string $value + * + * @dataProvider provideInvalidCompoundSelector + * @dataProvider provideInvalidCompoundSelectorForSetValue + */ + public function setValueThrowsExceptionWithInvalidValue(string $value): void + { + $this->expectException(\UnexpectedValueException::class); + + $subject = new CompoundSelector('p.intro'); + + $subject->setValue($value); + } + + /** + * @test + * + * @param non-empty-string $value + * + * @dataProvider provideCompoundSelectorAndSpecificity + */ + public function getValueReturnsValueProvidedToConstructor(string $value): void + { + $subject = new CompoundSelector($value); + + $result = $subject->getValue(); + + self::assertSame($value, $result); + } + + /** + * @test + * + * @param non-empty-string $value + * + * @dataProvider provideCompoundSelectorAndSpecificity + */ + public function getValueReturnsValueProvidedToSetValue(string $value): void + { + $subject = new CompoundSelector('p.intro'); + $subject->setValue($value); + + $result = $subject->getValue(); + + self::assertSame($value, $result); + } + + /** + * @test + * + * @param non-empty-string $compoundSelector + * @param int<0, max> $expectedSpecificity + * + * @dataProvider provideCompoundSelectorAndSpecificity + */ + public function getSpecificityByDefaultReturnsSpecificityOfCompoundSelectorProvidedToConstructor( + string $compoundSelector, + int $expectedSpecificity + ): void { + $subject = new CompoundSelector($compoundSelector); + + self::assertSame($expectedSpecificity, $subject->getSpecificity()); + } + + /** + * @test + * + * @param non-empty-string $compoundSelector + * @param int<0, max> $expectedSpecificity + * + * @dataProvider provideCompoundSelectorAndSpecificity + */ + public function getSpecificityReturnsSpecificityOfCompoundSelectorLastProvidedViaSetValue( + string $compoundSelector, + int $expectedSpecificity + ): void { + $subject = new CompoundSelector('p.intro'); + + $subject->setValue($compoundSelector); + + self::assertSame($expectedSpecificity, $subject->getSpecificity()); + } + + /** + * @test + * + * @param non-empty-string $value + * + * @dataProvider provideCompoundSelectorAndSpecificity + */ + public function renderReturnsValueProvided(string $value): void + { + $subject = new CompoundSelector($value); + + self::assertSame($value, $subject->render(OutputFormat::create())); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesClassName(): void + { + $subject = new CompoundSelector('a:hover'); + + $result = $subject->getArrayRepresentation(); + + self::assertSame('CompoundSelector', $result['class']); + } + + /** + * @test + * + * @param non-empty-string $value + * + * @dataProvider provideCompoundSelectorAndSpecificity + */ + public function getArrayRepresentationIncludesValue(string $value): void + { + $subject = new CompoundSelector($value); + + $result = $subject->getArrayRepresentation(); + + self::assertSame($value, $result['value']); + } +} diff --git a/tests/Unit/Property/SelectorTest.php b/tests/Unit/Property/SelectorTest.php index e53e8274d..d22190da7 100644 --- a/tests/Unit/Property/SelectorTest.php +++ b/tests/Unit/Property/SelectorTest.php @@ -5,8 +5,16 @@ namespace Sabberworm\CSS\Tests\Unit\Property; use PHPUnit\Framework\TestCase; +use Sabberworm\CSS\Comment\Comment; +use Sabberworm\CSS\Parsing\ParserState; +use Sabberworm\CSS\Parsing\UnexpectedTokenException; use Sabberworm\CSS\Property\Selector; +use Sabberworm\CSS\Property\Selector\Combinator; +use Sabberworm\CSS\Property\Selector\Component; +use Sabberworm\CSS\Property\Selector\CompoundSelector; use Sabberworm\CSS\Renderable; +use Sabberworm\CSS\Settings; +use TRegx\PhpUnit\DataProviders\DataProvider; /** * @covers \Sabberworm\CSS\Property\Selector @@ -48,24 +56,416 @@ public function setSelectorOverwritesSelectorProvidedToConstructor(): void } /** - * @return array}> + * @return array}> */ public static function provideSelectorsAndSpecificities(): array { return [ - 'element' => ['a', 1], - 'element and descendant with pseudo-selector' => ['ol li::before', 3], + 'type' => ['a', 1], 'class' => ['.highlighted', 10], - 'element with class' => ['li.green', 11], - 'class with pseudo-selector' => ['.help:hover', 20], + 'type with class' => ['li.green', 11], + 'pseudo-class' => [':hover', 10], + 'type with pseudo-class' => ['a:hover', 11], + 'class with pseudo-class' => ['.help:hover', 20], 'ID' => ['#file', 100], - 'ID and descendant class' => ['#test .help', 110], + 'ID and descendent class' => ['#test .help', 110], + 'type with ID' => ['h2#my-mug', 101], + 'pseudo-element' => ['::before', 1], + 'type with pseudo-element' => ['li::before', 2], + 'type and descendent type with pseudo-element' => ['ol li::before', 3], '`not`' => [':not(#your-mug)', 100], // TODO, broken: The specificity should be the highest of the `:not` arguments, not the sum. '`not` with multiple arguments' => [':not(#your-mug, .their-mug)', 110], + 'attribute with `"`' => ['[alt="{}()[]\\"\',"]', 10], + 'attribute with `\'`' => ['[alt=\'{}()[]"\\\',\']', 10], + ]; + } + + /** + * @return array + */ + public static function provideSelectorsWithEscapedQuotes(): array + { + return [ + 'escaped double quote in double-quoted attribute' => ['a[href="test\\"value"]'], + 'escaped single quote in single-quoted attribute' => ['a[href=\'test\\\'value\']'], + 'multiple escaped double quotes in double-quoted attribute' => ['a[title="say \\"hello\\" world"]'], + 'multiple escaped single quotes in single-quoted attribute' => ['a[title=\'say \\\'hello\\\' world\']'], + 'escaped quote at start of attribute value' => ['a[data-test="\\"start"]'], + 'escaped quote at end of attribute value' => ['a[data-test="end\\""]'], + 'escaped backslash followed by quote' => ['a[data-test="test\\\\"]'], + 'escaped backslash before escaped quote' => ['a[data-test="test\\\\\\"value"]'], + 'triple backslash before quote' => ['a[data-test="test\\\\\\""]'], + 'escaped single quotes in selector itself, with other escaped characters' + => ['.before\\:content-\\[\\\'\\\'\\]:before'], + 'escaped double quotes in selector itself, with other escaped characters' + => ['.before\\:content-\\[\\"\\"\\]:before'], ]; } + /** + * @test + * + * @param non-empty-string $selector + * + * @dataProvider provideSelectorsAndSpecificities + * @dataProvider provideSelectorsWithEscapedQuotes + */ + public function parsesValidSelector(string $selector): void + { + $result = Selector::parse(new ParserState($selector, Settings::create())); + + self::assertInstanceOf(Selector::class, $result); + self::assertSame($selector, $result->getSelector()); + } + + /** + * @test + */ + public function parsingAttributeWithEscapedQuoteDoesNotPrematurelyCloseString(): void + { + $selector = 'input[placeholder="Enter \\"quoted\\" text here"]'; + + $result = Selector::parse(new ParserState($selector, Settings::create())); + + self::assertInstanceOf(Selector::class, $result); + self::assertSame($selector, $result->getSelector()); + } + + /** + * @test + */ + public function parseDistinguishesEscapedFromUnescapedQuotes(): void + { + // One backslash = escaped quote (should not close string) + $selector = 'a[data-value="test\\"more"]'; + + $result = Selector::parse(new ParserState($selector, Settings::create())); + + self::assertSame($selector, $result->getSelector()); + } + + /** + * @test + */ + public function parseHandlesEvenNumberOfBackslashesBeforeQuote(): void + { + // Two backslashes = escaped backslash + unescaped quote (should close string) + $selector = 'a[data-value="test\\\\"]'; + + $result = Selector::parse(new ParserState($selector, Settings::create())); + + self::assertSame($selector, $result->getSelector()); + } + + /** + * @return array + */ + public static function provideInvalidSelectors(): array + { + return [ + 'empty string' => [''], + 'space' => [' '], + 'tab' => ["\t"], + 'line feed' => ["\n"], + 'carriage return' => ["\r"], + 'percent sign' => ['%'], + // This is currently broken. + // 'hash only' => ['#'], + // This is currently broken. + // 'dot only' => ['.'], + 'slash' => ['/'], + 'less-than sign' => ['<'], + ]; + } + + /** + * @return array + */ + public static function provideInvalidSelectorsForParse(): array + { + return [ + 'a `:not` missing the closing brace' => [':not(a'], + 'a `:not` missing the opening brace' => [':not a)'], + 'attribute value missing closing single quote' => ['a[href=\'#top]'], + 'attribute value missing closing double quote' => ['a[href="#top]'], + 'attribute value with mismatched quotes, single quote opening' => ['a[href=\'#top"]'], + 'attribute value with mismatched quotes, double quote opening' => ['a[href="#top\']'], + ]; + } + + /** + * @test + * + * @dataProvider provideInvalidSelectors + * @dataProvider provideInvalidSelectorsForParse + */ + public function parseThrowsExceptionWithInvalidSelector(string $selector): void + { + $this->expectException(UnexpectedTokenException::class); + + Selector::parse(new ParserState($selector, Settings::create())); + } + + /** + * @return array + */ + public static function provideStopCharacters(): array + { + return [ + ',' => [','], + '{' => ['{'], + '}' => ['}'], + ]; + } + + /** + * @return DataProvider + */ + public function provideStopCharactersAndValidSelectors(): DataProvider + { + return DataProvider::cross(self::provideStopCharacters(), self::provideSelectorsAndSpecificities()); + } + + /** + * @test + * + * @param non-empty-string $stopCharacter + * @param non-empty-string $selector + * + * @dataProvider provideStopCharactersAndValidSelectors + */ + public function parseDoesNotConsumeStopCharacter(string $stopCharacter, string $selector): void + { + $subject = new ParserState($selector . $stopCharacter, Settings::create()); + + Selector::parse($subject); + + self::assertSame($stopCharacter, $subject->peek()); + } + + /** + * @return array + */ + public static function provideSelectorsWithAndWithoutComment(): 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 provideSelectorsWithAndWithoutComment + */ + public function parsesSelectorWithComment(string $selectorWith, string $selectorWithout): void + { + $result = Selector::parse(new ParserState($selectorWith, Settings::create())); + + self::assertInstanceOf(Selector::class, $result); + self::assertSame($selectorWithout, $result->getSelector()); + } + + /** + * @test + * + * @param non-empty-string $selector + * + * @dataProvider provideSelectorsWithAndWithoutComment + */ + public function parseExtractsCommentFromSelector(string $selector): void + { + $result = []; + Selector::parse(new ParserState($selector, Settings::create()), $result); + + self::assertInstanceOf(Comment::class, $result[0]); + self::assertSame('comment', $result[0]->getComment()); + } + + /** + * @test + */ + public function parsesSelectorWithTwoComments(): void + { + $result = Selector::parse(new ParserState('/*comment1*/a/*comment2*/', Settings::create())); + + self::assertInstanceOf(Selector::class, $result); + self::assertSame('a', $result->getSelector()); + } + + /** + * @test + */ + public function parseExtractsTwoCommentsFromSelector(): void + { + $result = []; + Selector::parse(new ParserState('/*comment1*/a/*comment2*/', Settings::create()), $result); + + self::assertInstanceOf(Comment::class, $result[0]); + self::assertSame('comment1', $result[0]->getComment()); + self::assertInstanceOf(Comment::class, $result[1]); + self::assertSame('comment2', $result[1]->getComment()); + } + + /** + * @test + * + * @dataProvider provideInvalidSelectors + * @dataProvider provideInvalidSelectorsForParse + */ + public function constructorThrowsExceptionWithInvalidSelector(string $selector): void + { + $this->expectException(UnexpectedTokenException::class); + + new Selector($selector); + } + + /** + * @test + * + * @dataProvider provideInvalidSelectors + * @dataProvider provideInvalidSelectorsForParse + */ + public function setSelectorThrowsExceptionWithInvalidSelector(string $selector): void + { + $this->expectException(UnexpectedTokenException::class); + + $subject = new Selector('a'); + + $subject->setSelector($selector); + } + + /** + * @return array< + * non-empty-string, + * array{ + * 0: non-empty-list, + * 1: non-empty-list + * } + * > + */ + public static function provideComponentsAndArrayRepresentation(): array + { + return [ + 'simple selector' => [ + [new CompoundSelector('p')], + [ + [ + 'class' => 'CompoundSelector', + 'value' => 'p', + ], + ], + ], + 'selector with combinator' => [ + [ + new CompoundSelector('ul'), + new Combinator('>'), + new CompoundSelector('li'), + ], + [ + [ + 'class' => 'CompoundSelector', + 'value' => 'ul', + ], + [ + 'class' => 'Combinator', + 'value' => '>', + ], + [ + 'class' => 'CompoundSelector', + 'value' => 'li', + ], + ], + ], + ]; + } + + /** + * @test + * + * @param non-empty-list $components + * @param non-empty-list $expectedRepresenation + * + * @dataProvider provideComponentsAndArrayRepresentation + */ + public function constructsWithComponentsProvided(array $components, array $expectedRepresenation): void + { + $subject = new Selector($components); + + $representation = $subject->getArrayRepresentation()['components']; + self::assertSame($expectedRepresenation, $representation); + } + + /** + * @test + */ + public function setComponentsProvidesFluentInterface(): void + { + $subject = new Selector([new CompoundSelector('p')]); + + $result = $subject->setComponents([new CompoundSelector('li')]); + + self::assertSame($subject, $result); + } + + /** + * @test + * + * @param non-empty-list $components + * @param non-empty-list $expectedRepresenation + * + * @dataProvider provideComponentsAndArrayRepresentation + */ + public function setComponentsSetsComponentsProvided(array $components, array $expectedRepresenation): void + { + $subject = new Selector([new CompoundSelector('p')]); + + $subject->setComponents($components); + + $representation = $subject->getArrayRepresentation()['components']; + self::assertSame($expectedRepresenation, $representation); + } + + /** + * @test + * + * @param non-empty-list $components + * + * @dataProvider provideComponentsAndArrayRepresentation + */ + public function getComponentsReturnsComponentsProvidedToConstructor(array $components): void + { + $subject = new Selector($components); + + $result = $subject->getComponents(); + + self::assertSame($components, $result); + } + + /** + * @test + * + * @param non-empty-list $components + * + * @dataProvider provideComponentsAndArrayRepresentation + */ + public function getComponentsReturnsComponentsSet(array $components): void + { + $subject = new Selector([new CompoundSelector('p')]); + $subject->setComponents($components); + + $result = $subject->getComponents(); + + self::assertSame($components, $result); + } + /** * @test * @@ -106,6 +506,7 @@ public function getSpecificityReturnsSpecificityOfSelectorLastProvidedViaSetSele * @test * * @dataProvider provideSelectorsAndSpecificities + * @dataProvider provideSelectorsWithEscapedQuotes */ public function isValidForValidSelectorReturnsTrue(string $selector): void { @@ -113,32 +514,83 @@ public function isValidForValidSelectorReturnsTrue(string $selector): void } /** - * @return array + * @test + * + * @dataProvider provideInvalidSelectors */ - public static function provideInvalidSelectors(): array + public function isValidForInvalidSelectorReturnsFalse(string $selector): void { - return [ - // This is currently broken. - // 'empty string' => [''], - 'percent sign' => ['%'], - // This is currently broken. - // 'hash only' => ['#'], - // This is currently broken. - // 'dot only' => ['.'], - 'slash' => ['/'], - 'less-than sign' => ['<'], - // This is currently broken. - // 'whitespace only' => [" \t\n\r"], - ]; + self::assertFalse(Selector::isValid($selector)); } /** * @test - * - * @dataProvider provideInvalidSelectors */ - public function isValidForInvalidSelectorReturnsFalse(string $selector): void + public function cleansUpSpacesWithinSelector(): void { - self::assertFalse(Selector::isValid($selector)); + $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()); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesClassName(): void + { + $subject = new Selector([new CompoundSelector('p')]); + + $result = $subject->getArrayRepresentation(); + + self::assertSame('Selector', $result['class']); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesComponent(): void + { + $subject = new Selector([new CompoundSelector('p.test')]); + + $result = $subject->getArrayRepresentation(); + + self::assertSame('p.test', $result['components'][0]['value']); } } diff --git a/tests/Unit/RuleSet/AtRuleSetTest.php b/tests/Unit/RuleSet/AtRuleSetTest.php index 0e3e0c974..74c60a04a 100644 --- a/tests/Unit/RuleSet/AtRuleSetTest.php +++ b/tests/Unit/RuleSet/AtRuleSetTest.php @@ -30,4 +30,14 @@ public function implementsCSSListItem(): void { self::assertInstanceOf(CSSListItem::class, $this->subject); } + + /** + * @test + */ + public function getArrayRepresentationThrowsException(): void + { + $this->expectException(\BadMethodCallException::class); + + $this->subject->getArrayRepresentation(); + } } diff --git a/tests/Unit/RuleSet/DeclarationBlockTest.php b/tests/Unit/RuleSet/DeclarationBlockTest.php index 4b20e9fc8..39c40592d 100644 --- a/tests/Unit/RuleSet/DeclarationBlockTest.php +++ b/tests/Unit/RuleSet/DeclarationBlockTest.php @@ -5,12 +5,14 @@ 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; +use Sabberworm\CSS\Parsing\UnexpectedTokenException; use Sabberworm\CSS\Position\Positionable; +use Sabberworm\CSS\Property\Declaration; use Sabberworm\CSS\Property\Selector; -use Sabberworm\CSS\Rule\Rule; use Sabberworm\CSS\RuleSet\DeclarationBlock; use Sabberworm\CSS\RuleSet\RuleSet; use Sabberworm\CSS\Settings; @@ -21,7 +23,7 @@ */ final class DeclarationBlockTest extends TestCase { - use RuleContainerTest; + use DeclarationListTests; /** * @var DeclarationBlock @@ -158,25 +160,110 @@ public function parsesTwoCommaSeparatedSelectors(string $firstSelector, string $ } /** - * @return array + * @return array */ - public static function provideInvalidSelector(): array + public static function provideSelectorWithAndWithoutComment(): array { - // TODO: the `parse` method consumes the first character without inspection, - // so the 'lone' test strings are prefixed with a space. return [ - 'lone `(`' => [' ('], - 'lone `)`' => [' )'], - 'unclosed `(`' => [':not(#your-mug'], - 'extra `)`' => [':not(#your-mug))'], + '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 + { + return [ + 'no selector' => ['', 'Token “selector” (literal) not found. Got “{”. [line no: 1]'], + '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 “{”. [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]'], + ]; + } + + /** + * @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 + * * @dataProvider provideInvalidSelector */ public function parseSkipsBlockWithInvalidSelector(string $selector): void @@ -192,16 +279,101 @@ public function parseSkipsBlockWithInvalidSelector(string $selector): void } /** - * @return array + * @test + * + * @param non-empty-string $expectedExceptionMessage + * + * @dataProvider provideInvalidSelectorAndExpectedExceptionMessage */ - private static function getSelectorsAsStrings(DeclarationBlock $declarationBlock): array + 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 + */ + public static function provideClosingBrace(): array { - return \array_map( - static function (Selector $selectorObject): string { - return $selectorObject->getSelector(); - }, - $declarationBlock->getSelectors() - ); + 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 + */ + 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()); } /** @@ -219,7 +391,7 @@ public function getRuleSetOnVirginReturnsARuleSet(): void */ public function getRuleSetAfterRulesSetReturnsARuleSet(): void { - $this->subject->setRules([new Rule('color')]); + $this->subject->setDeclarations([new Declaration('color')]); $result = $this->subject->getRuleSet(); @@ -229,11 +401,11 @@ public function getRuleSetAfterRulesSetReturnsARuleSet(): void /** * @test */ - public function getRuleSetOnVirginReturnsObjectWithoutRules(): void + public function getRuleSetOnVirginReturnsObjectWithoutDeclarations(): void { $result = $this->subject->getRuleSet(); - self::assertSame([], $result->getRules()); + self::assertSame([], $result->getDeclarations()); } /** @@ -243,14 +415,14 @@ public function getRuleSetOnVirginReturnsObjectWithoutRules(): void * * @dataProvider providePropertyNames */ - public function getRuleSetReturnsObjectWithRulesSet(array $propertyNamesToSet): void + public function getRuleSetReturnsObjectWithDeclarationsSet(array $propertyNamesToSet): void { - $rules = self::createRulesFromPropertyNames($propertyNamesToSet); - $this->subject->setRules($rules); + $declarations = self::createDeclarationsFromPropertyNames($propertyNamesToSet); + $this->subject->setDeclarations($declarations); $result = $this->subject->getRuleSet(); - self::assertSame($rules, $result->getRules()); + self::assertSame($declarations, $result->getDeclarations()); } /** @@ -278,4 +450,124 @@ 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)); + } + + /** + * @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); + } + + /** + * @test + */ + public function getArrayRepresentationThrowsException(): void + { + $this->expectException(\BadMethodCallException::class); + + $this->subject->getArrayRepresentation(); + } + + /** + * @return list + */ + 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() + ); + } } diff --git a/tests/Unit/RuleSet/DeclarationListTests.php b/tests/Unit/RuleSet/DeclarationListTests.php new file mode 100644 index 000000000..f831e6181 --- /dev/null +++ b/tests/Unit/RuleSet/DeclarationListTests.php @@ -0,0 +1,1200 @@ +subject); + } + + /** + * @return array}> + */ + public static function providePropertyNames(): array + { + return [ + 'no properties' => [[]], + 'one property' => [['color']], + 'two different properties' => [['color', 'display']], + 'two of the same property' => [['color', 'color']], + ]; + } + + /** + * @return array + */ + public static function provideAnotherPropertyName(): array + { + return [ + 'property name `color` maybe matching that of existing declaration' => ['color'], + 'property name `display` maybe matching that of existing declaration' => ['display'], + 'property name `width` not matching that of existing declaration' => ['width'], + ]; + } + + /** + * @return DataProvider, 1: string}> + */ + public static function provideInitialPropertyNamesAndAnotherPropertyName(): DataProvider + { + return DataProvider::cross(self::providePropertyNames(), self::provideAnotherPropertyName()); + } + + /** + * @test + * + * @param list $initialPropertyNames + * + * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName + */ + public function addDeclarationWithoutPositionWithoutSiblingAddsDeclarationAfterInitialDeclarations( + array $initialPropertyNames, + string $propertyNameToAdd + ): void { + $declarationToAdd = new Declaration($propertyNameToAdd); + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + + $this->subject->addDeclaration($declarationToAdd); + + $declarations = $this->subject->getDeclarations(); + self::assertSame($declarationToAdd, \end($declarations)); + } + + /** + * @test + * + * @param list $initialPropertyNames + * + * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName + */ + public function addDeclarationWithoutPositionWithoutSiblingSetsValidLineNumber( + array $initialPropertyNames, + string $propertyNameToAdd + ): void { + $declarationToAdd = new Declaration($propertyNameToAdd); + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + + $this->subject->addDeclaration($declarationToAdd); + + self::assertIsInt($declarationToAdd->getLineNumber(), 'line number not set'); + self::assertGreaterThanOrEqual(1, $declarationToAdd->getLineNumber(), 'line number not valid'); + } + + /** + * @test + * + * @param list $initialPropertyNames + * + * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName + */ + public function addDeclarationWithoutPositionWithoutSiblingSetsValidColumnNumber( + array $initialPropertyNames, + string $propertyNameToAdd + ): void { + $declarationToAdd = new Declaration($propertyNameToAdd); + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + + $this->subject->addDeclaration($declarationToAdd); + + self::assertIsInt($declarationToAdd->getColumnNumber(), 'column number not set'); + self::assertGreaterThanOrEqual(0, $declarationToAdd->getColumnNumber(), 'column number not valid'); + } + + /** + * @test + * + * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName + * + * @param list $initialPropertyNames + */ + public function addDeclarationWithOnlyLineNumberWithoutSiblingAddsDeclaration( + array $initialPropertyNames, + string $propertyNameToAdd + ): void { + $declarationToAdd = new Declaration($propertyNameToAdd); + $declarationToAdd->setPosition(42); + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + + $this->subject->addDeclaration($declarationToAdd); + + self::assertContains($declarationToAdd, $this->subject->getDeclarations()); + } + + /** + * @test + * + * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName + * + * @param list $initialPropertyNames + */ + public function addDeclarationWithOnlyLineNumberWithoutSiblingSetsColumnNumber( + array $initialPropertyNames, + string $propertyNameToAdd + ): void { + $declarationToAdd = new Declaration($propertyNameToAdd); + $declarationToAdd->setPosition(42); + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + + $this->subject->addDeclaration($declarationToAdd); + + self::assertIsInt($declarationToAdd->getColumnNumber(), 'column number not set'); + self::assertGreaterThanOrEqual(0, $declarationToAdd->getColumnNumber(), 'column number not valid'); + } + + /** + * @test + * + * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName + * + * @param list $initialPropertyNames + */ + public function addDeclarationWithOnlyLineNumberWithoutSiblingPreservesLineNumber( + array $initialPropertyNames, + string $propertyNameToAdd + ): void { + $declarationToAdd = new Declaration($propertyNameToAdd); + $declarationToAdd->setPosition(42); + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + + $this->subject->addDeclaration($declarationToAdd); + + self::assertSame(42, $declarationToAdd->getLineNumber(), 'line number not preserved'); + } + + /** + * @test + * + * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName + * + * @param list $initialPropertyNames + */ + public function addDeclarationWithOnlyColumnNumberWithoutSiblingAddsDeclarationAfterInitialDeclarations( + array $initialPropertyNames, + string $propertyNameToAdd + ): void { + $declarationToAdd = new Declaration($propertyNameToAdd); + $declarationToAdd->setPosition(null, 42); + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + + $this->subject->addDeclaration($declarationToAdd); + + $declarations = $this->subject->getDeclarations(); + self::assertSame($declarationToAdd, \end($declarations)); + } + + /** + * @test + * + * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName + * + * @param list $initialPropertyNames + */ + public function addDeclarationWithOnlyColumnNumberWithoutSiblingSetsLineNumber( + array $initialPropertyNames, + string $propertyNameToAdd + ): void { + $declarationToAdd = new Declaration($propertyNameToAdd); + $declarationToAdd->setPosition(null, 42); + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + + $this->subject->addDeclaration($declarationToAdd); + + self::assertIsInt($declarationToAdd->getLineNumber(), 'line number not set'); + self::assertGreaterThanOrEqual(1, $declarationToAdd->getLineNumber(), 'line number not valid'); + } + + /** + * @test + * + * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName + * + * @param list $initialPropertyNames + */ + public function addDeclarationWithOnlyColumnNumberWithoutSiblingPreservesColumnNumber( + array $initialPropertyNames, + string $propertyNameToAdd + ): void { + $declarationToAdd = new Declaration($propertyNameToAdd); + $declarationToAdd->setPosition(null, 42); + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + + $this->subject->addDeclaration($declarationToAdd); + + self::assertSame(42, $declarationToAdd->getColumnNumber(), 'column number not preserved'); + } + + /** + * @test + * + * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName + * + * @param list $initialPropertyNames + */ + public function addDeclarationWithCompletePositionWithoutSiblingAddsDeclaration( + array $initialPropertyNames, + string $propertyNameToAdd + ): void { + $declarationToAdd = new Declaration($propertyNameToAdd); + $declarationToAdd->setPosition(42, 64); + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + + $this->subject->addDeclaration($declarationToAdd); + + self::assertContains($declarationToAdd, $this->subject->getDeclarations()); + } + + /** + * @test + * + * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName + * + * @param list $initialPropertyNames + */ + public function addDeclarationWithCompletePositionWithoutSiblingPreservesPosition( + array $initialPropertyNames, + string $propertyNameToAdd + ): void { + $declarationToAdd = new Declaration($propertyNameToAdd); + $declarationToAdd->setPosition(42, 64); + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + + $this->subject->addDeclaration($declarationToAdd); + + self::assertSame(42, $declarationToAdd->getLineNumber(), 'line number not preserved'); + self::assertSame(64, $declarationToAdd->getColumnNumber(), 'column number not preserved'); + } + + /** + * @return array, 1: int<0, max>}> + */ + public static function provideInitialPropertyNamesAndIndexOfOne(): array + { + $initialPropertyNamesSets = self::providePropertyNames(); + + // Provide sets with each possible index for the initially set `Declaration`s. + $initialPropertyNamesAndIndexSets = []; + foreach ($initialPropertyNamesSets as $setName => $data) { + $initialPropertyNames = $data[0]; + for ($index = 0; $index < \count($initialPropertyNames); ++$index) { + $initialPropertyNamesAndIndexSets[$setName . ', index ' . $index] = + [$initialPropertyNames, $index]; + } + } + + return $initialPropertyNamesAndIndexSets; + } + + /** + * @return DataProvider, 1: int<0, max>, 2: string}> + */ + public static function provideInitialPropertyNamesAndSiblingIndexAndPropertyNameToAdd(): DataProvider + { + return DataProvider::cross( + self::provideInitialPropertyNamesAndIndexOfOne(), + self::provideAnotherPropertyName() + ); + } + + /** + * @test + * + * @param non-empty-list $initialPropertyNames + * @param int<0, max> $siblingIndex + * + * @dataProvider provideInitialPropertyNamesAndSiblingIndexAndPropertyNameToAdd + */ + public function addDeclarationWithSiblingInsertsDeclarationBeforeSibling( + array $initialPropertyNames, + int $siblingIndex, + string $propertyNameToAdd + ): void { + $declarationToAdd = new Declaration($propertyNameToAdd); + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + $sibling = $this->subject->getDeclarations()[$siblingIndex]; + + $this->subject->addDeclaration($declarationToAdd, $sibling); + + $declarations = $this->subject->getDeclarations(); + $siblingPosition = \array_search($sibling, $declarations, true); + self::assertIsInt($siblingPosition); + self::assertSame($siblingPosition - 1, \array_search($declarationToAdd, $declarations, true)); + } + + /** + * @test + * + * @param non-empty-list $initialPropertyNames + * @param int<0, max> $siblingIndex + * + * @dataProvider provideInitialPropertyNamesAndSiblingIndexAndPropertyNameToAdd + */ + public function addDeclarationWithSiblingSetsValidLineNumber( + array $initialPropertyNames, + int $siblingIndex, + string $propertyNameToAdd + ): void { + $declarationToAdd = new Declaration($propertyNameToAdd); + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + $sibling = $this->subject->getDeclarations()[$siblingIndex]; + + $this->subject->addDeclaration($declarationToAdd, $sibling); + + self::assertIsInt($declarationToAdd->getLineNumber(), 'line number not set'); + self::assertGreaterThanOrEqual(1, $declarationToAdd->getLineNumber(), 'line number not valid'); + } + + /** + * @test + * + * @param non-empty-list $initialPropertyNames + * @param int<0, max> $siblingIndex + * + * @dataProvider provideInitialPropertyNamesAndSiblingIndexAndPropertyNameToAdd + */ + public function addDeclarationWithSiblingSetsValidColumnNumber( + array $initialPropertyNames, + int $siblingIndex, + string $propertyNameToAdd + ): void { + $declarationToAdd = new Declaration($propertyNameToAdd); + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + $sibling = $this->subject->getDeclarations()[$siblingIndex]; + + $this->subject->addDeclaration($declarationToAdd, $sibling); + + self::assertIsInt($declarationToAdd->getColumnNumber(), 'column number not set'); + self::assertGreaterThanOrEqual(0, $declarationToAdd->getColumnNumber(), 'column number not valid'); + } + + /** + * @test + * + * @param list $initialPropertyNames + * + * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName + */ + public function addDeclarationWithSiblingNotInSetAddsDeclarationAfterInitialDeclarations( + array $initialPropertyNames, + string $propertyNameToAdd + ): void { + $declarationToAdd = new Declaration($propertyNameToAdd); + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + + // `display` is sometimes in `$initialPropertyNames` and sometimes the `$propertyNameToAdd`. + // Choosing this for the bogus sibling allows testing all combinations of whether it is or isn't. + $this->subject->addDeclaration($declarationToAdd, new Declaration('display')); + + $declarations = $this->subject->getDeclarations(); + self::assertSame($declarationToAdd, \end($declarations)); + } + + /** + * @test + * + * @param list $initialPropertyNames + * + * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName + */ + public function addDeclarationWithSiblingNotInSetSetsValidLineNumber( + array $initialPropertyNames, + string $propertyNameToAdd + ): void { + $declarationToAdd = new Declaration($propertyNameToAdd); + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + + // `display` is sometimes in `$initialPropertyNames` and sometimes the `$propertyNameToAdd`. + // Choosing this for the bogus sibling allows testing all combinations of whether it is or isn't. + $this->subject->addDeclaration($declarationToAdd, new Declaration('display')); + + self::assertIsInt($declarationToAdd->getLineNumber(), 'line number not set'); + self::assertGreaterThanOrEqual(1, $declarationToAdd->getLineNumber(), 'line number not valid'); + } + + /** + * @test + * + * @param list $initialPropertyNames + * + * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName + */ + public function addDeclarationWithSiblingNotInSetSetsValidColumnNumber( + array $initialPropertyNames, + string $propertyNameToAdd + ): void { + $declarationToAdd = new Declaration($propertyNameToAdd); + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + + // `display` is sometimes in `$initialPropertyNames` and sometimes the `$propertyNameToAdd`. + // Choosing this for the bogus sibling allows testing all combinations of whether it is or isn't. + $this->subject->addDeclaration($declarationToAdd, new Declaration('display')); + + self::assertIsInt($declarationToAdd->getColumnNumber(), 'column number not set'); + self::assertGreaterThanOrEqual(0, $declarationToAdd->getColumnNumber(), 'column number not valid'); + } + + /** + * @test + * + * @param non-empty-list $initialPropertyNames + * @param int<0, max> $indexToRemove + * + * @dataProvider provideInitialPropertyNamesAndIndexOfOne + */ + public function removeDeclarationRemovesDeclarationInSet(array $initialPropertyNames, int $indexToRemove): void + { + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + $declarationToRemove = $this->subject->getDeclarations()[$indexToRemove]; + + $this->subject->removeDeclaration($declarationToRemove); + + self::assertNotContains($declarationToRemove, $this->subject->getDeclarations()); + } + + /** + * @test + * + * @param non-empty-list $initialPropertyNames + * @param int<0, max> $indexToRemove + * + * @dataProvider provideInitialPropertyNamesAndIndexOfOne + */ + public function removeDeclarationRemovesExactlyOneDeclaration(array $initialPropertyNames, int $indexToRemove): void + { + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + $declarationToRemove = $this->subject->getDeclarations()[$indexToRemove]; + + $this->subject->removeDeclaration($declarationToRemove); + + self::assertCount(\count($initialPropertyNames) - 1, $this->subject->getDeclarations()); + } + + /** + * @test + * + * @param list $initialPropertyNames + * + * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName + */ + public function removeDeclarationWithDeclarationNotInSetKeepsSetUnchanged( + array $initialPropertyNames, + string $propertyNameToRemove + ): void { + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + $initialDeclarations = $this->subject->getDeclarations(); + $declarationToRemove = new Declaration($propertyNameToRemove); + + $this->subject->removeDeclaration($declarationToRemove); + + self::assertSame($initialDeclarations, $this->subject->getDeclarations()); + } + + /** + * @return array, 1: string, 2: list}> + */ + public static function providePropertyNamesAndPropertyNameToRemoveAndExpectedRemainingPropertyNames(): array + { + return [ + 'removing single declaration' => [ + ['color'], + 'color', + [], + ], + 'removing first declaration' => [ + ['color', 'display'], + 'color', + ['display'], + ], + 'removing last declaration' => [ + ['color', 'display'], + 'display', + ['color'], + ], + 'removing middle declaration' => [ + ['color', 'display', 'width'], + 'display', + ['color', 'width'], + ], + 'removing multiple declarations' => [ + ['color', 'color'], + 'color', + [], + ], + 'removing multiple declarations with another kept' => [ + ['color', 'color', 'display'], + 'color', + ['display'], + ], + 'removing nonexistent declaration from empty list' => [ + [], + 'color', + [], + ], + 'removing nonexistent declaration from nonempty list' => [ + ['color', 'display'], + 'width', + ['color', 'display'], + ], + ]; + } + + /** + * @test + * + * @param list $initialPropertyNames + * + * @dataProvider providePropertyNamesAndPropertyNameToRemoveAndExpectedRemainingPropertyNames + */ + public function removeMatchingDeclarationsRemovesDeclarationsWithPropertyName( + array $initialPropertyNames, + string $propertyNameToRemove + ): void { + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + + $this->subject->removeMatchingDeclarations($propertyNameToRemove); + + self::assertArrayNotHasKey($propertyNameToRemove, $this->subject->getDeclarationsAssociative()); + } + + /** + * @test + * + * @param list $initialPropertyNames + * @param list $expectedRemainingPropertyNames + * + * @dataProvider providePropertyNamesAndPropertyNameToRemoveAndExpectedRemainingPropertyNames + */ + public function removeMatchingDeclarationsWithPropertyNameKeepsOtherDeclarations( + array $initialPropertyNames, + string $propertyNameToRemove, + array $expectedRemainingPropertyNames + ): void { + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + + $this->subject->removeMatchingDeclarations($propertyNameToRemove); + + $remainingDeclarations = $this->subject->getDeclarationsAssociative(); + if ($expectedRemainingPropertyNames === []) { + self::assertSame([], $remainingDeclarations); + } + foreach ($expectedRemainingPropertyNames as $expectedPropertyName) { + self::assertArrayHasKey($expectedPropertyName, $remainingDeclarations); + } + } + + /** + * @return array, 1: string, 2: list}> + */ + public static function providePropertyNamesAndPropertyNamePrefixToRemoveAndExpectedRemainingPropertyNames(): array + { + return [ + 'removing shorthand declaration' => [ + ['font'], + 'font', + [], + ], + 'removing longhand declaration' => [ + ['font-size'], + 'font', + [], + ], + 'removing shorthand and longhand declaration' => [ + ['font', 'font-size'], + 'font', + [], + ], + 'removing shorthand declaration with another kept' => [ + ['font', 'color'], + 'font', + ['color'], + ], + 'removing longhand declaration with another kept' => [ + ['font-size', 'color'], + 'font', + ['color'], + ], + 'keeping other declarations whose property names begin with the same characters' => [ + ['contain', 'container', 'container-type'], + 'contain', + ['container', 'container-type'], + ], + ]; + } + + /** + * @test + * + * @param list $initialPropertyNames + * + * @dataProvider providePropertyNamesAndPropertyNamePrefixToRemoveAndExpectedRemainingPropertyNames + */ + public function removeMatchingDeclarationsRemovesDeclarationsWithPropertyNamePrefix( + array $initialPropertyNames, + string $propertyNamePrefix + ): void { + $propertyNamePrefixWithHyphen = $propertyNamePrefix . '-'; + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + + $this->subject->removeMatchingDeclarations($propertyNamePrefixWithHyphen); + + $remainingDeclarations = $this->subject->getDeclarationsAssociative(); + self::assertArrayNotHasKey($propertyNamePrefix, $remainingDeclarations); + foreach (\array_keys($remainingDeclarations) as $remainingPropertyName) { + self::assertStringStartsNotWith($propertyNamePrefixWithHyphen, $remainingPropertyName); + } + } + + /** + * @test + * + * @param list $initialPropertyNames + * @param list $expectedRemainingPropertyNames + * + * @dataProvider providePropertyNamesAndPropertyNamePrefixToRemoveAndExpectedRemainingPropertyNames + */ + public function removeMatchingDeclarationsWithPropertyNamePrefixKeepsOtherDeclarations( + array $initialPropertyNames, + string $propertyNamePrefix, + array $expectedRemainingPropertyNames + ): void { + $propertyNamePrefixWithHyphen = $propertyNamePrefix . '-'; + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + + $this->subject->removeMatchingDeclarations($propertyNamePrefixWithHyphen); + + $remainingDeclarations = $this->subject->getDeclarationsAssociative(); + if ($expectedRemainingPropertyNames === []) { + self::assertSame([], $remainingDeclarations); + } + foreach ($expectedRemainingPropertyNames as $expectedPropertyName) { + self::assertArrayHasKey($expectedPropertyName, $remainingDeclarations); + } + } + + /** + * @test + * + * @param list $propertyNamesToRemove + * + * @dataProvider providePropertyNames + */ + public function removeAllDeclarationsRemovesAllDeclarations(array $propertyNamesToRemove): void + { + $this->setDeclarationsFromPropertyNames($propertyNamesToRemove); + + $this->subject->removeAllDeclarations(); + + self::assertSame([], $this->subject->getDeclarations()); + } + + /** + * @test + * + * @param list $propertyNamesToSet + * + * @dataProvider providePropertyNames + */ + public function setDeclarationsOnVirginSetsDeclarationsWithoutPositionInOrder(array $propertyNamesToSet): void + { + $declarationsToSet = self::createDeclarationsFromPropertyNames($propertyNamesToSet); + + $this->subject->setDeclarations($declarationsToSet); + + self::assertSame($declarationsToSet, $this->subject->getDeclarations()); + } + + /** + * @return DataProvider, 1: list}> + */ + public static function provideInitialPropertyNamesAndPropertyNamesToSet(): DataProvider + { + return DataProvider::cross(self::providePropertyNames(), self::providePropertyNames()); + } + + /** + * @test + * + * @param list $initialPropertyNames + * @param list $propertyNamesToSet + * + * @dataProvider provideInitialPropertyNamesAndPropertyNamesToSet + */ + public function setDeclarationsReplacesDeclarations(array $initialPropertyNames, array $propertyNamesToSet): void + { + $declarationsToSet = self::createDeclarationsFromPropertyNames($propertyNamesToSet); + $this->setDeclarationsFromPropertyNames($initialPropertyNames); + + $this->subject->setDeclarations($declarationsToSet); + + self::assertSame($declarationsToSet, $this->subject->getDeclarations()); + } + + /** + * @test + */ + public function setDeclarationsWithDeclarationWithoutPositionSetsValidLineNumber(): void + { + $declarationToSet = new Declaration('color'); + + $this->subject->setDeclarations([$declarationToSet]); + + self::assertIsInt($declarationToSet->getLineNumber(), 'line number not set'); + self::assertGreaterThanOrEqual(1, $declarationToSet->getLineNumber(), 'line number not valid'); + } + + /** + * @test + */ + public function setDeclarationsWithDeclarationWithoutPositionSetsValidColumnNumber(): void + { + $declarationToSet = new Declaration('color'); + + $this->subject->setDeclarations([$declarationToSet]); + + self::assertIsInt($declarationToSet->getColumnNumber(), 'column number not set'); + self::assertGreaterThanOrEqual(0, $declarationToSet->getColumnNumber(), 'column number not valid'); + } + + /** + * @test + */ + public function setDeclarationsWithDeclarationWithOnlyLineNumberSetsColumnNumber(): void + { + $declarationToSet = new Declaration('color'); + $declarationToSet->setPosition(42); + + $this->subject->setDeclarations([$declarationToSet]); + + self::assertIsInt($declarationToSet->getColumnNumber(), 'column number not set'); + self::assertGreaterThanOrEqual(0, $declarationToSet->getColumnNumber(), 'column number not valid'); + } + + /** + * @test + */ + public function setDeclarationsWithDeclarationWithOnlyLineNumberPreservesLineNumber(): void + { + $declarationToSet = new Declaration('color'); + $declarationToSet->setPosition(42); + + $this->subject->setDeclarations([$declarationToSet]); + + self::assertSame(42, $declarationToSet->getLineNumber(), 'line number not preserved'); + } + + /** + * @test + */ + public function setDeclarationsWithDeclarationWithOnlyColumnNumberSetsLineNumber(): void + { + $declarationToSet = new Declaration('color'); + $declarationToSet->setPosition(null, 42); + + $this->subject->setDeclarations([$declarationToSet]); + + self::assertIsInt($declarationToSet->getLineNumber(), 'line number not set'); + self::assertGreaterThanOrEqual(1, $declarationToSet->getLineNumber(), 'line number not valid'); + } + + /** + * @test + */ + public function setDeclarationsWithDeclarationWithOnlyColumnNumberPreservesColumnNumber(): void + { + $declarationToSet = new Declaration('color'); + $declarationToSet->setPosition(null, 42); + + $this->subject->setDeclarations([$declarationToSet]); + + self::assertSame(42, $declarationToSet->getColumnNumber(), 'column number not preserved'); + } + + /** + * @test + */ + public function setDeclarationsWithDeclarationWithCompletePositionPreservesPosition(): void + { + $declarationToSet = new Declaration('color'); + $declarationToSet->setPosition(42, 64); + + $this->subject->setDeclarations([$declarationToSet]); + + self::assertSame(42, $declarationToSet->getLineNumber(), 'line number not preserved'); + self::assertSame(64, $declarationToSet->getColumnNumber(), 'column number not preserved'); + } + + /** + * @test + * + * @param list $propertyNamesToSet + * + * @dataProvider providePropertyNames + */ + public function getDeclarationsReturnsDeclarationsSet(array $propertyNamesToSet): void + { + $declarationsToSet = self::createDeclarationsFromPropertyNames($propertyNamesToSet); + $this->subject->setDeclarations($declarationsToSet); + + $result = $this->subject->getDeclarations(); + + self::assertSame($declarationsToSet, $result); + } + + /** + * @test + */ + public function getDeclarationsOrdersByLineNumber(): void + { + $first = (new Declaration('color'))->setPosition(1, 64); + $second = (new Declaration('display'))->setPosition(19, 42); + $third = (new Declaration('color'))->setPosition(55, 11); + $this->subject->setDeclarations([$third, $second, $first]); + + $result = $this->subject->getDeclarations(); + + self::assertSame([$first, $second, $third], $result); + } + + /** + * @test + */ + public function getDeclarationsOrdersDeclarationsWithSameLineNumberByColumnNumber(): void + { + $first = (new Declaration('color'))->setPosition(1, 11); + $second = (new Declaration('display'))->setPosition(1, 42); + $third = (new Declaration('color'))->setPosition(1, 64); + $this->subject->setDeclarations([$third, $second, $first]); + + $result = $this->subject->getDeclarations(); + + self::assertSame([$first, $second, $third], $result); + } + + /** + * @return array, 1: string, 2: list}> + */ + public static function providePropertyNamesAndSearchPatternAndMatchingPropertyNames(): array + { + return [ + 'single declaration matched' => [ + ['color'], + 'color', + ['color'], + ], + 'first declaration matched' => [ + ['color', 'display'], + 'color', + ['color'], + ], + 'last declaration matched' => [ + ['color', 'display'], + 'display', + ['display'], + ], + 'middle declaration matched' => [ + ['color', 'display', 'width'], + 'display', + ['display'], + ], + 'multiple declarations for the same property matched' => [ + ['color', 'color'], + 'color', + ['color'], + ], + 'multiple declarations for the same property matched in haystack' => [ + ['color', 'display', 'color', 'width'], + 'color', + ['color'], + ], + 'shorthand declaration matched' => [ + ['font'], + 'font-', + ['font'], + ], + 'longhand declaration matched' => [ + ['font-size'], + 'font-', + ['font-size'], + ], + 'shorthand and longhand declaration matched' => [ + ['font', 'font-size'], + 'font-', + ['font', 'font-size'], + ], + 'shorthand declaration matched in haystack' => [ + ['font', 'color'], + 'font-', + ['font'], + ], + 'longhand declaration matched in haystack' => [ + ['font-size', 'color'], + 'font-', + ['font-size'], + ], + 'declarations whose property names begin with the same characters not matched with pattern match' => [ + ['contain', 'container', 'container-type'], + 'contain-', + ['contain'], + ], + 'declarations whose property names begin with the same characters not matched with exact match' => [ + ['contain', 'container', 'container-type'], + 'contain', + ['contain'], + ], + ]; + } + + /** + * @test + * + * @param list $propertyNamesToSet + * @param list $matchingPropertyNames + * + * @dataProvider providePropertyNamesAndSearchPatternAndMatchingPropertyNames + */ + public function getDeclarationsWithPatternReturnsAllMatchingDeclarations( + array $propertyNamesToSet, + string $searchPattern, + array $matchingPropertyNames + ): void { + $declarationsToSet = self::createDeclarationsFromPropertyNames($propertyNamesToSet); + // Use `array_values` to ensure canonical numeric array, since `array_filter` does not reindex. + $matchingDeclarations = \array_values( + \array_filter( + $declarationsToSet, + static function (Declaration $declaration) use ($matchingPropertyNames): bool { + return \in_array($declaration->getPropertyName(), $matchingPropertyNames, true); + } + ) + ); + $this->subject->setDeclarations($declarationsToSet); + + $result = $this->subject->getDeclarations($searchPattern); + + // `Declaration`s without pre-set positions are returned in the order set. This is tested separately. + self::assertSame($matchingDeclarations, $result); + } + + /** + * @return array, 1: string}> + */ + public static function providePropertyNamesAndNonMatchingSearchPattern(): array + { + return [ + 'no match in empty list' => [ + [], + 'color', + ], + 'no match for different property' => [ + ['color'], + 'display', + ], + 'no match for property not in list' => [ + ['color', 'display'], + 'width', + ], + ]; + } + + /** + * @test + * + * @param list $propertyNamesToSet + * + * @dataProvider providePropertyNamesAndNonMatchingSearchPattern + */ + public function getDeclarationsWithNonMatchingPatternReturnsEmptyArray( + array $propertyNamesToSet, + string $searchPattern + ): void { + $this->setDeclarationsFromPropertyNames($propertyNamesToSet); + + $result = $this->subject->getDeclarations($searchPattern); + + self::assertSame([], $result); + } + + /** + * @test + */ + public function getDeclarationsWithPatternOrdersDeclarationsByPosition(): void + { + $first = (new Declaration('color'))->setPosition(1, 42); + $second = (new Declaration('color'))->setPosition(1, 64); + $third = (new Declaration('color'))->setPosition(55, 7); + $this->subject->setDeclarations([$third, $second, $first]); + + $result = $this->subject->getDeclarations('color'); + + self::assertSame([$first, $second, $third], $result); + } + + /** + * @return array}> + */ + public static function provideDistinctPropertyNames(): array + { + return [ + 'no properties' => [[]], + 'one property' => [['color']], + 'two properties' => [['color', 'display']], + ]; + } + + /** + * @test + * + * @param list $propertyNamesToSet + * + * @dataProvider provideDistinctPropertyNames + */ + public function getDeclarationsAssocReturnsAllDeclarationsWithDistinctPropertyNames(array $propertyNamesToSet): void + { + $declarationsToSet = self::createDeclarationsFromPropertyNames($propertyNamesToSet); + $this->subject->setDeclarations($declarationsToSet); + + $result = $this->subject->getDeclarationsAssociative(); + + self::assertSame($declarationsToSet, \array_values($result)); + } + + /** + * @test + */ + public function getDeclarationsAssocReturnsLastDeclarationWithSamePropertyName(): void + { + $firstDeclaration = new Declaration('color'); + $lastDeclaration = new Declaration('color'); + $this->subject->setDeclarations([$firstDeclaration, $lastDeclaration]); + + $result = $this->subject->getDeclarationsAssociative(); + + self::assertSame([$lastDeclaration], \array_values($result)); + } + + /** + * @test + */ + public function getDeclarationsAssocOrdersDeclarationsByPosition(): void + { + $first = (new Declaration('color'))->setPosition(1, 42); + $second = (new Declaration('display'))->setPosition(1, 64); + $third = (new Declaration('width'))->setPosition(55, 7); + $this->subject->setDeclarations([$third, $second, $first]); + + $result = $this->subject->getDeclarationsAssociative(); + + self::assertSame([$first, $second, $third], \array_values($result)); + } + + /** + * @test + */ + public function getDeclarationsAssocKeysDeclarationsByPropertyName(): void + { + $this->subject->setDeclarations([new Declaration('color'), new Declaration('display')]); + + $result = $this->subject->getDeclarationsAssociative(); + + foreach ($result as $key => $declaration) { + self::assertSame($declaration->getPropertyName(), $key); + } + } + + /** + * @test + * + * @param list $propertyNamesToSet + * @param list $matchingPropertyNames + * + * @dataProvider providePropertyNamesAndSearchPatternAndMatchingPropertyNames + */ + public function getDeclarationsAssocWithPatternReturnsAllMatchingPropertyNames( + array $propertyNamesToSet, + string $searchPattern, + array $matchingPropertyNames + ): void { + $this->setDeclarationsFromPropertyNames($propertyNamesToSet); + + $result = $this->subject->getDeclarationsAssociative($searchPattern); + + $resultPropertyNames = \array_keys($result); + \sort($matchingPropertyNames); + \sort($resultPropertyNames); + self::assertSame($matchingPropertyNames, $resultPropertyNames); + } + + /** + * @test + * + * @param list $propertyNamesToSet + * + * @dataProvider providePropertyNamesAndNonMatchingSearchPattern + */ + public function getDeclarationsAssocWithNonMatchingPatternReturnsEmptyArray( + array $propertyNamesToSet, + string $searchPattern + ): void { + $this->setDeclarationsFromPropertyNames($propertyNamesToSet); + + $result = $this->subject->getDeclarationsAssociative($searchPattern); + + self::assertSame([], $result); + } + + /** + * @test + */ + public function getDeclarationsAssocWithPatternOrdersDeclarationsByPosition(): void + { + $first = (new Declaration('font'))->setPosition(1, 42); + $second = (new Declaration('font-family'))->setPosition(1, 64); + $third = (new Declaration('font-weight'))->setPosition(55, 7); + $this->subject->setDeclarations([$third, $second, $first]); + + $result = $this->subject->getDeclarations('font-'); + + self::assertSame([$first, $second, $third], \array_values($result)); + } + + /** + * @param list $propertyNames + */ + private function setDeclarationsFromPropertyNames(array $propertyNames): void + { + $this->subject->setDeclarations(self::createDeclarationsFromPropertyNames($propertyNames)); + } + + /** + * @param list $propertyNames + * + * @return list + */ + private static function createDeclarationsFromPropertyNames(array $propertyNames): array + { + return \array_map( + function (string $propertyName): Declaration { + return new Declaration($propertyName); + }, + $propertyNames + ); + } +} diff --git a/tests/Unit/RuleSet/RuleContainerTest.php b/tests/Unit/RuleSet/RuleContainerTest.php deleted file mode 100644 index bcb641a22..000000000 --- a/tests/Unit/RuleSet/RuleContainerTest.php +++ /dev/null @@ -1,1200 +0,0 @@ -subject); - } - - /** - * @return array}> - */ - public static function providePropertyNames(): array - { - return [ - 'no properties' => [[]], - 'one property' => [['color']], - 'two different properties' => [['color', 'display']], - 'two of the same property' => [['color', 'color']], - ]; - } - - /** - * @return array - */ - public static function provideAnotherPropertyName(): array - { - return [ - 'property name `color` maybe matching that of existing declaration' => ['color'], - 'property name `display` maybe matching that of existing declaration' => ['display'], - 'property name `width` not matching that of existing declaration' => ['width'], - ]; - } - - /** - * @return DataProvider, 1: string}> - */ - public static function provideInitialPropertyNamesAndAnotherPropertyName(): DataProvider - { - return DataProvider::cross(self::providePropertyNames(), self::provideAnotherPropertyName()); - } - - /** - * @test - * - * @param list $initialPropertyNames - * - * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName - */ - public function addRuleWithoutPositionWithoutSiblingAddsRuleAfterInitialRules( - array $initialPropertyNames, - string $propertyNameToAdd - ): void { - $ruleToAdd = new Rule($propertyNameToAdd); - $this->setRulesFromPropertyNames($initialPropertyNames); - - $this->subject->addRule($ruleToAdd); - - $rules = $this->subject->getRules(); - self::assertSame($ruleToAdd, \end($rules)); - } - - /** - * @test - * - * @param list $initialPropertyNames - * - * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName - */ - public function addRuleWithoutPositionWithoutSiblingSetsValidLineNumber( - array $initialPropertyNames, - string $propertyNameToAdd - ): void { - $ruleToAdd = new Rule($propertyNameToAdd); - $this->setRulesFromPropertyNames($initialPropertyNames); - - $this->subject->addRule($ruleToAdd); - - self::assertIsInt($ruleToAdd->getLineNumber(), 'line number not set'); - self::assertGreaterThanOrEqual(1, $ruleToAdd->getLineNumber(), 'line number not valid'); - } - - /** - * @test - * - * @param list $initialPropertyNames - * - * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName - */ - public function addRuleWithoutPositionWithoutSiblingSetsValidColumnNumber( - array $initialPropertyNames, - string $propertyNameToAdd - ): void { - $ruleToAdd = new Rule($propertyNameToAdd); - $this->setRulesFromPropertyNames($initialPropertyNames); - - $this->subject->addRule($ruleToAdd); - - self::assertIsInt($ruleToAdd->getColumnNumber(), 'column number not set'); - self::assertGreaterThanOrEqual(0, $ruleToAdd->getColumnNumber(), 'column number not valid'); - } - - /** - * @test - * - * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName - * - * @param list $initialPropertyNames - */ - public function addRuleWithOnlyLineNumberWithoutSiblingAddsRule( - array $initialPropertyNames, - string $propertyNameToAdd - ): void { - $ruleToAdd = new Rule($propertyNameToAdd); - $ruleToAdd->setPosition(42); - $this->setRulesFromPropertyNames($initialPropertyNames); - - $this->subject->addRule($ruleToAdd); - - self::assertContains($ruleToAdd, $this->subject->getRules()); - } - - /** - * @test - * - * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName - * - * @param list $initialPropertyNames - */ - public function addRuleWithOnlyLineNumberWithoutSiblingSetsColumnNumber( - array $initialPropertyNames, - string $propertyNameToAdd - ): void { - $ruleToAdd = new Rule($propertyNameToAdd); - $ruleToAdd->setPosition(42); - $this->setRulesFromPropertyNames($initialPropertyNames); - - $this->subject->addRule($ruleToAdd); - - self::assertIsInt($ruleToAdd->getColumnNumber(), 'column number not set'); - self::assertGreaterThanOrEqual(0, $ruleToAdd->getColumnNumber(), 'column number not valid'); - } - - /** - * @test - * - * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName - * - * @param list $initialPropertyNames - */ - public function addRuleWithOnlyLineNumberWithoutSiblingPreservesLineNumber( - array $initialPropertyNames, - string $propertyNameToAdd - ): void { - $ruleToAdd = new Rule($propertyNameToAdd); - $ruleToAdd->setPosition(42); - $this->setRulesFromPropertyNames($initialPropertyNames); - - $this->subject->addRule($ruleToAdd); - - self::assertSame(42, $ruleToAdd->getLineNumber(), 'line number not preserved'); - } - - /** - * @test - * - * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName - * - * @param list $initialPropertyNames - */ - public function addRuleWithOnlyColumnNumberWithoutSiblingAddsRuleAfterInitialRules( - array $initialPropertyNames, - string $propertyNameToAdd - ): void { - $ruleToAdd = new Rule($propertyNameToAdd); - $ruleToAdd->setPosition(null, 42); - $this->setRulesFromPropertyNames($initialPropertyNames); - - $this->subject->addRule($ruleToAdd); - - $rules = $this->subject->getRules(); - self::assertSame($ruleToAdd, \end($rules)); - } - - /** - * @test - * - * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName - * - * @param list $initialPropertyNames - */ - public function addRuleWithOnlyColumnNumberWithoutSiblingSetsLineNumber( - array $initialPropertyNames, - string $propertyNameToAdd - ): void { - $ruleToAdd = new Rule($propertyNameToAdd); - $ruleToAdd->setPosition(null, 42); - $this->setRulesFromPropertyNames($initialPropertyNames); - - $this->subject->addRule($ruleToAdd); - - self::assertIsInt($ruleToAdd->getLineNumber(), 'line number not set'); - self::assertGreaterThanOrEqual(1, $ruleToAdd->getLineNumber(), 'line number not valid'); - } - - /** - * @test - * - * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName - * - * @param list $initialPropertyNames - */ - public function addRuleWithOnlyColumnNumberWithoutSiblingPreservesColumnNumber( - array $initialPropertyNames, - string $propertyNameToAdd - ): void { - $ruleToAdd = new Rule($propertyNameToAdd); - $ruleToAdd->setPosition(null, 42); - $this->setRulesFromPropertyNames($initialPropertyNames); - - $this->subject->addRule($ruleToAdd); - - self::assertSame(42, $ruleToAdd->getColumnNumber(), 'column number not preserved'); - } - - /** - * @test - * - * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName - * - * @param list $initialPropertyNames - */ - public function addRuleWithCompletePositionWithoutSiblingAddsRule( - array $initialPropertyNames, - string $propertyNameToAdd - ): void { - $ruleToAdd = new Rule($propertyNameToAdd); - $ruleToAdd->setPosition(42, 64); - $this->setRulesFromPropertyNames($initialPropertyNames); - - $this->subject->addRule($ruleToAdd); - - self::assertContains($ruleToAdd, $this->subject->getRules()); - } - - /** - * @test - * - * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName - * - * @param list $initialPropertyNames - */ - public function addRuleWithCompletePositionWithoutSiblingPreservesPosition( - array $initialPropertyNames, - string $propertyNameToAdd - ): void { - $ruleToAdd = new Rule($propertyNameToAdd); - $ruleToAdd->setPosition(42, 64); - $this->setRulesFromPropertyNames($initialPropertyNames); - - $this->subject->addRule($ruleToAdd); - - self::assertSame(42, $ruleToAdd->getLineNumber(), 'line number not preserved'); - self::assertSame(64, $ruleToAdd->getColumnNumber(), 'column number not preserved'); - } - - /** - * @return array, 1: int<0, max>}> - */ - public static function provideInitialPropertyNamesAndIndexOfOne(): array - { - $initialPropertyNamesSets = self::providePropertyNames(); - - // Provide sets with each possible index for the initially set `Rule`s. - $initialPropertyNamesAndIndexSets = []; - foreach ($initialPropertyNamesSets as $setName => $data) { - $initialPropertyNames = $data[0]; - for ($index = 0; $index < \count($initialPropertyNames); ++$index) { - $initialPropertyNamesAndIndexSets[$setName . ', index ' . $index] = - [$initialPropertyNames, $index]; - } - } - - return $initialPropertyNamesAndIndexSets; - } - - /** - * @return DataProvider, 1: int<0, max>, 2: string}> - */ - public static function provideInitialPropertyNamesAndSiblingIndexAndPropertyNameToAdd(): DataProvider - { - return DataProvider::cross( - self::provideInitialPropertyNamesAndIndexOfOne(), - self::provideAnotherPropertyName() - ); - } - - /** - * @test - * - * @param non-empty-list $initialPropertyNames - * @param int<0, max> $siblingIndex - * - * @dataProvider provideInitialPropertyNamesAndSiblingIndexAndPropertyNameToAdd - */ - public function addRuleWithSiblingInsertsRuleBeforeSibling( - array $initialPropertyNames, - int $siblingIndex, - string $propertyNameToAdd - ): void { - $ruleToAdd = new Rule($propertyNameToAdd); - $this->setRulesFromPropertyNames($initialPropertyNames); - $sibling = $this->subject->getRules()[$siblingIndex]; - - $this->subject->addRule($ruleToAdd, $sibling); - - $rules = $this->subject->getRules(); - $siblingPosition = \array_search($sibling, $rules, true); - self::assertIsInt($siblingPosition); - self::assertSame($siblingPosition - 1, \array_search($ruleToAdd, $rules, true)); - } - - /** - * @test - * - * @param non-empty-list $initialPropertyNames - * @param int<0, max> $siblingIndex - * - * @dataProvider provideInitialPropertyNamesAndSiblingIndexAndPropertyNameToAdd - */ - public function addRuleWithSiblingSetsValidLineNumber( - array $initialPropertyNames, - int $siblingIndex, - string $propertyNameToAdd - ): void { - $ruleToAdd = new Rule($propertyNameToAdd); - $this->setRulesFromPropertyNames($initialPropertyNames); - $sibling = $this->subject->getRules()[$siblingIndex]; - - $this->subject->addRule($ruleToAdd, $sibling); - - self::assertIsInt($ruleToAdd->getLineNumber(), 'line number not set'); - self::assertGreaterThanOrEqual(1, $ruleToAdd->getLineNumber(), 'line number not valid'); - } - - /** - * @test - * - * @param non-empty-list $initialPropertyNames - * @param int<0, max> $siblingIndex - * - * @dataProvider provideInitialPropertyNamesAndSiblingIndexAndPropertyNameToAdd - */ - public function addRuleWithSiblingSetsValidColumnNumber( - array $initialPropertyNames, - int $siblingIndex, - string $propertyNameToAdd - ): void { - $ruleToAdd = new Rule($propertyNameToAdd); - $this->setRulesFromPropertyNames($initialPropertyNames); - $sibling = $this->subject->getRules()[$siblingIndex]; - - $this->subject->addRule($ruleToAdd, $sibling); - - self::assertIsInt($ruleToAdd->getColumnNumber(), 'column number not set'); - self::assertGreaterThanOrEqual(0, $ruleToAdd->getColumnNumber(), 'column number not valid'); - } - - /** - * @test - * - * @param list $initialPropertyNames - * - * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName - */ - public function addRuleWithSiblingNotInSetAddsRuleAfterInitialRules( - array $initialPropertyNames, - string $propertyNameToAdd - ): void { - $ruleToAdd = new Rule($propertyNameToAdd); - $this->setRulesFromPropertyNames($initialPropertyNames); - - // `display` is sometimes in `$initialPropertyNames` and sometimes the `$propertyNameToAdd`. - // Choosing this for the bogus sibling allows testing all combinations of whether it is or isn't. - $this->subject->addRule($ruleToAdd, new Rule('display')); - - $rules = $this->subject->getRules(); - self::assertSame($ruleToAdd, \end($rules)); - } - - /** - * @test - * - * @param list $initialPropertyNames - * - * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName - */ - public function addRuleWithSiblingNotInSetSetsValidLineNumber( - array $initialPropertyNames, - string $propertyNameToAdd - ): void { - $ruleToAdd = new Rule($propertyNameToAdd); - $this->setRulesFromPropertyNames($initialPropertyNames); - - // `display` is sometimes in `$initialPropertyNames` and sometimes the `$propertyNameToAdd`. - // Choosing this for the bogus sibling allows testing all combinations of whether it is or isn't. - $this->subject->addRule($ruleToAdd, new Rule('display')); - - self::assertIsInt($ruleToAdd->getLineNumber(), 'line number not set'); - self::assertGreaterThanOrEqual(1, $ruleToAdd->getLineNumber(), 'line number not valid'); - } - - /** - * @test - * - * @param list $initialPropertyNames - * - * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName - */ - public function addRuleWithSiblingNotInSetSetsValidColumnNumber( - array $initialPropertyNames, - string $propertyNameToAdd - ): void { - $ruleToAdd = new Rule($propertyNameToAdd); - $this->setRulesFromPropertyNames($initialPropertyNames); - - // `display` is sometimes in `$initialPropertyNames` and sometimes the `$propertyNameToAdd`. - // Choosing this for the bogus sibling allows testing all combinations of whether it is or isn't. - $this->subject->addRule($ruleToAdd, new Rule('display')); - - self::assertIsInt($ruleToAdd->getColumnNumber(), 'column number not set'); - self::assertGreaterThanOrEqual(0, $ruleToAdd->getColumnNumber(), 'column number not valid'); - } - - /** - * @test - * - * @param non-empty-list $initialPropertyNames - * @param int<0, max> $indexToRemove - * - * @dataProvider provideInitialPropertyNamesAndIndexOfOne - */ - public function removeRuleRemovesRuleInSet(array $initialPropertyNames, int $indexToRemove): void - { - $this->setRulesFromPropertyNames($initialPropertyNames); - $ruleToRemove = $this->subject->getRules()[$indexToRemove]; - - $this->subject->removeRule($ruleToRemove); - - self::assertNotContains($ruleToRemove, $this->subject->getRules()); - } - - /** - * @test - * - * @param non-empty-list $initialPropertyNames - * @param int<0, max> $indexToRemove - * - * @dataProvider provideInitialPropertyNamesAndIndexOfOne - */ - public function removeRuleRemovesExactlyOneRule(array $initialPropertyNames, int $indexToRemove): void - { - $this->setRulesFromPropertyNames($initialPropertyNames); - $ruleToRemove = $this->subject->getRules()[$indexToRemove]; - - $this->subject->removeRule($ruleToRemove); - - self::assertCount(\count($initialPropertyNames) - 1, $this->subject->getRules()); - } - - /** - * @test - * - * @param list $initialPropertyNames - * - * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName - */ - public function removeRuleWithRuleNotInSetKeepsSetUnchanged( - array $initialPropertyNames, - string $propertyNameToRemove - ): void { - $this->setRulesFromPropertyNames($initialPropertyNames); - $initialRules = $this->subject->getRules(); - $ruleToRemove = new Rule($propertyNameToRemove); - - $this->subject->removeRule($ruleToRemove); - - self::assertSame($initialRules, $this->subject->getRules()); - } - - /** - * @return array, 1: string, 2: list}> - */ - public static function providePropertyNamesAndPropertyNameToRemoveAndExpectedRemainingPropertyNames(): array - { - return [ - 'removing single rule' => [ - ['color'], - 'color', - [], - ], - 'removing first rule' => [ - ['color', 'display'], - 'color', - ['display'], - ], - 'removing last rule' => [ - ['color', 'display'], - 'display', - ['color'], - ], - 'removing middle rule' => [ - ['color', 'display', 'width'], - 'display', - ['color', 'width'], - ], - 'removing multiple rules' => [ - ['color', 'color'], - 'color', - [], - ], - 'removing multiple rules with another kept' => [ - ['color', 'color', 'display'], - 'color', - ['display'], - ], - 'removing nonexistent rule from empty list' => [ - [], - 'color', - [], - ], - 'removing nonexistent rule from nonempty list' => [ - ['color', 'display'], - 'width', - ['color', 'display'], - ], - ]; - } - - /** - * @test - * - * @param list $initialPropertyNames - * - * @dataProvider providePropertyNamesAndPropertyNameToRemoveAndExpectedRemainingPropertyNames - */ - public function removeMatchingRulesRemovesRulesWithPropertyName( - array $initialPropertyNames, - string $propertyNameToRemove - ): void { - $this->setRulesFromPropertyNames($initialPropertyNames); - - $this->subject->removeMatchingRules($propertyNameToRemove); - - self::assertArrayNotHasKey($propertyNameToRemove, $this->subject->getRulesAssoc()); - } - - /** - * @test - * - * @param list $initialPropertyNames - * @param list $expectedRemainingPropertyNames - * - * @dataProvider providePropertyNamesAndPropertyNameToRemoveAndExpectedRemainingPropertyNames - */ - public function removeMatchingRulesWithPropertyNameKeepsOtherRules( - array $initialPropertyNames, - string $propertyNameToRemove, - array $expectedRemainingPropertyNames - ): void { - $this->setRulesFromPropertyNames($initialPropertyNames); - - $this->subject->removeMatchingRules($propertyNameToRemove); - - $remainingRules = $this->subject->getRulesAssoc(); - if ($expectedRemainingPropertyNames === []) { - self::assertSame([], $remainingRules); - } - foreach ($expectedRemainingPropertyNames as $expectedPropertyName) { - self::assertArrayHasKey($expectedPropertyName, $remainingRules); - } - } - - /** - * @return array, 1: string, 2: list}> - */ - public static function providePropertyNamesAndPropertyNamePrefixToRemoveAndExpectedRemainingPropertyNames(): array - { - return [ - 'removing shorthand rule' => [ - ['font'], - 'font', - [], - ], - 'removing longhand rule' => [ - ['font-size'], - 'font', - [], - ], - 'removing shorthand and longhand rule' => [ - ['font', 'font-size'], - 'font', - [], - ], - 'removing shorthand rule with another kept' => [ - ['font', 'color'], - 'font', - ['color'], - ], - 'removing longhand rule with another kept' => [ - ['font-size', 'color'], - 'font', - ['color'], - ], - 'keeping other rules whose property names begin with the same characters' => [ - ['contain', 'container', 'container-type'], - 'contain', - ['container', 'container-type'], - ], - ]; - } - - /** - * @test - * - * @param list $initialPropertyNames - * - * @dataProvider providePropertyNamesAndPropertyNamePrefixToRemoveAndExpectedRemainingPropertyNames - */ - public function removeMatchingRulesRemovesRulesWithPropertyNamePrefix( - array $initialPropertyNames, - string $propertyNamePrefix - ): void { - $propertyNamePrefixWithHyphen = $propertyNamePrefix . '-'; - $this->setRulesFromPropertyNames($initialPropertyNames); - - $this->subject->removeMatchingRules($propertyNamePrefixWithHyphen); - - $remainingRules = $this->subject->getRulesAssoc(); - self::assertArrayNotHasKey($propertyNamePrefix, $remainingRules); - foreach (\array_keys($remainingRules) as $remainingPropertyName) { - self::assertStringStartsNotWith($propertyNamePrefixWithHyphen, $remainingPropertyName); - } - } - - /** - * @test - * - * @param list $initialPropertyNames - * @param list $expectedRemainingPropertyNames - * - * @dataProvider providePropertyNamesAndPropertyNamePrefixToRemoveAndExpectedRemainingPropertyNames - */ - public function removeMatchingRulesWithPropertyNamePrefixKeepsOtherRules( - array $initialPropertyNames, - string $propertyNamePrefix, - array $expectedRemainingPropertyNames - ): void { - $propertyNamePrefixWithHyphen = $propertyNamePrefix . '-'; - $this->setRulesFromPropertyNames($initialPropertyNames); - - $this->subject->removeMatchingRules($propertyNamePrefixWithHyphen); - - $remainingRules = $this->subject->getRulesAssoc(); - if ($expectedRemainingPropertyNames === []) { - self::assertSame([], $remainingRules); - } - foreach ($expectedRemainingPropertyNames as $expectedPropertyName) { - self::assertArrayHasKey($expectedPropertyName, $remainingRules); - } - } - - /** - * @test - * - * @param list $propertyNamesToRemove - * - * @dataProvider providePropertyNames - */ - public function removeAllRulesRemovesAllRules(array $propertyNamesToRemove): void - { - $this->setRulesFromPropertyNames($propertyNamesToRemove); - - $this->subject->removeAllRules(); - - self::assertSame([], $this->subject->getRules()); - } - - /** - * @test - * - * @param list $propertyNamesToSet - * - * @dataProvider providePropertyNames - */ - public function setRulesOnVirginSetsRulesWithoutPositionInOrder(array $propertyNamesToSet): void - { - $rulesToSet = self::createRulesFromPropertyNames($propertyNamesToSet); - - $this->subject->setRules($rulesToSet); - - self::assertSame($rulesToSet, $this->subject->getRules()); - } - - /** - * @return DataProvider, 1: list}> - */ - public static function provideInitialPropertyNamesAndPropertyNamesToSet(): DataProvider - { - return DataProvider::cross(self::providePropertyNames(), self::providePropertyNames()); - } - - /** - * @test - * - * @param list $initialPropertyNames - * @param list $propertyNamesToSet - * - * @dataProvider provideInitialPropertyNamesAndPropertyNamesToSet - */ - public function setRulesReplacesRules(array $initialPropertyNames, array $propertyNamesToSet): void - { - $rulesToSet = self::createRulesFromPropertyNames($propertyNamesToSet); - $this->setRulesFromPropertyNames($initialPropertyNames); - - $this->subject->setRules($rulesToSet); - - self::assertSame($rulesToSet, $this->subject->getRules()); - } - - /** - * @test - */ - public function setRulesWithRuleWithoutPositionSetsValidLineNumber(): void - { - $ruleToSet = new Rule('color'); - - $this->subject->setRules([$ruleToSet]); - - self::assertIsInt($ruleToSet->getLineNumber(), 'line number not set'); - self::assertGreaterThanOrEqual(1, $ruleToSet->getLineNumber(), 'line number not valid'); - } - - /** - * @test - */ - public function setRulesWithRuleWithoutPositionSetsValidColumnNumber(): void - { - $ruleToSet = new Rule('color'); - - $this->subject->setRules([$ruleToSet]); - - self::assertIsInt($ruleToSet->getColumnNumber(), 'column number not set'); - self::assertGreaterThanOrEqual(0, $ruleToSet->getColumnNumber(), 'column number not valid'); - } - - /** - * @test - */ - public function setRulesWithRuleWithOnlyLineNumberSetsColumnNumber(): void - { - $ruleToSet = new Rule('color'); - $ruleToSet->setPosition(42); - - $this->subject->setRules([$ruleToSet]); - - self::assertIsInt($ruleToSet->getColumnNumber(), 'column number not set'); - self::assertGreaterThanOrEqual(0, $ruleToSet->getColumnNumber(), 'column number not valid'); - } - - /** - * @test - */ - public function setRulesWithRuleWithOnlyLineNumberPreservesLineNumber(): void - { - $ruleToSet = new Rule('color'); - $ruleToSet->setPosition(42); - - $this->subject->setRules([$ruleToSet]); - - self::assertSame(42, $ruleToSet->getLineNumber(), 'line number not preserved'); - } - - /** - * @test - */ - public function setRulesWithRuleWithOnlyColumnNumberSetsLineNumber(): void - { - $ruleToSet = new Rule('color'); - $ruleToSet->setPosition(null, 42); - - $this->subject->setRules([$ruleToSet]); - - self::assertIsInt($ruleToSet->getLineNumber(), 'line number not set'); - self::assertGreaterThanOrEqual(1, $ruleToSet->getLineNumber(), 'line number not valid'); - } - - /** - * @test - */ - public function setRulesWithRuleWithOnlyColumnNumberPreservesColumnNumber(): void - { - $ruleToSet = new Rule('color'); - $ruleToSet->setPosition(null, 42); - - $this->subject->setRules([$ruleToSet]); - - self::assertSame(42, $ruleToSet->getColumnNumber(), 'column number not preserved'); - } - - /** - * @test - */ - public function setRulesWithRuleWithCompletePositionPreservesPosition(): void - { - $ruleToSet = new Rule('color'); - $ruleToSet->setPosition(42, 64); - - $this->subject->setRules([$ruleToSet]); - - self::assertSame(42, $ruleToSet->getLineNumber(), 'line number not preserved'); - self::assertSame(64, $ruleToSet->getColumnNumber(), 'column number not preserved'); - } - - /** - * @test - * - * @param list $propertyNamesToSet - * - * @dataProvider providePropertyNames - */ - public function getRulesReturnsRulesSet(array $propertyNamesToSet): void - { - $rulesToSet = self::createRulesFromPropertyNames($propertyNamesToSet); - $this->subject->setRules($rulesToSet); - - $result = $this->subject->getRules(); - - self::assertSame($rulesToSet, $result); - } - - /** - * @test - */ - public function getRulesOrdersByLineNumber(): void - { - $first = (new Rule('color'))->setPosition(1, 64); - $second = (new Rule('display'))->setPosition(19, 42); - $third = (new Rule('color'))->setPosition(55, 11); - $this->subject->setRules([$third, $second, $first]); - - $result = $this->subject->getRules(); - - self::assertSame([$first, $second, $third], $result); - } - - /** - * @test - */ - public function getRulesOrdersRulesWithSameLineNumberByColumnNumber(): void - { - $first = (new Rule('color'))->setPosition(1, 11); - $second = (new Rule('display'))->setPosition(1, 42); - $third = (new Rule('color'))->setPosition(1, 64); - $this->subject->setRules([$third, $second, $first]); - - $result = $this->subject->getRules(); - - self::assertSame([$first, $second, $third], $result); - } - - /** - * @return array, 1: string, 2: list}> - */ - public static function providePropertyNamesAndSearchPatternAndMatchingPropertyNames(): array - { - return [ - 'single rule matched' => [ - ['color'], - 'color', - ['color'], - ], - 'first rule matched' => [ - ['color', 'display'], - 'color', - ['color'], - ], - 'last rule matched' => [ - ['color', 'display'], - 'display', - ['display'], - ], - 'middle rule matched' => [ - ['color', 'display', 'width'], - 'display', - ['display'], - ], - 'multiple rules for the same property matched' => [ - ['color', 'color'], - 'color', - ['color'], - ], - 'multiple rules for the same property matched in haystack' => [ - ['color', 'display', 'color', 'width'], - 'color', - ['color'], - ], - 'shorthand rule matched' => [ - ['font'], - 'font-', - ['font'], - ], - 'longhand rule matched' => [ - ['font-size'], - 'font-', - ['font-size'], - ], - 'shorthand and longhand rule matched' => [ - ['font', 'font-size'], - 'font-', - ['font', 'font-size'], - ], - 'shorthand rule matched in haystack' => [ - ['font', 'color'], - 'font-', - ['font'], - ], - 'longhand rule matched in haystack' => [ - ['font-size', 'color'], - 'font-', - ['font-size'], - ], - 'rules whose property names begin with the same characters not matched with pattern match' => [ - ['contain', 'container', 'container-type'], - 'contain-', - ['contain'], - ], - 'rules whose property names begin with the same characters not matched with exact match' => [ - ['contain', 'container', 'container-type'], - 'contain', - ['contain'], - ], - ]; - } - - /** - * @test - * - * @param list $propertyNamesToSet - * @param list $matchingPropertyNames - * - * @dataProvider providePropertyNamesAndSearchPatternAndMatchingPropertyNames - */ - public function getRulesWithPatternReturnsAllMatchingRules( - array $propertyNamesToSet, - string $searchPattern, - array $matchingPropertyNames - ): void { - $rulesToSet = self::createRulesFromPropertyNames($propertyNamesToSet); - // Use `array_values` to ensure canonical numeric array, since `array_filter` does not reindex. - $matchingRules = \array_values( - \array_filter( - $rulesToSet, - static function (Rule $rule) use ($matchingPropertyNames): bool { - return \in_array($rule->getRule(), $matchingPropertyNames, true); - } - ) - ); - $this->subject->setRules($rulesToSet); - - $result = $this->subject->getRules($searchPattern); - - // `Rule`s without pre-set positions are returned in the order set. This is tested separately. - self::assertSame($matchingRules, $result); - } - - /** - * @return array, 1: string}> - */ - public static function providePropertyNamesAndNonMatchingSearchPattern(): array - { - return [ - 'no match in empty list' => [ - [], - 'color', - ], - 'no match for different property' => [ - ['color'], - 'display', - ], - 'no match for property not in list' => [ - ['color', 'display'], - 'width', - ], - ]; - } - - /** - * @test - * - * @param list $propertyNamesToSet - * - * @dataProvider providePropertyNamesAndNonMatchingSearchPattern - */ - public function getRulesWithNonMatchingPatternReturnsEmptyArray( - array $propertyNamesToSet, - string $searchPattern - ): void { - $this->setRulesFromPropertyNames($propertyNamesToSet); - - $result = $this->subject->getRules($searchPattern); - - self::assertSame([], $result); - } - - /** - * @test - */ - public function getRulesWithPatternOrdersRulesByPosition(): void - { - $first = (new Rule('color'))->setPosition(1, 42); - $second = (new Rule('color'))->setPosition(1, 64); - $third = (new Rule('color'))->setPosition(55, 7); - $this->subject->setRules([$third, $second, $first]); - - $result = $this->subject->getRules('color'); - - self::assertSame([$first, $second, $third], $result); - } - - /** - * @return array}> - */ - public static function provideDistinctPropertyNames(): array - { - return [ - 'no properties' => [[]], - 'one property' => [['color']], - 'two properties' => [['color', 'display']], - ]; - } - - /** - * @test - * - * @param list $propertyNamesToSet - * - * @dataProvider provideDistinctPropertyNames - */ - public function getRulesAssocReturnsAllRulesWithDistinctPropertyNames(array $propertyNamesToSet): void - { - $rulesToSet = self::createRulesFromPropertyNames($propertyNamesToSet); - $this->subject->setRules($rulesToSet); - - $result = $this->subject->getRulesAssoc(); - - self::assertSame($rulesToSet, \array_values($result)); - } - - /** - * @test - */ - public function getRulesAssocReturnsLastRuleWithSamePropertyName(): void - { - $firstRule = new Rule('color'); - $lastRule = new Rule('color'); - $this->subject->setRules([$firstRule, $lastRule]); - - $result = $this->subject->getRulesAssoc(); - - self::assertSame([$lastRule], \array_values($result)); - } - - /** - * @test - */ - public function getRulesAssocOrdersRulesByPosition(): void - { - $first = (new Rule('color'))->setPosition(1, 42); - $second = (new Rule('display'))->setPosition(1, 64); - $third = (new Rule('width'))->setPosition(55, 7); - $this->subject->setRules([$third, $second, $first]); - - $result = $this->subject->getRulesAssoc(); - - self::assertSame([$first, $second, $third], \array_values($result)); - } - - /** - * @test - */ - public function getRulesAssocKeysRulesByPropertyName(): void - { - $this->subject->setRules([new Rule('color'), new Rule('display')]); - - $result = $this->subject->getRulesAssoc(); - - foreach ($result as $key => $rule) { - self::assertSame($rule->getRule(), $key); - } - } - - /** - * @test - * - * @param list $propertyNamesToSet - * @param list $matchingPropertyNames - * - * @dataProvider providePropertyNamesAndSearchPatternAndMatchingPropertyNames - */ - public function getRulesAssocWithPatternReturnsAllMatchingPropertyNames( - array $propertyNamesToSet, - string $searchPattern, - array $matchingPropertyNames - ): void { - $this->setRulesFromPropertyNames($propertyNamesToSet); - - $result = $this->subject->getRulesAssoc($searchPattern); - - $resultPropertyNames = \array_keys($result); - \sort($matchingPropertyNames); - \sort($resultPropertyNames); - self::assertSame($matchingPropertyNames, $resultPropertyNames); - } - - /** - * @test - * - * @param list $propertyNamesToSet - * - * @dataProvider providePropertyNamesAndNonMatchingSearchPattern - */ - public function getRulesAssocWithNonMatchingPatternReturnsEmptyArray( - array $propertyNamesToSet, - string $searchPattern - ): void { - $this->setRulesFromPropertyNames($propertyNamesToSet); - - $result = $this->subject->getRulesAssoc($searchPattern); - - self::assertSame([], $result); - } - - /** - * @test - */ - public function getRulesAssocWithPatternOrdersRulesByPosition(): void - { - $first = (new Rule('font'))->setPosition(1, 42); - $second = (new Rule('font-family'))->setPosition(1, 64); - $third = (new Rule('font-weight'))->setPosition(55, 7); - $this->subject->setRules([$third, $second, $first]); - - $result = $this->subject->getRules('font-'); - - self::assertSame([$first, $second, $third], \array_values($result)); - } - - /** - * @param list $propertyNames - */ - private function setRulesFromPropertyNames(array $propertyNames): void - { - $this->subject->setRules(self::createRulesFromPropertyNames($propertyNames)); - } - - /** - * @param list $propertyNames - * - * @return list - */ - private static function createRulesFromPropertyNames(array $propertyNames): array - { - return \array_map( - function (string $propertyName): Rule { - return new Rule($propertyName); - }, - $propertyNames - ); - } -} diff --git a/tests/Unit/RuleSet/RuleSetTest.php b/tests/Unit/RuleSet/RuleSetTest.php index 92c056ca0..6ba574de2 100644 --- a/tests/Unit/RuleSet/RuleSetTest.php +++ b/tests/Unit/RuleSet/RuleSetTest.php @@ -14,7 +14,7 @@ */ final class RuleSetTest extends TestCase { - use RuleContainerTest; + use DeclarationListTests; /** * @var RuleSet @@ -79,4 +79,14 @@ public function getLineNumberReturnsLineNumberPassedToConstructor(?int $lineNumb self::assertSame($lineNumber, $result); } + + /** + * @test + */ + public function getArrayRepresentationThrowsException(): void + { + $this->expectException(\BadMethodCallException::class); + + $this->subject->getArrayRepresentation(); + } } diff --git a/tests/Unit/ShortClassNameProviderTest.php b/tests/Unit/ShortClassNameProviderTest.php new file mode 100644 index 000000000..c5d5694fa --- /dev/null +++ b/tests/Unit/ShortClassNameProviderTest.php @@ -0,0 +1,26 @@ +getTheShortClassName(); + + self::assertSame('ConcreteShortClassNameProvider', $result); + } +} diff --git a/tests/Unit/Value/CSSFunctionTest.php b/tests/Unit/Value/CSSFunctionTest.php new file mode 100644 index 000000000..4ec56afd2 --- /dev/null +++ b/tests/Unit/Value/CSSFunctionTest.php @@ -0,0 +1,40 @@ +getArrayRepresentation(); + + self::assertSame('CSSFunction', $result['class']); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesFunctionName(): void + { + $subject = new CSSFunction('filter', []); + + $result = $subject->getArrayRepresentation(); + + self::assertSame('filter', $result['name']); + } +} diff --git a/tests/Unit/Value/CSSStringTest.php b/tests/Unit/Value/CSSStringTest.php index c54ecdd75..6f6b5018f 100644 --- a/tests/Unit/Value/CSSStringTest.php +++ b/tests/Unit/Value/CSSStringTest.php @@ -5,6 +5,7 @@ namespace Sabberworm\CSS\Tests\Unit\Value; use PHPUnit\Framework\TestCase; +use Sabberworm\CSS\OutputFormat; use Sabberworm\CSS\Value\CSSString; use Sabberworm\CSS\Value\PrimitiveValue; use Sabberworm\CSS\Value\Value; @@ -80,4 +81,67 @@ public function getLineNumberReturnsLineNumberProvidedToConstructor(): void self::assertSame($lineNumber, $subject->getLineNumber()); } + + /** + * @test + */ + public function getArrayRepresentationIncludesClassName(): void + { + $subject = new CSSString(''); + + $result = $subject->getArrayRepresentation(); + + self::assertSame('CSSString', $result['class']); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesContents(): void + { + $contents = 'What is love?'; + $subject = new CSSString($contents); + + $result = $subject->getArrayRepresentation(); + + self::assertSame($contents, $result['contents']); + } + + /** + * @test + */ + public function doesNotEscapeSingleQuotesThatDoNotNeedToBeEscaped(): void + { + $input = "data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none'" . + " xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd'" . + " d='M14.3145 2 3V11.9987H17.5687L18 8.20761H14.3145L14.32 6.31012C14.32 5.32134 14.4207" . + ' 4.79153 15.9426 4.79153H17.977V1H14.7223C10.8129 1 9.43687 2.83909 9.43687 ' . + " 5.93187V8.20804H7V11.9991H9.43687V23H14.3145Z' fill='black'/%3E%3C/svg%3E%0A"; + + $outputFormat = OutputFormat::createPretty(); + + self::assertSame("\"{$input}\"", (new CSSString($input))->render($outputFormat)); + + $outputFormat->setStringQuotingType("'"); + + $expected = str_replace("'", "\\'", $input); + + self::assertSame("'{$expected}'", (new CSSString($input))->render($outputFormat)); + } + + /** + * @test + */ + public function doesNotEscapeDoubleQuotesThatDoNotNeedToBeEscaped(): void + { + $input = '"Hello World"'; + + $outputFormat = OutputFormat::createPretty(); + + self::assertSame('"\\"Hello World\\""', (new CSSString($input))->render($outputFormat)); + + $outputFormat->setStringQuotingType("'"); + + self::assertSame("'{$input}'", (new CSSString($input))->render($outputFormat)); + } } diff --git a/tests/Unit/Value/CalcFunctionTest.php b/tests/Unit/Value/CalcFunctionTest.php new file mode 100644 index 000000000..330c43b72 --- /dev/null +++ b/tests/Unit/Value/CalcFunctionTest.php @@ -0,0 +1,213 @@ +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]); + + $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]); + + $nestedCalc = $components[2]; + self::assertInstanceOf(CalcFunction::class, $nestedCalc); + + $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 + */ + private function parse(string $css): CalcFunction + { + $parserState = new ParserState($css, Settings::create()); + + $function = CalcFunction::parse($parserState); + self::assertInstanceOf(CalcFunction::class, $function); + return $function; + } + + /** + * @test + */ + public function getArrayRepresentationThrowsException(): void + { + $this->expectException(\BadMethodCallException::class); + + $subject = new CalcFunction('calc', []); + + $subject->getArrayRepresentation(); + } +} diff --git a/tests/Unit/Value/CalcRuleValueListTest.php b/tests/Unit/Value/CalcRuleValueListTest.php index 7314e3c51..a02c05f7d 100644 --- a/tests/Unit/Value/CalcRuleValueListTest.php +++ b/tests/Unit/Value/CalcRuleValueListTest.php @@ -56,4 +56,16 @@ public function separatorAlwaysIsComma(): void self::assertSame(',', $subject->getListSeparator()); } + + /** + * @test + */ + public function getArrayRepresentationThrowsException(): void + { + $this->expectException(\BadMethodCallException::class); + + $subject = new CalcRuleValueList(); + + $subject->getArrayRepresentation(); + } } diff --git a/tests/Unit/Value/ColorTest.php b/tests/Unit/Value/ColorTest.php index aaa257553..0311f9e5e 100644 --- a/tests/Unit/Value/ColorTest.php +++ b/tests/Unit/Value/ColorTest.php @@ -471,4 +471,16 @@ public function throwsExceptionWithInvalidColor(string $color): void Color::parse(new ParserState($color, Settings::create())); } + + /** + * @test + */ + public function getArrayRepresentationThrowsException(): void + { + $this->expectException(\BadMethodCallException::class); + + $subject = new Color([]); + + $subject->getArrayRepresentation(); + } } diff --git a/tests/Unit/Value/Fixtures/ConcreteValueList.php b/tests/Unit/Value/Fixtures/ConcreteValueList.php new file mode 100644 index 000000000..a9659d30c --- /dev/null +++ b/tests/Unit/Value/Fixtures/ConcreteValueList.php @@ -0,0 +1,9 @@ +expectException(\BadMethodCallException::class); + + $subject = new LineName(); + + $subject->getArrayRepresentation(); + } +} diff --git a/tests/Unit/Value/RuleValueListTest.php b/tests/Unit/Value/RuleValueListTest.php new file mode 100644 index 000000000..9284c4dbc --- /dev/null +++ b/tests/Unit/Value/RuleValueListTest.php @@ -0,0 +1,28 @@ +getArrayRepresentation(); + + self::assertSame('RuleValueList', $result['class']); + } +} diff --git a/tests/Unit/Value/SizeTest.php b/tests/Unit/Value/SizeTest.php index c80f788cd..bebed016b 100644 --- a/tests/Unit/Value/SizeTest.php +++ b/tests/Unit/Value/SizeTest.php @@ -98,4 +98,40 @@ public function parsesUnit(string $unit): void self::assertSame($unit, $parsedSize->getUnit()); } + + /** + * @test + */ + public function getArrayRepresentationIncludesClassName(): void + { + $subject = new Size(1); + + $result = $subject->getArrayRepresentation(); + + self::assertSame('Size', $result['class']); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesNumber(): void + { + $subject = new Size(1); + + $result = $subject->getArrayRepresentation(); + + self::assertSame(1.0, $result['number']); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesUnit(): void + { + $subject = new Size(1, 'px'); + + $result = $subject->getArrayRepresentation(); + + self::assertSame('px', $result['unit']); + } } diff --git a/tests/Unit/Value/URLTest.php b/tests/Unit/Value/URLTest.php index 66654e647..c8c632683 100644 --- a/tests/Unit/Value/URLTest.php +++ b/tests/Unit/Value/URLTest.php @@ -80,4 +80,35 @@ public function getLineNumberReturnsLineNumberProvidedToConstructor(): void self::assertSame($lineNumber, $subject->getLineNumber()); } + + /** + * @test + */ + public function getArrayRepresentationIncludesClassName(): void + { + $subject = new URL(new CSSString('https://example.com')); + + $result = $subject->getArrayRepresentation(); + + self::assertSame('URL', $result['class']); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesUri(): void + { + $uri = 'https://example.com'; + $subject = new URL(new CSSString($uri)); + + $result = $subject->getArrayRepresentation(); + + self::assertSame( + [ + 'class' => 'CSSString', + 'contents' => $uri, + ], + $result['uri'] + ); + } } diff --git a/tests/Unit/Value/ValueListTest.php b/tests/Unit/Value/ValueListTest.php new file mode 100644 index 000000000..b711fabbe --- /dev/null +++ b/tests/Unit/Value/ValueListTest.php @@ -0,0 +1,77 @@ +getArrayRepresentation(); + + self::assertSame('ConcreteValueList', $result['class']); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesStringComponent(): void + { + $subject = new ConcreteValueList(['Helvetica']); + + $result = $subject->getArrayRepresentation(); + + self::assertSame('Helvetica', $result['components'][0]['value']); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesValueComponent(): void + { + $subject = new ConcreteValueList([new Size(1)]); + + $result = $subject->getArrayRepresentation(); + + self::assertSame('Size', $result['components'][0]['class']); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesMultipleMixedComponents(): void + { + $subject = new ConcreteValueList([new Size(1), '+', new Size(2)]); + + $result = $subject->getArrayRepresentation(); + + self::assertSame('Size', $result['components'][0]['class']); + self::assertSame('+', $result['components'][1]['value']); + self::assertSame('Size', $result['components'][2]['class']); + } + + /** + * @test + */ + public function getArrayRepresentationIncludesSeparator(): void + { + $subject = new ConcreteValueList(); + + $result = $subject->getArrayRepresentation(); + + self::assertSame(',', $result['separator']); + } +} diff --git a/tests/Unit/Value/ValueTest.php b/tests/Unit/Value/ValueTest.php index 9a664a771..e9b36eef6 100644 --- a/tests/Unit/Value/ValueTest.php +++ b/tests/Unit/Value/ValueTest.php @@ -21,9 +21,7 @@ final class ValueTest extends TestCase /** * the default set of delimiters for parsing most values * - * @see \Sabberworm\CSS\Rule\Rule::listDelimiterForRule - * - * @var list + * @see \Sabberworm\CSS\Property\Declaration::getDelimitersForPropertyValue */ private const DEFAULT_DELIMITERS = [',', ' ', '/']; @@ -62,8 +60,42 @@ public function parsesArithmeticInFunctions(string $operator): void self::DEFAULT_DELIMITERS ); - self::assertInstanceOf(CSSFunction::class, $subject); - self::assertSame('max(300px,50vh ' . $operator . ' 10px)', $subject->render(OutputFormat::createCompact())); + $result = $subject->getArrayRepresentation(); + self::assertSame( + [ + 'class' => 'CSSFunction', + 'name' => 'max', + 'components' => [ + [ + 'class' => 'Size', + 'number' => 300.0, + 'unit' => 'px', + ], + [ + 'class' => 'RuleValueList', + 'components' => [ + [ + 'class' => 'Size', + 'number' => 50.0, + 'unit' => 'vh', + ], + [ + 'class' => 'string', + 'value' => $operator, + ], + [ + 'class' => 'Size', + 'number' => 10.0, + 'unit' => 'px', + ], + ], + 'separator' => ' ', + ], + ], + 'separator' => ',', + ], + $result + ); } /** @@ -143,4 +175,16 @@ public function parsesArithmeticWithMalformedOperandsInFunctions(string $leftOpe $subject->render(OutputFormat::createCompact()) ); } + + /** + * @test + */ + public function getArrayRepresentationIncludesClassName(): void + { + $subject = new ConcreteValue(); + + $result = $subject->getArrayRepresentation(); + + self::assertSame('ConcreteValue', $result['class']); + } } diff --git a/tests/UnitDeprecated/Rule/RuleTest.php b/tests/UnitDeprecated/Rule/RuleTest.php new file mode 100644 index 000000000..efe00a968 --- /dev/null +++ b/tests/UnitDeprecated/Rule/RuleTest.php @@ -0,0 +1,25 @@ +