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 = [',', ' ', '/'];