diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 251e8fc4d..b35ea69f9 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') }} @@ -112,7 +112,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install PHP uses: shivammathur/setup-php@v2 @@ -126,7 +126,7 @@ jobs: run: composer config --global --list - name: Cache dependencies installed with composer - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/composer key: php${{ matrix.php-version }}-composer-${{ hashFiles('**/composer.json') }} @@ -145,4 +145,4 @@ jobs: phive --no-progress install --trust-gpg-keys 0FDE18AE1D09E19F60F6B1CBC00543248C87FB13,BBAB5DF0A0D6672989CF1869E82B2FB314E9906E - name: Run Command - run: composer ci:${{ matrix.command }} + run: composer check:${{ matrix.command }} diff --git a/.github/workflows/codecoverage.yml b/.github/workflows/codecoverage.yml index 29c37ba50..15b1c0546 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,7 +52,7 @@ jobs: composer show; - name: Run Tests - run: composer ci:tests:coverage + run: composer check:tests:coverage - name: Show generated coverage files run: ls -lah diff --git a/.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..d75a0e766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,14 +10,31 @@ Please also have a look at our ### Added +- Provide line number in exception message for mismatched parentheses in + selector (#1435) +- Add support for CSS container queries (#1400) + ### Changed +- Clean up extra whitespace in CSS selector (#1398) +- The array keys passed to `DeclarationBlock::setSelectors()` are no longer + preserved (#1407) + ### Deprecated ### Removed ### Fixed +- Reject selector comprising only whitespace (#1433) +- Improve recovery parsing when a rogue `}` is encountered (#1425, #1426) +- Parse comment(s) immediately preceding a selector (#1421, #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) ### Documentation 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..caaf9a54a 100644 --- a/composer.json +++ b/composer.json @@ -30,13 +30,14 @@ "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.50", "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": { @@ -67,31 +68,33 @@ } }, "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 config src tests", + "check:php:fixer": "\"./.phive/php-cs-fixer\" --config=config/php-cs-fixer.php fix --dry-run -v --show-progress=dots --diff bin src tests config", + "check:php:lint": "parallel-lint src tests config bin", + "check:php:rector": "rector --no-progress-bar --dry-run --config=config/rector.php", + "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,28 +102,32 @@ "fix:php": [ "@fix:composer:normalize", "@fix:php:rector", + "@fix:php:codesniffer", "@fix:php:fixer" ], + "fix:php:codesniffer": "phpcbf --standard=config/phpcs.xml config src tests", "fix:php:fixer": "\"./.phive/php-cs-fixer\" --config=config/php-cs-fixer.php fix bin src tests", "fix:php:rector": "rector --config=config/rector.php", "phpstan:baseline": "phpstan --configuration=config/phpstan.neon --generate-baseline=config/phpstan-baseline.neon --allow-empty-baseline" }, "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." diff --git a/config/php-cs-fixer.php b/config/php-cs-fixer.php index c10bf59ae..326105a0b 100644 --- a/config/php-cs-fixer.php +++ b/config/php-cs-fixer.php @@ -6,18 +6,18 @@ ->setRiskyAllowed(true) ->setRules( [ - '@PER-CS2.0' => true, - '@PER-CS2.0:risky' => true, - - '@PHPUnit50Migration:risky' => true, - '@PHPUnit52Migration:risky' => true, - '@PHPUnit54Migration:risky' => true, - '@PHPUnit55Migration:risky' => true, - '@PHPUnit56Migration:risky' => true, - '@PHPUnit57Migration:risky' => true, - '@PHPUnit60Migration:risky' => true, - '@PHPUnit75Migration:risky' => true, - '@PHPUnit84Migration:risky' => true, + '@PER-CS2x0' => true, + '@PER-CS2x0:risky' => true, + + '@PHPUnit5x0Migration:risky' => true, + '@PHPUnit5x2Migration:risky' => true, + '@PHPUnit5x4Migration:risky' => true, + '@PHPUnit5x5Migration:risky' => true, + '@PHPUnit5x6Migration:risky' => true, + '@PHPUnit5x7Migration:risky' => true, + '@PHPUnit6x0Migration:risky' => true, + '@PHPUnit7x5Migration:risky' => true, + '@PHPUnit8x4Migration:risky' => true, // overwrite the PER2 defaults to restore compatibility with PHP 7.x 'trailing_comma_in_multiline' => ['elements' => ['arrays']], 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..4c4add4da 100644 --- a/config/phpstan-baseline.neon +++ b/config/phpstan-baseline.neon @@ -1,11 +1,5 @@ parameters: ignoreErrors: - - - message: '#^Loose comparison via "\=\=" is not allowed\.$#' - identifier: equal.notAllowed - count: 1 - path: ../src/CSSList/CSSList.php - - message: '#^Parameter \#2 \$found of class Sabberworm\\CSS\\Parsing\\UnexpectedTokenException constructor expects string, Sabberworm\\CSS\\Value\\CSSFunction\|Sabberworm\\CSS\\Value\\CSSString\|Sabberworm\\CSS\\Value\\LineName\|Sabberworm\\CSS\\Value\\Size\|Sabberworm\\CSS\\Value\\URL given\.$#' identifier: argument.type diff --git a/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/CSSList.php b/src/CSSList/CSSList.php index e09a03a98..f18cd7725 100644 --- a/src/CSSList/CSSList.php +++ b/src/CSSList/CSSList.php @@ -72,9 +72,15 @@ public static function parseList(ParserState $parserState, CSSList $list): void $listItem = null; if ($usesLenientParsing) { try { + $positionBeforeParse = $parserState->currentColumn(); $listItem = self::parseListItem($parserState, $list); } catch (UnexpectedTokenException $e) { $listItem = false; + // If the failed parsing did not consume anything that was to come ... + if ($parserState->currentColumn() === $positionBeforeParse && !$parserState->isEnd()) { + // ... the unexpected token needs to be skipped, otherwise there'll be an infinite loop. + $parserState->consume(1); + } } } else { $listItem = self::parseListItem($parserState, $list); @@ -133,7 +139,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()); } @@ -370,7 +377,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 +434,34 @@ public function getContents(): array { return $this->contents; } + + /** + * @param list $selectors1 + * @param list $selectors2 + */ + private static function selectorsMatch(array $selectors1, array $selectors2): bool + { + $selectorStrings1 = self::getSelectorStrings($selectors1); + $selectorStrings2 = self::getSelectorStrings($selectors2); + + \sort($selectorStrings1); + \sort($selectorStrings2); + + return $selectorStrings1 === $selectorStrings2; + } + + /** + * @param list $selectors + * + * @return list + */ + private static function getSelectorStrings(array $selectors): array + { + return \array_map( + static function (Selector $selector): string { + return $selector->getSelector(); + }, + $selectors + ); + } } diff --git a/src/Comment/Comment.php b/src/Comment/Comment.php index 33188988f..6f29682c2 100644 --- a/src/Comment/Comment.php +++ b/src/Comment/Comment.php @@ -5,9 +5,9 @@ namespace Sabberworm\CSS\Comment; use Sabberworm\CSS\OutputFormat; -use Sabberworm\CSS\Renderable; use Sabberworm\CSS\Position\Position; use Sabberworm\CSS\Position\Positionable; +use Sabberworm\CSS\Renderable; class Comment implements Positionable, Renderable { diff --git a/src/Parsing/ParserState.php b/src/Parsing/ParserState.php index cc69ed974..44f96a944 100644 --- a/src/Parsing/ParserState.php +++ b/src/Parsing/ParserState.php @@ -16,9 +16,6 @@ */ class ParserState { - /** - * @var null - */ public const EOF = null; /** @@ -195,6 +192,10 @@ public function parseCharacter(bool $isForIdentifier): ?string * * @throws UnexpectedEOFException * @throws UnexpectedTokenException + * + * @phpstan-impure + * This method may change the state of the object by advancing the internal position; + * it does not simply 'get' a value. */ public function consumeWhiteSpace(): array { @@ -282,6 +283,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 +353,7 @@ public function isEnd(): bool /** * @param list|string|self::EOF $stopCharacters - * @param array $comments + * @param list $comments * * @throws UnexpectedEOFException * @throws UnexpectedTokenException @@ -346,6 +368,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 +380,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 +478,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..f5ae475ea 100644 --- a/src/Property/AtRule.php +++ b/src/Property/AtRule.php @@ -16,11 +16,9 @@ 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'; /** * @return non-empty-string 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..b935b2a82 100644 --- a/src/Property/Selector.php +++ b/src/Property/Selector.php @@ -9,6 +9,7 @@ use Sabberworm\CSS\Renderable; use function Safe\preg_match; +use function Safe\preg_replace; /** * Class representing a single CSS selector. Selectors have to be split by the comma prior to being passed into this @@ -17,8 +18,6 @@ class Selector implements Renderable { /** - * @var non-empty-string - * * @internal since 8.5.2 */ public const SELECTOR_VALIDATION_RX = '/ @@ -76,7 +75,12 @@ public function getSelector(): string public function setSelector(string $selector): void { - $this->selector = \trim($selector); + $selector = \trim($selector); + + $hasAttribute = \strpos($selector, '[') !== false; + + // Whitespace can't be adjusted within an attribute selector, as it would change its meaning + $this->selector = !$hasAttribute ? preg_replace('/\\s++/', ' ', $selector) : $selector; } /** diff --git a/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/RuleSet/DeclarationBlock.php b/src/RuleSet/DeclarationBlock.php index 4d0775460..a3921815f 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; @@ -19,6 +20,7 @@ use Sabberworm\CSS\Property\KeyframeSelector; use Sabberworm\CSS\Property\Selector; use Sabberworm\CSS\Rule\Rule; +use Sabberworm\CSS\Settings; /** * This class represents a `RuleSet` constrained by a `Selector`. @@ -36,7 +38,7 @@ class DeclarationBlock implements CSSElement, CSSListItem, Positionable, RuleCon use Position; /** - * @var array + * @var list */ private $selectors = []; @@ -65,66 +67,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, $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 +97,31 @@ 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. + // Parse as if it's the opening part of a rule. + try { + $parserState = new ParserState($selectors . '{', Settings::create()); + $selectorsToSet = self::parseSelectors($parserState); + $parserState->consume('{'); // throw exception if this is not next + if (!$parserState->isEnd()) { + throw new UnexpectedTokenException('EOF', 'more'); + } + } catch (UnexpectedTokenException $exception) { + // The exception message from parsing may refer to the faux `{` block start token, + // which would be confusing. + // Rethrow with a more useful message, that also includes the selector(s) string that was passed. + throw new UnexpectedTokenException( + 'Selector(s) string is not valid.', + $selectors, + 'custom' + ); + } } - 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 +131,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 +140,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 +169,7 @@ public function removeSelector($selectorToRemove): bool } /** - * @return array + * @return list */ public function getSelectors(): array { @@ -216,9 +190,9 @@ public function addRule(Rule $ruleToAdd, ?Rule $sibling = null): void } /** - * @see RuleSet::getRules() - * * @return array, Rule> + * + * @see RuleSet::getRules() */ public function getRules(?string $searchPattern = null): array { @@ -226,9 +200,9 @@ public function getRules(?string $searchPattern = null): array } /** - * @see RuleSet::setRules() - * * @param array $rules + * + * @see RuleSet::setRules() */ public function setRules(array $rules): void { @@ -236,9 +210,9 @@ public function setRules(array $rules): void } /** - * @see RuleSet::getRulesAssoc() - * * @return array + * + * @see RuleSet::getRulesAssoc() */ public function getRulesAssoc(?string $searchPattern = null): array { @@ -298,4 +272,98 @@ public function render(OutputFormat $outputFormat): string return $result; } + + /** + * @param list $comments + * + * @return list + * + * @throws UnexpectedTokenException + */ + private static function parseSelectors(ParserState $parserState, array &$comments = []): array + { + $selectors = []; + + while (true) { + $selectors[] = self::parseSelector($parserState, $comments); + if (!$parserState->consumeIfComes(',')) { + break; + } + } + + return $selectors; + } + + /** + * @param list $comments + * + * @throws UnexpectedTokenException + */ + private static function parseSelector(ParserState $parserState, array &$comments = []): string + { + $selectorParts = []; + $stringWrapperCharacter = null; + $functionNestingLevel = 0; + static $stopCharacters = ['{', '}', '\'', '"', '(', ')', ',']; + + while (true) { + $selectorParts[] = $parserState->consumeUntil($stopCharacters, false, false, $comments); + $nextCharacter = $parserState->peek(); + 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', + ')', + 'literal', + $parserState->currentLine() + ); + } + --$functionNestingLevel; + } + break; + case '{': + // The fallthrough is intentional. + case '}': + if (!\is_string($stringWrapperCharacter)) { + break 2; + } + break; + case ',': + if (!\is_string($stringWrapperCharacter) && $functionNestingLevel === 0) { + break 2; + } + break; + } + $selectorParts[] = $parserState->consume(1); + } + + if ($functionNestingLevel !== 0) { + throw new UnexpectedTokenException(')', $nextCharacter, 'literal', $parserState->currentLine()); + } + + $selector = \trim(\implode('', $selectorParts)); + if ($selector === '') { + throw new UnexpectedTokenException('selector', $nextCharacter, 'literal', $parserState->currentLine()); + } + + return $selector; + } } diff --git a/src/Value/CalcFunction.php b/src/Value/CalcFunction.php index dba6e1dd9..f674ed151 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()} ", diff --git a/src/Value/Size.php b/src/Value/Size.php index eac736d79..e8e2a1d00 100644 --- a/src/Value/Size.php +++ b/src/Value/Size.php @@ -19,8 +19,6 @@ class Size extends PrimitiveValue { /** * vh/vw/vm(ax)/vmin/rem are absolute insofar as they don’t scale to the immediate parent (only the viewport) - * - * @var list */ private const ABSOLUTE_SIZE_UNITS = [ 'px', @@ -40,14 +38,8 @@ class Size extends PrimitiveValue 'rem', ]; - /** - * @var list - */ private const RELATIVE_SIZE_UNITS = ['%', 'em', 'ex', 'ch', 'fr']; - /** - * @var list - */ private const NON_SIZE_UNITS = ['deg', 'grad', 'rad', 's', 'ms', 'turn', 'Hz', 'kHz']; /** diff --git a/tests/CSSList/AtRuleBlockListTest.php b/tests/CSSList/AtRuleBlockListTest.php index 9a725b212..3539463bf 100644 --- a/tests/CSSList/AtRuleBlockListTest.php +++ b/tests/CSSList/AtRuleBlockListTest.php @@ -48,6 +48,9 @@ public static function provideSyntacticallyCorrectAtRule(): array } ', ], + 'container' => [ + '@container (min-width: 60rem) { .items { background: blue; } }', + ], ]; } diff --git a/tests/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/Value/ValueTest.php b/tests/Functional/Value/ValueTest.php index 4d65ee2a8..b8dd5b4f1 100644 --- a/tests/Functional/Value/ValueTest.php +++ b/tests/Functional/Value/ValueTest.php @@ -20,8 +20,6 @@ final class ValueTest extends TestCase * the default set of delimiters for parsing most values * * @see \Sabberworm\CSS\Rule\Rule::listDelimiterForRule - * - * @var list */ private const DEFAULT_DELIMITERS = [',', ' ', '/']; diff --git a/tests/OutputFormatTest.php b/tests/OutputFormatTest.php index 3a8deb30e..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..97e0d09b5 100644 --- a/tests/ParserTest.php +++ b/tests/ParserTest.php @@ -156,14 +156,11 @@ public function colorParsing(): void } self::assertSame( '#mine {color: red;border-color: #0a64e6;border-color: rgba(10,100,231,.3);outline-color: #222;' - . 'background-color: #232323;}' - . "\n" - . '#yours {background-color: hsl(220,10%,220%);background-color: hsla(220,10%,220%,.3);}' - . "\n" + . "background-color: #232323;}\n" + . "#yours {background-color: hsl(220,10%,220%);background-color: hsla(220,10%,220%,.3);}\n" . '#variables {background-color: rgb(var(--some-rgb));background-color: rgb(var(--r),var(--g),var(--b));' . 'background-color: rgb(255,var(--g),var(--b));background-color: rgb(255,255,var(--b));' - . 'background-color: rgb(255,var(--rg));background-color: hsl(var(--some-hsl));}' - . "\n" + . "background-color: rgb(255,var(--rg));background-color: hsl(var(--some-hsl));}\n" . '#variables-alpha {background-color: rgba(var(--some-rgb),.1);' . 'background-color: rgba(var(--some-rg),255,.1);background-color: hsla(var(--some-hsl),.1);}', $document->render() @@ -265,35 +262,21 @@ public function manipulation(): void { $document = self::parsedStructureForFile('atrules'); self::assertSame( - '@charset "utf-8";' - . "\n" - . '@font-face {font-family: "CrassRoots";src: url("../media/cr.ttf");}' - . "\n" - . 'html, body {font-size: -.6em;}' - . "\n" - . '@keyframes mymove {from {top: 0px;}' - . "\n\t" - . 'to {top: 200px;}}' - . "\n" - . '@-moz-keyframes some-move {from {top: 0px;}' - . "\n\t" - . 'to {top: 200px;}}' - . "\n" + "@charset \"utf-8\";\n" + . "@font-face {font-family: \"CrassRoots\";src: url(\"../media/cr.ttf\");}\n" + . "html, body {font-size: -.6em;}\n" + . "@keyframes mymove {from {top: 0px;}\n" + . "\tto {top: 200px;}}\n" + . "@-moz-keyframes some-move {from {top: 0px;}\n" + . "\tto {top: 200px;}}\n" . '@supports ( (perspective: 10px) or (-moz-perspective: 10px) or (-webkit-perspective: 10px) or ' - . '(-ms-perspective: 10px) or (-o-perspective: 10px) ) {body {font-family: "Helvetica";}}' - . "\n" - . '@page :pseudo-class {margin: 2in;}' - . "\n" - . '@-moz-document url(https://www.w3.org/),' - . "\n" - . ' url-prefix(https://www.w3.org/Style/),' - . "\n" - . ' domain(mozilla.org),' - . "\n" - . ' regexp("https:.*") {body {color: purple;background: yellow;}}' - . "\n" - . '@media screen and (orientation: landscape) {@-ms-viewport {width: 1024px;height: 768px;}}' - . "\n" + . "(-ms-perspective: 10px) or (-o-perspective: 10px) ) {body {font-family: \"Helvetica\";}}\n" + . "@page :pseudo-class {margin: 2in;}\n" + . "@-moz-document url(https://www.w3.org/),\n" + . " url-prefix(https://www.w3.org/Style/),\n" + . " domain(mozilla.org),\n" + . " regexp(\"https:.*\") {body {color: purple;background: yellow;}}\n" + . "@media screen and (orientation: landscape) {@-ms-viewport {width: 1024px;height: 768px;}}\n" . '@region-style #intro {p {color: blue;}}', $document->render() ); @@ -304,35 +287,21 @@ public function manipulation(): void } } self::assertSame( - '@charset "utf-8";' - . "\n" - . '@font-face {font-family: "CrassRoots";src: url("../media/cr.ttf");}' - . "\n" - . '#my_id html, #my_id body {font-size: -.6em;}' - . "\n" - . '@keyframes mymove {from {top: 0px;}' - . "\n\t" - . 'to {top: 200px;}}' - . "\n" - . '@-moz-keyframes some-move {from {top: 0px;}' - . "\n\t" - . 'to {top: 200px;}}' - . "\n" + "@charset \"utf-8\";\n" + . "@font-face {font-family: \"CrassRoots\";src: url(\"../media/cr.ttf\");}\n" + . "#my_id html, #my_id body {font-size: -.6em;}\n" + . "@keyframes mymove {from {top: 0px;}\n" + . "\tto {top: 200px;}}\n" + . "@-moz-keyframes some-move {from {top: 0px;}\n" + . "\tto {top: 200px;}}\n" . '@supports ( (perspective: 10px) or (-moz-perspective: 10px) or (-webkit-perspective: 10px) ' - . 'or (-ms-perspective: 10px) or (-o-perspective: 10px) ) {#my_id body {font-family: "Helvetica";}}' - . "\n" - . '@page :pseudo-class {margin: 2in;}' - . "\n" - . '@-moz-document url(https://www.w3.org/),' - . "\n" - . ' url-prefix(https://www.w3.org/Style/),' - . "\n" - . ' domain(mozilla.org),' - . "\n" - . ' regexp("https:.*") {#my_id body {color: purple;background: yellow;}}' - . "\n" - . '@media screen and (orientation: landscape) {@-ms-viewport {width: 1024px;height: 768px;}}' - . "\n" + . "or (-ms-perspective: 10px) or (-o-perspective: 10px) ) {#my_id body {font-family: \"Helvetica\";}}\n" + . "@page :pseudo-class {margin: 2in;}\n" + . "@-moz-document url(https://www.w3.org/),\n" + . " url-prefix(https://www.w3.org/Style/),\n" + . " domain(mozilla.org),\n" + . " regexp(\"https:.*\") {#my_id body {color: purple;background: yellow;}}\n" + . "@media screen and (orientation: landscape) {@-ms-viewport {width: 1024px;height: 768px;}}\n" . '@region-style #intro {#my_id p {color: blue;}}', $document->render(OutputFormat::create()->setRenderComments(false)) ); @@ -438,18 +407,14 @@ public function slashedValues(): void public function functionSyntax(): void { $document = self::parsedStructureForFile('functions'); - $expected = 'div.main {background-image: linear-gradient(#000,#fff);}' - . "\n" + $expected = "div.main {background-image: linear-gradient(#000,#fff);}\n" . '.collapser::before, .collapser::-moz-before, .collapser::-webkit-before {content: "»";font-size: 1.2em;' . 'margin-right: .2em;-moz-transition-property: -moz-transform;-moz-transition-duration: .2s;' - . '-moz-transform-origin: center 60%;}' - . "\n" + . "-moz-transform-origin: center 60%;}\n" . '.collapser.expanded::before, .collapser.expanded::-moz-before,' - . ' .collapser.expanded::-webkit-before {-moz-transform: rotate(90deg);}' - . "\n" + . " .collapser.expanded::-webkit-before {-moz-transform: rotate(90deg);}\n" . '.collapser + * {height: 0;overflow: hidden;-moz-transition-property: height;' - . '-moz-transition-duration: .3s;}' - . "\n" + . "-moz-transition-duration: .3s;}\n" . '.collapser.expanded + * {height: auto;}'; self::assertSame($expected, $document->render()); @@ -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()); } @@ -1049,10 +1001,9 @@ public function commentExtracting(): void $fooBarBlock = $nodes[1]; self::assertInstanceOf(Commentable::class, $fooBarBlock); $fooBarBlockComments = $fooBarBlock->getComments(); - // TODO Support comments in selectors. - // $this->assertCount(2, $fooBarBlockComments); - // $this->assertSame("* Number 4 *", $fooBarBlockComments[0]->getComment()); - // $this->assertSame("* Number 5 *", $fooBarBlockComments[1]->getComment()); + self::assertCount(2, $fooBarBlockComments); + self::assertSame(' Number 4 ', $fooBarBlockComments[0]->getComment()); + self::assertSame(' Number 5 ', $fooBarBlockComments[1]->getComment()); // Declaration rules. self::assertInstanceOf(DeclarationBlock::class, $fooBarBlock); diff --git a/tests/Unit/CSSList/CSSListTest.php b/tests/Unit/CSSList/CSSListTest.php index ada176e9a..548acbb77 100644 --- a/tests/Unit/CSSList/CSSListTest.php +++ b/tests/Unit/CSSList/CSSListTest.php @@ -7,10 +7,15 @@ use PHPUnit\Framework\TestCase; use Sabberworm\CSS\Comment\Commentable; use Sabberworm\CSS\CSSElement; +use Sabberworm\CSS\CSSList\CSSList; use Sabberworm\CSS\CSSList\CSSListItem; +use Sabberworm\CSS\CSSList\Document; +use Sabberworm\CSS\OutputFormat; +use Sabberworm\CSS\Parsing\ParserState; use Sabberworm\CSS\Property\Selector; use Sabberworm\CSS\Renderable; use Sabberworm\CSS\RuleSet\DeclarationBlock; +use Sabberworm\CSS\Settings; use Sabberworm\CSS\Tests\Unit\CSSList\Fixtures\ConcreteCSSList; /** @@ -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,37 @@ public function removeDeclarationBlockBySelectorRemovesMultipleBlocksWithStringS self::assertSame([], $subject->getContents()); } + + /** + * The content provided must (currently) be in the same format as the expected rendering. + * + * @return array + */ + public function provideValidContentForParsing(): array + { + return [ + 'at-import rule' => ['@import url("foo.css");'], + 'rule with declaration block' => ['a {color: green;}'], + ]; + } + + /** + * @test + * + * @param non-empty-string $followingContent + * + * @dataProvider provideValidContentForParsing + */ + public function parseListAtRootLevelSkipsErroneousClosingBraceAndParsesFollowingContent( + string $followingContent + ): void { + $parserState = new ParserState('}' . $followingContent, Settings::create()); + // The subject needs to be a `Document`, as that is currently the test for 'root level'. + // Otherwise `}` will be treated as 'end of list'. + $subject = new Document(); + + CSSList::parseList($parserState, $subject); + + self::assertSame($followingContent, $subject->render(new OutputFormat())); + } } diff --git a/tests/Unit/Parsing/ParserStateTest.php b/tests/Unit/Parsing/ParserStateTest.php new file mode 100644 index 000000000..bd22508ab --- /dev/null +++ b/tests/Unit/Parsing/ParserStateTest.php @@ -0,0 +1,160 @@ + + * } + * > + */ + 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()); + } +} diff --git a/tests/Unit/Property/AtRuleTest.php b/tests/Unit/Property/AtRuleTest.php new file mode 100644 index 000000000..6094fbf83 --- /dev/null +++ b/tests/Unit/Property/AtRuleTest.php @@ -0,0 +1,25 @@ + 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()); + } } diff --git a/tests/Unit/RuleSet/DeclarationBlockTest.php b/tests/Unit/RuleSet/DeclarationBlockTest.php index 4b20e9fc8..980ea4004 100644 --- a/tests/Unit/RuleSet/DeclarationBlockTest.php +++ b/tests/Unit/RuleSet/DeclarationBlockTest.php @@ -5,9 +5,11 @@ 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\Selector; use Sabberworm\CSS\Rule\Rule; @@ -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()); } /** @@ -278,4 +450,114 @@ 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); + } + + /** + * @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/Value/CalcFunctionTest.php b/tests/Unit/Value/CalcFunctionTest.php new file mode 100644 index 000000000..65f23e131 --- /dev/null +++ b/tests/Unit/Value/CalcFunctionTest.php @@ -0,0 +1,201 @@ +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; + } +} diff --git a/tests/Unit/Value/ValueTest.php b/tests/Unit/Value/ValueTest.php index 9a664a771..b7279c6e9 100644 --- a/tests/Unit/Value/ValueTest.php +++ b/tests/Unit/Value/ValueTest.php @@ -22,8 +22,6 @@ final class ValueTest extends TestCase * the default set of delimiters for parsing most values * * @see \Sabberworm\CSS\Rule\Rule::listDelimiterForRule - * - * @var list */ private const DEFAULT_DELIMITERS = [',', ' ', '/'];