diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 000000000..833336b3d
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,17 @@
+# top-most EditorConfig file
+root = true
+
+# Unix-style newlines with a newline ending every file, and with sane defaults
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+indent_style = space
+indent_size = 4
+max_line_length = 120
+
+[*.md]
+max_line_length = 80
+# GitHub-flavored markdown uses two spaces and the end of a line to indicate a linebreak.
+trim_trailing_whitespace = false
diff --git a/.gitattributes b/.gitattributes
index 47213ad6f..82a4f0b95 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,8 +1,12 @@
+/.editorconfig export-ignore
/.gitattributes export-ignore
/.github/ export-ignore
/.gitignore export-ignore
-/.phive/
-/Doxyfile export-ignore
-/phpcs.xml export-ignore
+/.phive/ export-ignore
+/CODE_OF_CONDUCT.md export-ignore
+/CONTRIBUTING.md export-ignore
+/bin/ export-ignore
+/config/ export-ignore
+/docs/ export-ignore
/phpunit.xml export-ignore
-/tests export-ignore
+/tests/ export-ignore
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 000000000..4f6aacc9d
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,27 @@
+# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ commit-message:
+ prefix: "[Dependabot] "
+ milestone: 1
+
+ - package-ecosystem: "composer"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ allow:
+ - dependency-type: "development"
+ ignore:
+ - dependency-name: "phpstan/*"
+ - dependency-name: "phpunit/phpunit"
+ versions: [ ">= 9.0.0" ]
+ - dependency-name: "rector/rector"
+ versioning-strategy: "increase"
+ commit-message:
+ prefix: "[Dependabot] "
+ milestone: 1
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fe033c92f..14624a482 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,80 +1,146 @@
# https://help.github.com/en/categories/automating-your-workflow-with-github-actions
on:
- pull_request:
- push:
- schedule:
- - cron: '3 3 * * 1'
+ push:
+ branches:
+ - main
+ pull_request:
+ schedule:
+ - cron: '3 3 * * 1'
name: CI
jobs:
- php-lint:
- name: PHP Lint
- runs-on: ubuntu-20.04
- strategy:
- matrix:
- php-version: [ '5.6', '7.0', '7.1', '7.2', '7.3', '7.4' ]
-
- steps:
- - name: Checkout
- uses: actions/checkout@v2
-
- - name: Install PHP
- uses: shivammathur/setup-php@v2
- with:
- php-version: ${{ matrix.php-version }}
- coverage: none
-
- - name: PHP Lint
- run: find lib tests -name '*.php' -print0 | xargs -0 -n 1 -P 4 php -l
-
- unit-tests:
- name: Unit tests
-
- runs-on: ubuntu-20.04
-
- needs: [ php-lint ]
-
- strategy:
- fail-fast: false
- matrix:
- php-version: [ '5.6', '7.0', '7.1', '7.2', '7.3' ]
- coverage: [ 'none' ]
- include:
- - php-version: 7.4
- coverage: xdebug
-
- steps:
- - name: Checkout
- uses: actions/checkout@v2
-
- - name: Install PHP
- uses: shivammathur/setup-php@v2
- with:
- php-version: ${{ matrix.php-version }}
- tools: composer:v2
- coverage: "${{ matrix.coverage }}"
-
- - name: Cache dependencies installed with composer
- uses: actions/cache@v1
- with:
- path: ~/.cache/composer
- key: php${{ matrix.php-version }}-composer-${{ hashFiles('**/composer.json') }}
- restore-keys: |
- php${{ matrix.php-version }}-composer-
-
- - name: Install Composer dependencies
- run: |
- composer update --with-dependencies --no-progress;
- composer show;
-
- - name: Run Tests
- run: ./vendor/bin/phpunit --coverage-clover build/coverage/xml
-
- - name: Upload coverage results to Codacy
- env:
- CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }}
- if: "${{ matrix.coverage != 'none' && env.CODACY_PROJECT_TOKEN != '' }}"
- run: |
- ./vendor/bin/codacycoverage clover build/coverage/xml
+ php-lint:
+ name: PHP Lint
+ runs-on: ubuntu-22.04
+ strategy:
+ matrix:
+ php-version: [ '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4' ]
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ ini-file: development
+ tools: composer:v2
+ coverage: none
+
+ - name: Show the Composer configuration
+ run: composer config --global --list
+
+ - name: Cache dependencies installed with composer
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/composer
+ key: php${{ matrix.php-version }}-composer-${{ hashFiles('**/composer.json') }}
+ restore-keys: |
+ php${{ matrix.php-version }}-composer-
+
+ - name: Install Composer dependencies
+ run: |
+ composer update --with-dependencies --no-progress;
+ composer show;
+
+ - name: PHP Lint
+ run: composer ci:php:lint
+
+ unit-tests:
+ name: Unit tests
+
+ runs-on: ubuntu-22.04
+
+ needs: [ php-lint ]
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php-version: [ '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4' ]
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ ini-file: development
+ tools: composer:v2
+ coverage: none
+
+ - name: Show the Composer configuration
+ run: composer config --global --list
+
+ - name: Cache dependencies installed with composer
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/composer
+ key: php${{ matrix.php-version }}-composer-${{ hashFiles('**/composer.json') }}
+ restore-keys: |
+ php${{ matrix.php-version }}-composer-
+
+ - name: Install Composer dependencies
+ run: |
+ composer update --with-dependencies --no-progress;
+ composer show;
+
+ - name: Run Tests
+ run: ./vendor/bin/phpunit
+
+ static-analysis:
+ name: Static Analysis
+
+ runs-on: ubuntu-22.04
+
+ needs: [ php-lint ]
+
+ strategy:
+ fail-fast: false
+ matrix:
+ command:
+ - composer:normalize
+ - php:fixer
+ - php:stan
+ - php:rector
+ php-version:
+ - '8.3'
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ ini-file: development
+ tools: "composer:v2, phive"
+ coverage: none
+
+ - name: Show the Composer configuration
+ run: composer config --global --list
+
+ - name: Cache dependencies installed with composer
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/composer
+ key: php${{ matrix.php-version }}-composer-${{ hashFiles('**/composer.json') }}
+ restore-keys: |
+ php${{ matrix.php-version }}-composer-
+
+ - name: Install Composer dependencies
+ run: |
+ composer update --with-dependencies --no-progress;
+ composer show;
+
+ - name: Install development tools
+ run: |
+ phive --no-progress install --trust-gpg-keys 0FDE18AE1D09E19F60F6B1CBC00543248C87FB13,BBAB5DF0A0D6672989CF1869E82B2FB314E9906E
+
+ - name: Run Command
+ run: composer ci:${{ matrix.command }}
diff --git a/.github/workflows/codecoverage.yml b/.github/workflows/codecoverage.yml
new file mode 100644
index 000000000..dff381973
--- /dev/null
+++ b/.github/workflows/codecoverage.yml
@@ -0,0 +1,64 @@
+# https://help.github.com/en/categories/automating-your-workflow-with-github-actions
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+name: Code coverage
+
+jobs:
+ code-coverage:
+ name: Code coverage
+
+ runs-on: ubuntu-22.04
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php-version:
+ - '7.4'
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ ini-file: development
+ tools: composer:v2
+ coverage: xdebug
+
+ - name: Show the Composer version
+ run: composer --version
+
+ - name: Show the Composer configuration
+ run: composer config --global --list
+
+ - name: Cache dependencies installed with composer
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/composer
+ key: php${{ matrix.php-version }}-composer-${{ hashFiles('**/composer.json') }}
+ restore-keys: |
+ php${{ matrix.php-version }}-composer-
+
+ - name: Install Composer dependencies
+ run: |
+ composer update --with-dependencies --no-progress;
+ composer show;
+
+ - name: Run Tests
+ run: composer ci:tests:coverage
+
+ - name: Show generated coverage files
+ run: ls -lah
+
+ - name: Upload coverage results to Coveralls
+ uses: coverallsapp/github-action@v2
+ env:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ file: coverage.xml
diff --git a/.gitignore b/.gitignore
index c730549b1..8bdbea99c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,9 @@
/.phive/*
+/.php-cs-fixer.cache
/.php_cs.cache
+/.phpunit.result.cache
/composer.lock
+/coverage.xml
+/phpstan.neon
/vendor/
!/.phive/phars.xml
diff --git a/.phive/phars.xml b/.phive/phars.xml
index 7a248a7a7..6af30ed59 100644
--- a/.phive/phars.xml
+++ b/.phive/phars.xml
@@ -1,5 +1,5 @@
-
-
+
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 023ab9970..7f69624e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,225 +1,503 @@
-# Revision History
+# Changelog
-## x.y
+All notable changes to this project will be documented in this file.
+This project adheres to [Semantic Versioning](https://semver.org/).
+
+Please also have a look at our
+[API and deprecation policy](docs/API-and-deprecation-policy.md).
## x.y.z
-* Require PHP ≥ 5.6
+### Added
+
+- Interface `RuleContainer` for `RuleSet` `Rule` manipulation methods (#1256)
+- `RuleSet::removeMatchingRules()` method
+ (for the implementing classes `AtRuleSet` and `DeclarationBlock`) (#1249)
+- `RuleSet::removeAllRules()` method
+ (for the implementing classes `AtRuleSet` and `DeclarationBlock`) (#1249)
+- Add Interface `CSSElement` (#1231)
+- Methods `getLineNumber` and `getColumnNumber` which return a nullable `int`
+ for the following classes:
+ `Comment`, `CSSList`, `SourceException`, `Charset`, `CSSNamespace`, `Import`,
+ `Rule`, `DeclarationBlock`, `RuleSet`, `CSSFunction`, `Value` (#1225, #1263)
+- `Positionable` interface for CSS items that may have a position
+ (line and perhaps column number) in the parsed CSS (#1221)
+- Partial support for CSS Color Module Level 4:
+ - `rgb` and `rgba`, and `hsl` and `hsla` are now aliases (#797}
+ - Parse color functions that use the "modern" syntax (#800)
+ - Render RGB functions with "modern" syntax when required (#840)
+ - Support `none` as color function component value (#859)
+- Add a class diagram to the README (#482)
+- Add more tests (#449)
+
+### Changed
+
+- `setPosition()` (in `Rule` and other classes) now has fluent interface,
+ returning itself (#1259)
+- `RuleSet::removeRule()` now only allows `Rule` as the parameter
+ (implementing classes are `AtRuleSet` and `DeclarationBlock`);
+ use `removeMatchingRules()` or `removeAllRules()` for other functions (#1255)
+- `RuleSet::getRules()` and `getRulesAssoc()` now only allow `string` or `null`
+ as the parameter (implementing classes are `AtRuleSet` and `DeclarationBlock`)
+ (#1253)
+- Parameters for `getAllValues()` are deconflated, so it now takes three (all
+ optional), allowing `$element` and `$ruleSearchPattern` to be specified
+ separately (#1241)
+- Implement `Positionable` in the following CSS item classes:
+ `Comment`, `CSSList`, `SourceException`, `Charset`, `CSSNamespace`, `Import`,
+ `Rule`, `DeclarationBlock`, `RuleSet`, `CSSFunction`, `Value` (#1225)
+- Initialize `KeyFrame` properties to sensible defaults (#1146)
+- Make `OutputFormat` `final` (#1128)
+- Make `Selector` a `Renderable` (#1017)
+- Only allow `string` for some `OutputFormat` properties (#885)
+- Use more native type declarations and strict mode
+ (#641, #772, #774, #778, #804, #841, #873, #875, #891, #922, #923, #933, #958,
+ #964, #967, #1000, #1044, #1134, #1136, #1137, #1139, #1140, #1141, #1145,
+ #1162, #1163, #1166, #1172, #1174, #1178, #1179, #1181, #1183, #1184, #1186,
+ #1187, #1190, #1192, #1193, #1203)
+- Add visibility to all class/interface constants (#469)
+
+### Deprecated
+
+- Passing a `string` or `null` to `RuleSet::removeRule()` is deprecated
+ (implementing classes are `AtRuleSet` and `DeclarationBlock`);
+ use `removeMatchingRules()` or `removeAllRules()` instead (#1249)
+- Passing a `Rule` to `RuleSet::getRules()` or `getRulesAssoc()` is deprecated,
+ affecting the implementing classes `AtRuleSet` and `DeclarationBlock`
+ (call e.g. `getRules($rule->getRule())` instead) (#1248)
+- Passing a string as the first argument to `getAllValues()` is deprecated;
+ the search pattern should now be passed as the second argument (#1241)
+- Passing a Boolean as the second argument to `getAllValues()` is deprecated;
+ the flag for searching in function arguments should now be passed as the third
+ argument (#1241)
+- `getLineNo()` is deprecated in these classes (use `getLineNumber()` instead):
+ `Comment`, `CSSList`, `SourceException`, `Charset`, `CSSNamespace`, `Import`,
+ `Rule`, `DeclarationBlock`, `RuleSet`, `CSSFunction`, `Value` (#1225, #1233)
+- `Rule::getColNo()` is deprecated (use `getColumnNumber()` instead)
+ (#1225, #1233)
+- Providing zero as the line number argument to `Rule::setPosition()` is
+ deprecated (pass `null` instead if there is no line number) (#1225, #1233)
+
+### Removed
+
+- Passing a string as the first argument to `getAllValues()` is no longer
+ supported and will not work;
+ the search pattern should now be passed as the second argument (#1243)
+- Passing a Boolean as the second argument to `getAllValues()` is no longer
+ supported and will not work; the flag for searching in function arguments
+ should now be passed as the third argument (#1243)
+- Remove `__toString()` (#1046)
+- Drop magic method forwarding in `OutputFormat` (#898)
+- Drop `atRuleArgs()` from the `AtRule` interface (#1141)
+- Remove `OutputFormat::get()` and `::set()` (#1108, #1110)
+- Drop special support for vendor prefixes (#1083)
+- Remove the IE hack in `Rule` (#995)
+- Drop `getLineNo()` from the `Renderable` interface (#1038)
+- Remove `OutputFormat::level()` (#874)
+- Remove expansion of shorthand properties (#838)
+- Remove `Parser::setCharset/getCharset` (#808)
+- Remove `Rule::getValues()` (#582)
+- Remove `Rule::setValues()` (#562)
+- Remove `Document::getAllSelectors()` (#561)
+- Remove `DeclarationBlock::getSelector()` (#559)
+- Remove `DeclarationBlock::setSelector()` (#560)
+- Drop support for PHP < 7.2 (#420)
+
+### Fixed
+
+- Insert `Rule` before sibling even with different property name
+ (in `RuleSet::addRule()`) (#1270)
+- Ensure `RuleSet::addRule()` sets non-negative column number when sibling
+ provided (#1268)
+- Set line number when `RuleSet::addRule()` called with only column number set
+ (#1265)
+- Ensure first rule added with `RuleSet::addRule()` has valid position (#1262)
+- Don't render `rgb` colors with percentage values using hex notation (#803)
+
+### Documentation
+
+- Add an API and deprecation policy (#720)
+
+@ziegenberg is a new contributor to this release and did a lot of the heavy
+lifting. Thanks! :heart:
+
+## 8.8.0: Bug fixes and deprecations
+
+### Added
+
+- `OutputFormat` properties for space around specific list separators (#880)
+
+### Changed
-## 8.0
+- Mark the `OutputFormat` constructor as `@internal` (#1131)
+- Mark `OutputFormatter` as `@internal` (#896)
+- Mark `Selector::isValid()` as `@internal` (#1037)
+- Mark parsing-related methods of most CSS elements as `@internal` (#908)
+- Mark `OutputFormat::nextLevel()` as `@internal` (#901)
+- Make all non-private properties `@internal` (#886)
+
+### Deprecated
+
+- Deprecate extending `OutputFormat` (#1131)
+- Deprecate `OutputFormat::get()` and `::set()` (#1107)
+- Deprecate support for `-webkit-calc` and `-moz-calc` (#1086)
+- Deprecate magic method forwarding from `OutputFormat` to `OutputFormatter`
+ (#894)
+- Deprecate `__toString()` (#1006)
+- Deprecate greedy calculation of selector specificity (#1018)
+- Deprecate the IE hack in `Rule` (#993, #1003)
+- `OutputFormat` properties for space around list separators as an array (#880)
+- Deprecate `OutputFormat::level()` (#870)
-### 8.0.0 (2016-06-30)
+### Fixed
-* Store source CSS line numbers in tokens and parsing exceptions.
-* *No deprecations*
+- Include comments for all rules in declaration block (#1169)
+- Render rules in line and column number order (#1059)
+- Create `Size` with correct types in `expandBackgroundShorthand` (#814)
+- Parse `@font-face` `src` property as comma-delimited list (#794)
-#### Backwards-incompatible changes
+## 8.7.0: Add support for PHP 8.4
-* Unrecoverable parser errors throw an exception of type `Sabberworm\CSS\Parsing\SourceException` instead of `\Exception`.
+### Added
-### 8.1.0 (2016-07-19)
+- Add support for PHP 8.4 (#643, #657)
-* Comments are no longer silently ignored but stored with the object with which they appear (no render support, though). Thanks to @FMCorz.
-* The IE hacks using `\0` and `\9` can now be parsed (and rendered) in lenient mode. Thanks (again) to @FMCorz.
-* Media queries with or without spaces before the query are parsed. Still no *real* parsing support, though. Sorry…
-* PHPUnit is now listed as a dev-dependency in composer.json.
+### Changed
+
+- Mark parsing-internal classes and methods as `@internal` (#674)
+- Block installations on unsupported higher PHP versions (#691)
+
+### Deprecated
+
+- Deprecate the expansion of shorthand properties
+ (#578, #580, #579, #577, #576, #575, #574, #573, #572, #571, #570, #569, #566,
+ #567, #558, #714)
+- Deprecate `Parser::setCharset()` and `Parser::getCharset()` (#688)
+
+### Fixed
+
+- Fix type errors in PHP strict mode (#664)
+
+## 8.6.0
+
+### Added
+
+- Support arithmetic operators in CSS function arguments (#607)
+- Add support for inserting an item in a CSS list (#545)
+- Add support for the `dvh`, `lvh` and `svh` length units (#415)
+
+### Changed
+
+- Improve performance of `Value::parseValue` with many delimiters by refactoring
+ to remove `array_search()` (#413)
+
+## 8.5.2
+
+### Changed
+
+- Mark all class constants as `@internal` (#472)
+
+### Fixed
+
+- Fix undefined local variable in `CalcFunction::parse()` (#593)
+
+## 8.5.1
+
+### Fixed
+
+- Fix PHP notice caused by parsing invalid color values having less than
+ 6 characters (#485)
+- Fix (regression) failure to parse at-rules with strict parsing (#456)
+
+## 8.5.0
+
+### Added
+
+- Add a method to get an import's media queries (#384)
+- Add more unit tests (#381, #382)
+
+### Fixed
+
+- Retain CSSList and Rule comments when rendering CSS (#351)
+- Replace invalid `turns` unit with `turn` (#350)
+- Also allow string values for rules (#348)
+- Fix invalid calc parsing (#169)
+- Handle scientific notation when parsing sizes (#179)
+- Fix PHP 8.1 compatibility in `ParserState::strsplit()` (#344)
+
+## 8.4.0
+
+### Features
+
+* Support for PHP 8.x
+* PHPDoc annotations
+* Allow usage of CSS variables inside color functions (by parsing them as
+ regular functions)
+* Use PSR-12 code style
+* *No deprecations*
+
+### Bugfixes
+
+* Improved handling of whitespace in `calc()`
+* Fix parsing units whose prefix is also a valid unit, like `vmin`
+* Allow passing an object to `CSSList#replace`
+* Fix PHP 7.3 warnings
+* Correctly parse keyframes with `%`
+* Don’t convert large numbers to scientific notation
+* Allow a file to end after an `@import`
+* Preserve case of CSS variables as specced
+* Allow identifiers to use escapes the same way as strings
+* No longer use `eval` for the comparison in `getSelectorsBySpecificity`, in
+ case it gets passed untrusted input (CVE-2020-13756). Also fixed in 8.3.1,
+ 8.2.1, 8.1.1, 8.0.1, 7.0.4, 6.0.2, 5.2.1, 5.1.3, 5.0.9, 4.0.1, 3.0.1, 2.0.1,
+ 1.0.1.
+* Prevent an infinite loop when parsing invalid grid line names
+* Remove invalid unit `vm`
+* Retain rule order after expanding shorthands
+
+### Backwards-incompatible changes
+
+* PHP ≥ 5.6 is now required
+* HHVM compatibility target dropped
+
+## 8.3.0 (2019-02-22)
+
+* Refactor parsing logic to mostly reside in the class files whose data
+ structure is to be parsed (this should eventually allow us to unit-test
+ specific parts of the parsing logic individually).
+* Fix error in parsing `calc` expessions when the first operand is a negative
+ number, thanks to @raxbg.
+* Support parsing CSS4 colors in hex notation with alpha values, thanks to
+ @raxbg.
+* Swallow more errors in lenient mode, thanks to @raxbg.
+* Allow specifying arbitrary strings to output before and after declaration
+ blocks, thanks to @westonruter.
* *No backwards-incompatible changes*
* *No deprecations*
-### 8.2.0 (2018-07-13)
+## 8.2.0 (2018-07-13)
* Support parsing `calc()`, thanks to @raxbg.
* Support parsing grid-lines, again thanks to @raxbg.
-* Support parsing legacy IE filters (`progid:`) in lenient mode, thanks to @FMCorz
+* Support parsing legacy IE filters (`progid:`) in lenient mode, thanks to
+ @FMCorz
* Performance improvements parsing large files, again thanks to @FMCorz
* *No backwards-incompatible changes*
* *No deprecations*
-### 8.3.0 (2019-02-22)
+## 8.1.0 (2016-07-19)
-* Refactor parsing logic to mostly reside in the class files whose data structure is to be parsed (this should eventually allow us to unit-test specific parts of the parsing logic individually).
-* Fix error in parsing `calc` expessions when the first operand is a negative number, thanks to @raxbg.
-* Support parsing CSS4 colors in hex notation with alpha values, thanks to @raxbg.
-* Swallow more errors in lenient mode, thanks to @raxbg.
-* Allow specifying arbitrary strings to output before and after declaration blocks, thanks to @westonruter.
+* Comments are no longer silently ignored but stored with the object with which
+ they appear (no render support, though). Thanks to @FMCorz.
+* The IE hacks using `\0` and `\9` can now be parsed (and rendered) in lenient
+ mode. Thanks (again) to @FMCorz.
+* Media queries with or without spaces before the query are parsed. Still no
+ *real* parsing support, though. Sorry…
+* PHPUnit is now listed as a dev-dependency in composer.json.
* *No backwards-incompatible changes*
* *No deprecations*
-## 7.0
+## 8.0.0 (2016-06-30)
-### 7.0.0 (2015-08-24)
-
-* Compatibility with PHP 7. Well timed, eh?
+* Store source CSS line numbers in tokens and parsing exceptions.
* *No deprecations*
-#### Backwards-incompatible changes
+### Backwards-incompatible changes
-* The `Sabberworm\CSS\Value\String` class has been renamed to `Sabberworm\CSS\Value\CSSString`.
+* Unrecoverable parser errors throw an exception of type
+ `Sabberworm\CSS\Parsing\SourceException` instead of `\Exception`.
-### 7.0.1 (2015-12-25)
+## 7.0.3 (2016-04-27)
-* No more suppressed `E_NOTICE`
+* Fixed parsing empty CSS when multibyte is off
* *No backwards-incompatible changes*
* *No deprecations*
-### 7.0.2 (2016-02-11)
+## 7.0.2 (2016-02-11)
-* 150 time performance boost thanks to @[ossinkine](https://github.com/ossinkine)
+* 150 time performance boost thanks
+ to @[ossinkine](https://github.com/ossinkine)
* *No backwards-incompatible changes*
* *No deprecations*
-### 7.0.3 (2016-04-27)
+## 7.0.1 (2015-12-25)
-* Fixed parsing empty CSS when multibyte is off
+* No more suppressed `E_NOTICE`
* *No backwards-incompatible changes*
* *No deprecations*
-## 6.0
+## 7.0.0 (2015-08-24)
-### 6.0.0 (2014-07-03)
-
-* Format output using Sabberworm\CSS\OutputFormat
-* *No backwards-incompatible changes*
+* Compatibility with PHP 7. Well timed, eh?
+* *No deprecations*
-#### Deprecations
+### Backwards-incompatible changes
-* The parse() method replaces __toString with an optional argument (instance of the OutputFormat class)
+* The `Sabberworm\CSS\Value\String` class has been renamed to
+ `Sabberworm\CSS\Value\CSSString`.
-### 6.0.1 (2015-08-24)
+## 6.0.1 (2015-08-24)
* Remove some declarations in interfaces incompatible with PHP 5.3 (< 5.3.9)
* *No deprecations*
-## 5.0
+## 6.0.0 (2014-07-03)
-### 5.0.0 (2013-03-20)
+* Format output using Sabberworm\CSS\OutputFormat
+* *No backwards-incompatible changes*
+
+### Deprecations
+
+* The parse() method replaces __toString with an optional argument (instance of
+ the OutputFormat class)
+
+## 5.2.0 (2014-06-30)
+
+* Support removing a selector from a declaration block using
+ `$oBlock->removeSelector($mSelector)`
+* Introduce a specialized exception (Sabberworm\CSS\Parsing\OuputException) for
+ exceptions during output rendering
-* Correctly parse all known CSS 3 units (including Hz and kHz).
-* Output RGB colors in short (#aaa or #ababab) notation
-* Be case-insensitive when parsing identifiers.
* *No deprecations*
#### Backwards-incompatible changes
-* `Sabberworm\CSS\Value\Color`’s `__toString` method overrides `CSSList`’s to maybe return something other than `type(value, …)` (see above).
+* Outputting a declaration block that has no selectors throws an OuputException
+ instead of outputting an invalid ` {…}` into the CSS document.
-### 5.0.1 (2013-03-20)
+## 5.1.2 (2013-10-30)
-* Internal cleanup
+* Remove the use of consumeUntil in comment parsing. This makes it possible to
+ parse comments such as `/** Perfectly valid **/`
+* Add fr relative size unit
+* Fix some issues with HHVM
* *No backwards-incompatible changes*
* *No deprecations*
-### 5.0.2 (2013-03-21)
+## 5.1.1 (2013-10-28)
-* CHANGELOG.md file added to distribution
+* Updated CHANGELOG.md to reflect changes since 5.0.4
* *No backwards-incompatible changes*
* *No deprecations*
-### 5.0.3 (2013-03-21)
+## 5.1.0 (2013-10-24)
-* More size units recognized
+* Performance enhancements by Michael M Slusarz
+* More rescue entry points for lenient parsing (unexpected tokens between
+ declaration blocks and unclosed comments)
* *No backwards-incompatible changes*
* *No deprecations*
-### 5.0.4 (2013-03-21)
+## 5.0.8 (2013-08-15)
-* Don’t output floats with locale-aware separator chars
+* Make default settings’ multibyte parsing option dependent on whether or not
+ the mbstring extension is actually installed.
* *No backwards-incompatible changes*
* *No deprecations*
-### 5.0.5 (2013-04-17)
+## 5.0.7 (2013-08-04)
-* Initial support for lenient parsing (setting this parser option will catch some exceptions internally and recover the parser’s state as neatly as possible).
+* Fix broken decimal point output optimization
* *No backwards-incompatible changes*
* *No deprecations*
-### 5.0.6 (2013-05-31)
+## 5.0.6 (2013-05-31)
* Fix broken unit test
* *No backwards-incompatible changes*
* *No deprecations*
-### 5.0.7 (2013-08-04)
+## 5.0.5 (2013-04-17)
-* Fix broken decimal point output optimization
+* Initial support for lenient parsing (setting this parser option will catch
+ some exceptions internally and recover the parser’s state as neatly as
+ possible).
* *No backwards-incompatible changes*
* *No deprecations*
-### 5.0.8 (2013-08-15)
+## 5.0.4 (2013-03-21)
-* Make default settings’ multibyte parsing option dependent on whether or not the mbstring extension is actually installed.
+* Don’t output floats with locale-aware separator chars
* *No backwards-incompatible changes*
* *No deprecations*
-### 5.1.0 (2013-10-24)
+## 5.0.3 (2013-03-21)
-* Performance enhancements by Michael M Slusarz
-* More rescue entry points for lenient parsing (unexpected tokens between declaration blocks and unclosed comments)
+* More size units recognized
* *No backwards-incompatible changes*
* *No deprecations*
-### 5.1.1 (2013-10-28)
+## 5.0.2 (2013-03-21)
-* Updated CHANGELOG.md to reflect changes since 5.0.4
+* CHANGELOG.md file added to distribution
* *No backwards-incompatible changes*
* *No deprecations*
-### 5.1.2 (2013-10-30)
+## 5.0.1 (2013-03-20)
-* Remove the use of consumeUntil in comment parsing. This makes it possible to parse comments such as `/** Perfectly valid **/`
-* Add fr relative size unit
-* Fix some issues with HHVM
+* Internal cleanup
* *No backwards-incompatible changes*
* *No deprecations*
-### 5.2.0 (2014-06-30)
-
-* Support removing a selector from a declaration block using `$oBlock->removeSelector($mSelector)`
-* Introduce a specialized exception (Sabberworm\CSS\Parsing\OuputException) for exceptions during output rendering
+## 5.0.0 (2013-03-20)
+* Correctly parse all known CSS 3 units (including Hz and kHz).
+* Output RGB colors in short (#aaa or #ababab) notation
+* Be case-insensitive when parsing identifiers.
* *No deprecations*
-#### Backwards-incompatible changes
-
-* Outputting a declaration block that has no selectors throws an OuputException instead of outputting an invalid ` {…}` into the CSS document.
+### Backwards-incompatible changes
-## 4.0
+* `Sabberworm\CSS\Value\Color`’s `__toString` method overrides `CSSList`’s to
+ maybe return something other than `type(value, …)` (see above).
-### 4.0.0 (2013-03-19)
+## 4.0.0 (2013-03-19)
* Support for more @-rules
-* Generic interface `Sabberworm\CSS\Property\AtRule`, implemented by all @-rule classes
+* Generic interface `Sabberworm\CSS\Property\AtRule`, implemented by all @-rule
+ classes
* *No deprecations*
-#### Backwards-incompatible changes
+### Backwards-incompatible changes
* `Sabberworm\CSS\RuleSet\AtRule` renamed to `Sabberworm\CSS\RuleSet\AtRuleSet`
-* `Sabberworm\CSS\CSSList\MediaQuery` renamed to `Sabberworm\CSS\RuleSet\CSSList\AtRuleBlockList` with differing semantics and API (which also works for other block-list-based @-rules like `@supports`).
-
-## 3.0
+* `Sabberworm\CSS\CSSList\MediaQuery` renamed to
+ `Sabberworm\CSS\RuleSet\CSSList\AtRuleBlockList` with differing semantics and
+ API (which also works for other block-list-based @-rules like `@supports`).
-### 3.0.0 (2013-03-06)
+## 3.0.0 (2013-03-06)
* Support for lenient parsing (on by default)
* *No deprecations*
-#### Backwards-incompatible changes
+### Backwards-incompatible changes
-* All properties (like whether or not to use `mb_`-functions, which default charset to use and – new – whether or not to be forgiving when parsing) are now encapsulated in an instance of `Sabberworm\CSS\Settings` which can be passed as the second argument to `Sabberworm\CSS\Parser->__construct()`.
-* Specifying a charset as the second argument to `Sabberworm\CSS\Parser->__construct()` is no longer supported. Use `Sabberworm\CSS\Settings::create()->withDefaultCharset('some-charset')` instead.
-* Setting `Sabberworm\CSS\Parser->bUseMbFunctions` has no effect. Use `Sabberworm\CSS\Settings::create()->withMultibyteSupport(true/false)` instead.
-* `Sabberworm\CSS\Parser->parse()` may throw a `Sabberworm\CSS\Parsing\UnexpectedTokenException` when in strict parsing mode.
+* All properties (like whether or not to use `mb_`-functions, which default
+ charset to use and – new – whether or not to be forgiving when parsing) are
+ now encapsulated in an instance of `Sabberworm\CSS\Settings` which can be
+ passed as the second argument to `Sabberworm\CSS\Parser->__construct()`.
+* Specifying a charset as the second argument to
+ `Sabberworm\CSS\Parser->__construct()` is no longer supported. Use
+ `Sabberworm\CSS\Settings::create()->withDefaultCharset('some-charset')`
+ instead.
+* Setting `Sabberworm\CSS\Parser->bUseMbFunctions` has no effect. Use
+ `Sabberworm\CSS\Settings::create()->withMultibyteSupport(true/false)` instead.
+* `Sabberworm\CSS\Parser->parse()` may throw a
+ `Sabberworm\CSS\Parsing\UnexpectedTokenException` when in strict parsing mode.
-## 2.0
-
-### 2.0.0 (2013-01-29)
+## 2.0.0 (2013-01-29)
* Allow multiple rules of the same type per rule set
-#### Backwards-incompatible changes
+### Backwards-incompatible changes
-* `Sabberworm\CSS\RuleSet->getRules()` returns an index-based array instead of an associative array. Use `Sabberworm\CSS\RuleSet->getRulesAssoc()` (which eliminates duplicate rules and lets the later rule of the same name win).
-* `Sabberworm\CSS\RuleSet->removeRule()` works as it did before except when passed an instance of `Sabberworm\CSS\Rule\Rule`, in which case it would only remove the exact rule given instead of all the rules of the same type. To get the old behaviour, use `Sabberworm\CSS\RuleSet->removeRule($oRule->getRule()`;
+* `Sabberworm\CSS\RuleSet->getRules()` returns an index-based array instead of
+ an associative array. Use `Sabberworm\CSS\RuleSet->getRulesAssoc()` (which
+ eliminates duplicate rules and lets the later rule of the same name win).
+* `Sabberworm\CSS\RuleSet->removeRule()` works as it did before except when
+ passed an instance of `Sabberworm\CSS\Rule\Rule`, in which case it would only
+ remove the exact rule given instead of all the rules of the same type. To get
+ the old behaviour, use `Sabberworm\CSS\RuleSet->removeRule($oRule->getRule()`;
## 1.0
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 000000000..1b87c0935
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,119 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, caste, color, religion, or sexual
+identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the overall
+ community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or advances of
+ any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email address,
+ without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official email address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+(myintervals-coc at gaggle dot email).
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of
+actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or permanent
+ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the
+community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/),
+version 2.1, available at
+https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 000000000..1d0085f3a
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,196 @@
+# Contributing to PHP-CSS-Parser
+
+Those that wish to contribute bug fixes, new features, refactorings and
+clean-up to PHP-CSS-Parser are more than welcome.
+
+When you contribute, please take the following things into account:
+
+## Contributor Code of Conduct
+
+Please note that this project is released with a
+[Contributor Code of Conduct](../CODE_OF_CONDUCT.md). By participating in this
+project, you agree to abide by its terms.
+
+## General workflow
+
+This is the workflow for contributing changes to this project::
+
+1. [Fork the Git repository](https://docs.github.com/en/get-started/exploring-projects-on-github/contributing-to-a-project).
+1. Clone your forked repository locally and install the development
+ dependencies.
+1. Create a local branch for your changes.
+1. Add unit tests for your changes.
+ These tests should fail without your changes.
+1. Add your changes. Your added unit tests now should pass, and no other tests
+ should be broken. Check that your changes follow the same coding style as the
+ rest of the project.
+1. Add a changelog entry, newest on top.
+1. Commit and push your changes.
+1. [Create a pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests)
+ for your changes.
+1. Check that the CI build is green. (If it is not, fix the problems listed.)
+ Please note that for first-time contributors, you will need to wait for a
+ maintainer to allow your CI build to run.
+1. Wait for a review by the maintainers.
+1. Polish your changes as needed until they are ready to be merged.
+
+## About code reviews
+
+After you have submitted a pull request, the maintainers will review your
+changes. This will probably result in quite a few comments on ways to improve
+your pull request. This project receives contributions from developers around
+the world, so we need the code to be the most consistent, readable, and
+maintainable that it can be.
+
+Please do not feel frustrated by this - instead please view this both as our
+contribution to your pull request as well as a way to learn more about
+improving code quality.
+
+If you would like to know whether an idea would fit in the general strategy of
+this project or would like to get feedback on the best architecture for your
+ideas, we propose you open a ticket first and discuss your ideas there
+first before investing a lot of time in writing code.
+
+## Install the development dependencies
+
+To install the most important development dependencies, please run the following
+command:
+
+```bash
+composer install
+```
+
+We also have some optional development dependencies that require higher PHP
+versions than the lowest PHP version this project supports. Hence they are not
+installed by default.
+
+To install these, you will need to have [PHIVE](https://phar.io/) installed.
+You can then run the following command:
+
+```bash
+phive install
+```
+
+## Unit-test your changes
+
+Please cover all changes with unit tests and make sure that your code does not
+break any existing tests. We will only merge pull requests that include full
+code coverage of the fixed bugs and the new features.
+
+To run the existing PHPUnit tests, run this command:
+
+```bash
+composer ci:tests:unit
+```
+
+## Coding Style
+
+Please use the same coding style
+([PER 2.0](https://www.php-fig.org/per/coding-style/)) as the rest of the code.
+Indentation is four spaces.
+
+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
+```
+
+Please make your code clean, well-readable and easy to understand.
+
+If you add new methods or fields, please add proper PHPDoc for the new
+methods/fields. Please use grammatically correct, complete sentences in the
+code documentation.
+
+You can autoformat your code using the following command:
+
+```bash
+composer fix
+```
+
+## Git commits
+
+Commit message should have a <= 50-character summary, optionally followed by a
+blank line and a more in depth description of 79 characters per line.
+
+Please use grammatically correct, complete sentences in the commit messages.
+
+Also, please prefix the subject line of the commit message with either
+`[FEATURE]`, `[TASK]`, `[BUGFIX]` OR `[CLEANUP]`. This makes it faster to see
+what a commit is about.
+
+## Creating pull requests (PRs)
+
+When you create a pull request, please
+[make your PR editable](https://github.com/blog/2247-improving-collaboration-with-forks).
+
+## Rebasing
+
+If other PRs have been merged during the time between your initial PR creation
+and final approval, it may be required that you rebase your changes against the
+latest `main` branch.
+
+There are potential pitfalls here if you follow the suggestions from `git`,
+which could leave your branch in an unrecoverable mess,
+and you having to start over with a new branch and new PR.
+
+The procedure below is tried and tested, and will help you avoid frustration.
+
+To rebase a feature branch to the latest `main`:
+
+1. Make sure that your local copy of the repository has the most up-to-date
+ revisions of `main` (this is important, otherwise you may end up rebasing to
+ an older base point):
+ ```bash
+ git switch main
+ git pull
+ ```
+1. Switch to the (feature) branch to be rebased and make sure your copy is up to
+ date:
+ ```bash
+ git switch feature/something-cool
+ git pull
+ ```
+1. Consider taking a copy of the folder tree at this stage; this may help when
+ resolving conflicts in the next step.
+1. Begin the rebasing process
+ ```bash
+ git rebase main
+ ```
+1. Resolve the conflicts in the reported files. (This will typically require
+ reversing the order of the new entries in `CHANGELOG.md`.) You may use a
+ folder `diff` against the copy taken at step 3 to assist, but bear in mind
+ that at this stage `git` is partway through rebasing, so some files will have
+ been merged and include the latest changes from `main`, whilst others might
+ not. In any case, you should ignore changes to files not reported as having
+ conflicts.
+
+ If there were no conflicts, skip this and the next step.
+1. Mark the conflicting files as resolved and continue the rebase
+ ```bash
+ git add .
+ git rebase --continue
+ ```
+ (You can alternatively use more specific wildcards or specify individual
+ files with a full relative path.)
+
+ If there were no conflicts reported in the previous step, skip this step.
+
+ If there are more conflicts to resolve, repeat the previous step then this
+ step again.
+1. Force-push the rebased (feature) branch to the remote repository
+ ```bash
+ git push --force
+ ```
+ The `--force` option is important. Without it, you'll get an error with a
+ hint suggesting a `git pull` is required:
+ ```
+ hint: Updates were rejected because the tip of your current branch is behind
+ hint: its remote counterpart. Integrate the remote changes (e.g.
+ hint: 'git pull ...') before pushing again.
+ hint: See the 'Note about fast-forwards' in 'git push --help' for details.
+ ```
+ ***DO NOT*** follow the hint and execute `git pull`. This will result in the
+ set of all commits on the feature branch being duplicated, and the "patching
+ base" not being moved at all.
diff --git a/README.md b/README.md
index 285310022..9ecdc3e7f 100644
--- a/README.md
+++ b/README.md
@@ -1,22 +1,16 @@
-PHP CSS Parser
---------------
+# PHP CSS Parser
-[](https://github.com/sabberworm/PHP-CSS-Parser/actions/)
+[](https://github.com/MyIntervals/PHP-CSS-Parser/actions/)
+[](https://coveralls.io/github/MyIntervals/PHP-CSS-Parser?branch=main)
A Parser for CSS Files written in PHP. Allows extraction of CSS files into a data structure, manipulation of said structure and output as (optimized) CSS.
## Usage
-### Installation using composer
+### Installation using Composer
-Add php-css-parser to your composer.json
-
-```json
-{
- "require": {
- "sabberworm/php-css-parser": "*"
- }
-}
+```bash
+composer require sabberworm/php-css-parser
```
### Extraction
@@ -24,14 +18,14 @@ Add php-css-parser to your composer.json
To use the CSS Parser, create a new instance. The constructor takes the following form:
```php
-new Sabberworm\CSS\Parser($sText);
+new \Sabberworm\CSS\Parser($css);
```
To read a file, for example, you’d do the following:
```php
-$oCssParser = new Sabberworm\CSS\Parser(file_get_contents('somefile.css'));
-$oCssDocument = $oCssParser->parse();
+$parser = new \Sabberworm\CSS\Parser(file_get_contents('somefile.css'));
+$cssDocument = $parser->parse();
```
The resulting CSS document structure can be manipulated prior to being output.
@@ -40,42 +34,45 @@ The resulting CSS document structure can be manipulated prior to being output.
#### Charset
-The charset option is used only if no @charset declaration is found in the CSS file. UTF-8 is the default, so you won’t have to create a settings object at all if you don’t intend to change that.
+The charset option will only be used if the CSS file does not contain an `@charset` declaration. UTF-8 is the default, so you won’t have to create a settings object at all if you don’t intend to change that.
```php
-$oSettings = Sabberworm\CSS\Settings::create()->withDefaultCharset('windows-1252');
-new Sabberworm\CSS\Parser($sText, $oSettings);
+$settings = \Sabberworm\CSS\Settings::create()
+ ->withDefaultCharset('windows-1252');
+$parser = new \Sabberworm\CSS\Parser($css, $settings);
```
#### Strict parsing
-To have the parser choke on invalid rules, supply a thusly configured Sabberworm\CSS\Settings object:
+To have the parser throw an exception when encountering invalid/unknown constructs (as opposed to trying to ignore them and carry on parsing), supply a thusly configured `\Sabberworm\CSS\Settings` object:
```php
-$oCssParser = new Sabberworm\CSS\Parser(file_get_contents('somefile.css'), Sabberworm\CSS\Settings::create()->beStrict());
+$parser = new \Sabberworm\CSS\Parser(
+ file_get_contents('somefile.css'),
+ \Sabberworm\CSS\Settings::create()->beStrict()
+);
```
+Note that this will also disable a workaround for parsing the unquoted variant of the legacy IE-specific `filter` rule.
+
#### Disable multibyte functions
-To achieve faster parsing, you can choose to have PHP-CSS-Parser use regular string functions instead of `mb_*` functions. This should work fine in most cases, even for UTF-8 files, as all the multibyte characters are in string literals. Still it’s not recommended to use this with input you have no control over as it’s not thoroughly covered by test cases.
+To achieve faster parsing, you can choose to have PHP-CSS-Parser use regular string functions instead of `mb_*` functions. This should work fine in most cases, even for UTF-8 files, as all the multibyte characters are in string literals. Still it’s not recommended using this with input you have no control over as it’s not thoroughly covered by test cases.
```php
-$oSettings = Sabberworm\CSS\Settings::create()->withMultibyteSupport(false);
-new Sabberworm\CSS\Parser($sText, $oSettings);
+$settings = \Sabberworm\CSS\Settings::create()->withMultibyteSupport(false);
+$parser = new \Sabberworm\CSS\Parser($css, $settings);
```
### Manipulation
-The resulting data structure consists mainly of five basic types: `CSSList`, `RuleSet`, `Rule`, `Selector` and `Value`. There are two additional types used: `Import` and `Charset` which you won’t use often.
+The resulting data structure consists mainly of five basic types: `CSSList`, `RuleSet`, `Rule`, `Selector` and `Value`. There are two additional types used: `Import` and `Charset`, which you won’t use often.
#### CSSList
-`CSSList` represents a generic CSS container, most likely containing declaration blocks (rule sets with a selector) but it may also contain at-rules, charset declarations, etc. `CSSList` has the following concrete subtypes:
+`CSSList` represents a generic CSS container, most likely containing declaration blocks (rule sets with a selector), but it may also contain at-rules, charset declarations, etc.
-* `Document` – representing the root of a CSS file.
-* `MediaQuery` – represents a subsection of a CSSList that only applies to a output device matching the contained media query.
-
-To access the items stored in a `CSSList` – like the document you got back when calling `$oCssParser->parse()` –, use `getContents()`, then iterate over that collection and use instanceof to check whether you’re dealing with another `CSSList`, a `RuleSet`, a `Import` or a `Charset`.
+To access the items stored in a `CSSList` – like the document you got back when calling `$parser->parse()` –, use `getContents()`, then iterate over that collection and use `instanceof` to check whether you’re dealing with another `CSSList`, a `RuleSet`, a `Import` or a `Charset`.
To append a new item (selector, media query, etc.) to an existing `CSSList`, construct it using the constructor for this class and use the `append($oItem)` method.
@@ -83,16 +80,16 @@ To append a new item (selector, media query, etc.) to an existing `CSSList`, con
`RuleSet` is a container for individual rules. The most common form of a rule set is one constrained by a selector. The following concrete subtypes exist:
-* `AtRuleSet` – for generic at-rules which do not match the ones specifically mentioned like @import, @charset or @media. A common example for this is @font-face.
-* `DeclarationBlock` – a RuleSet constrained by a `Selector`; contains an array of selector objects (comma-separated in the CSS) as well as the rules to be applied to the matching elements.
+* `AtRuleSet` – for generic at-rules for generic at-rules which are not covered by specific classes, i.e., not `@import`, `@charset` or `@media`. A common example for this is `@font-face`.
+* `DeclarationBlock` – a `RuleSet` constrained by a `Selector`; contains an array of selector objects (comma-separated in the CSS) as well as the rules to be applied to the matching elements.
-Note: A `CSSList` can contain other `CSSList`s (and `Import`s as well as a `Charset`) while a `RuleSet` can only contain `Rule`s.
+Note: A `CSSList` can contain other `CSSList`s (and `Import`s as well as a `Charset`), while a `RuleSet` can only contain `Rule`s.
-If you want to manipulate a `RuleSet`, use the methods `addRule(Rule $oRule)`, `getRules()` and `removeRule($mRule)` (which accepts either a Rule instance or a rule name; optionally suffixed by a dash to remove all related rules).
+If you want to manipulate a `RuleSet`, use the methods `addRule(Rule $rule)`, `getRules()` and `removeRule($rule)` (which accepts either a `Rule` or a rule name; optionally suffixed by a dash to remove all related rules).
#### Rule
-`Rule`s just have a key (the rule) and a value. These values are all instances of a `Value`.
+`Rule`s just have a string key (the rule) and a `Value`.
#### Value
@@ -101,63 +98,69 @@ If you want to manipulate a `RuleSet`, use the methods `addRule(Rule $oRule)`, `
* `Size` – consists of a numeric `size` value and a unit.
* `Color` – colors can be input in the form #rrggbb, #rgb or schema(val1, val2, …) but are always stored as an array of ('s' => val1, 'c' => val2, 'h' => val3, …) and output in the second form.
* `CSSString` – this is just a wrapper for quoted strings to distinguish them from keywords; always output with double quotes.
-* `URL` – URLs in CSS; always output in URL("") notation.
+* `URL` – URLs in CSS; always output in `URL("")` notation.
+
+There is another abstract subclass of `Value`, `ValueList`: A `ValueList` represents a lists of `Value`s, separated by some separation character (mostly `,`, whitespace, or `/`).
-There is another abstract subclass of `Value`, `ValueList`. A `ValueList` represents a lists of `Value`s, separated by some separation character (mostly `,`, whitespace, or `/`). There are two types of `ValueList`s:
+There are two types of `ValueList`s:
-* `RuleValueList` – The default type, used to represent all multi-valued rules like `font: bold 12px/3 Helvetica, Verdana, sans-serif;` (where the value would be a whitespace-separated list of the primitive value `bold`, a slash-separated list and a comma-separated list).
+* `RuleValueList` – The default type, used to represent all multivalued rules like `font: bold 12px/3 Helvetica, Verdana, sans-serif;` (where the value would be a whitespace-separated list of the primitive value `bold`, a slash-separated list and a comma-separated list).
* `CSSFunction` – A special kind of value that also contains a function name and where the values are the function’s arguments. Also handles equals-sign-separated argument lists like `filter: alpha(opacity=90);`.
#### Convenience methods
-There are a few convenience methods on Document to ease finding, manipulating and deleting rules:
+There are a few convenience methods on `Document` to ease finding, manipulating and deleting rules:
-* `getAllDeclarationBlocks()` – does what it says; no matter how deeply nested your selectors are. Aliased as `getAllSelectors()`.
-* `getAllRuleSets()` – does what it says; no matter how deeply nested your rule sets are.
+* `getAllDeclarationBlocks()` – does what it says; no matter how deeply nested the selectors are. Aliased as `getAllSelectors()`.
+* `getAllRuleSets()` – does what it says; no matter how deeply nested the rule sets are.
* `getAllValues()` – finds all `Value` objects inside `Rule`s.
## To-Do
-* More convenience methods [like `selectorsWithElement($sId/Class/TagName)`, `attributesOfType($sType)`, `removeAttributesOfType($sType)`]
-* Real multibyte support. Currently only multibyte charsets whose first 255 code points take up only one byte and are identical with ASCII are supported (yes, UTF-8 fits this description).
+* More convenience methods (like `selectorsWithElement($sId/Class/TagName)`, `attributesOfType($type)`, `removeAttributesOfType($type)`)
+* Real multibyte support. Currently, only multibyte charsets whose first 255 code points take up only one byte and are identical with ASCII are supported (yes, UTF-8 fits this description).
* Named color support (using `Color` instead of an anonymous string literal)
## Use cases
-### Use `Parser` to prepend an id to all selectors
+### Use `Parser` to prepend an ID to all selectors
```php
-$sMyId = "#my_id";
-$oParser = new Sabberworm\CSS\Parser($sText);
-$oCss = $oParser->parse();
-foreach($oCss->getAllDeclarationBlocks() as $oBlock) {
- foreach($oBlock->getSelectors() as $oSelector) {
- //Loop over all selector parts (the comma-separated strings in a selector) and prepend the id
- $oSelector->setSelector($sMyId.' '.$oSelector->getSelector());
- }
+$myId = "#my_id";
+$parser = new \Sabberworm\CSS\Parser($css);
+$cssDocument = $parser->parse();
+foreach ($cssDocument->getAllDeclarationBlocks() as $block) {
+ foreach ($block->getSelectors() as $selector) {
+ // Loop over all selector parts (the comma-separated strings in a
+ // selector) and prepend the ID.
+ $selector->setSelector($myId.' '.$selector->getSelector());
+ }
}
```
### Shrink all absolute sizes to half
```php
-$oParser = new Sabberworm\CSS\Parser($sText);
-$oCss = $oParser->parse();
-foreach($oCss->getAllValues() as $mValue) {
- if($mValue instanceof CSSSize && !$mValue->isRelative()) {
- $mValue->setSize($mValue->getSize()/2);
- }
+$parser = new \Sabberworm\CSS\Parser($css);
+$cssDocument = $parser->parse();
+foreach ($cssDocument->getAllValues() as $value) {
+ if ($value instanceof CSSSize && !$value->isRelative()) {
+ $value->setSize($value->getSize() / 2);
+ }
}
```
### Remove unwanted rules
```php
-$oParser = new Sabberworm\CSS\Parser($sText);
-$oCss = $oParser->parse();
-foreach($oCss->getAllRuleSets() as $oRuleSet) {
- $oRuleSet->removeRule('font-'); //Note that the added dash will make this remove all rules starting with font- (like font-size, font-weight, etc.) as well as a potential font-rule
- $oRuleSet->removeRule('cursor');
+$parser = new \Sabberworm\CSS\Parser($css);
+$cssDocument = $parser->parse();
+foreach($cssDocument->getAllRuleSets() as $oRuleSet) {
+ // Note that the added dash will make this remove all rules starting with
+ // `font-` (like `font-size`, `font-weight`, etc.) as well as a potential
+ // `font` rule.
+ $oRuleSet->removeRule('font-');
+ $oRuleSet->removeRule('cursor');
}
```
@@ -166,26 +169,27 @@ foreach($oCss->getAllRuleSets() as $oRuleSet) {
To output the entire CSS document into a variable, just use `->render()`:
```php
-$oCssParser = new Sabberworm\CSS\Parser(file_get_contents('somefile.css'));
-$oCssDocument = $oCssParser->parse();
-print $oCssDocument->render();
+$parser = new \Sabberworm\CSS\Parser(file_get_contents('somefile.css'));
+$cssDocument = $parser->parse();
+print $cssDocument->render();
```
-If you want to format the output, pass an instance of type `Sabberworm\CSS\OutputFormat`:
+If you want to format the output, pass an instance of type `\Sabberworm\CSS\OutputFormat`:
```php
-$oFormat = Sabberworm\CSS\OutputFormat::create()->indentWithSpaces(4)->setSpaceBetweenRules("\n");
-print $oCssDocument->render($oFormat);
+$format = \Sabberworm\CSS\OutputFormat::create()
+ ->indentWithSpaces(4)->setSpaceBetweenRules("\n");
+print $cssDocument->render($format);
```
Or use one of the predefined formats:
```php
-print $oCssDocument->render(Sabberworm\CSS\OutputFormat::createPretty());
-print $oCssDocument->render(Sabberworm\CSS\OutputFormat::createCompact());
+print $cssDocument->render(Sabberworm\CSS\OutputFormat::createPretty());
+print $cssDocument->render(Sabberworm\CSS\OutputFormat::createCompact());
```
-To see what you can do with output formatting, look at the tests in `tests/Sabberworm/CSS/OutputFormatTest.php`.
+To see what you can do with output formatting, look at the tests in `tests/OutputFormatTest.php`.
## Examples
@@ -198,62 +202,63 @@ To see what you can do with output formatting, look at the tests in `tests/Sabbe
@font-face {
font-family: "CrassRoots";
- src: url("../media/cr.ttf")
+ src: url("../media/cr.ttf");
}
html, body {
- font-size: 1.6em
+ font-size: 1.6em;
}
@keyframes mymove {
- from { top: 0px; }
- to { top: 200px; }
+ from { top: 0px; }
+ to { top: 200px; }
}
```
-#### Structure (`var_dump()`)
+
+ Structure (var_dump()
)
```php
class Sabberworm\CSS\CSSList\Document#4 (2) {
- protected $aContents =>
+ protected $contents =>
array(4) {
[0] =>
class Sabberworm\CSS\Property\Charset#6 (2) {
- private $sCharset =>
+ private $charset =>
class Sabberworm\CSS\Value\CSSString#5 (2) {
- private $sString =>
+ private $string =>
string(5) "utf-8"
- protected $iLineNo =>
+ protected $lineNumber =>
int(1)
}
- protected $iLineNo =>
+ protected $lineNumber =>
int(1)
}
[1] =>
class Sabberworm\CSS\RuleSet\AtRuleSet#7 (4) {
- private $sType =>
+ private $type =>
string(9) "font-face"
- private $sArgs =>
+ private $arguments =>
string(0) ""
- private $aRules =>
+ private $rules =>
array(2) {
'font-family' =>
array(1) {
[0] =>
class Sabberworm\CSS\Rule\Rule#8 (4) {
- private $sRule =>
+ private $rule =>
string(11) "font-family"
- private $mValue =>
+ private $value =>
class Sabberworm\CSS\Value\CSSString#9 (2) {
- private $sString =>
+ private $string =>
string(10) "CrassRoots"
- protected $iLineNo =>
+ protected $lineNumber =>
int(4)
}
- private $bIsImportant =>
+ private $isImportant =>
bool(false)
- protected $iLineNo =>
+ protected $lineNumber =>
int(4)
}
}
@@ -261,76 +266,76 @@ class Sabberworm\CSS\CSSList\Document#4 (2) {
array(1) {
[0] =>
class Sabberworm\CSS\Rule\Rule#10 (4) {
- private $sRule =>
+ private $rule =>
string(3) "src"
- private $mValue =>
+ private $value =>
class Sabberworm\CSS\Value\URL#11 (2) {
- private $oURL =>
+ private $url =>
class Sabberworm\CSS\Value\CSSString#12 (2) {
- private $sString =>
+ private $string =>
string(15) "../media/cr.ttf"
- protected $iLineNo =>
+ protected $lineNumber =>
int(5)
}
- protected $iLineNo =>
+ protected $lineNumber =>
int(5)
}
- private $bIsImportant =>
+ private $isImportant =>
bool(false)
- protected $iLineNo =>
+ protected $lineNumber =>
int(5)
}
}
}
- protected $iLineNo =>
+ protected $lineNumber =>
int(3)
}
[2] =>
class Sabberworm\CSS\RuleSet\DeclarationBlock#13 (3) {
- private $aSelectors =>
+ private $selectors =>
array(2) {
[0] =>
class Sabberworm\CSS\Property\Selector#14 (2) {
- private $sSelector =>
+ private $selector =>
string(4) "html"
- private $iSpecificity =>
+ private $specificity =>
NULL
}
[1] =>
class Sabberworm\CSS\Property\Selector#15 (2) {
- private $sSelector =>
+ private $selector =>
string(4) "body"
- private $iSpecificity =>
+ private $specificity =>
NULL
}
}
- private $aRules =>
+ private $rules =>
array(1) {
'font-size' =>
array(1) {
[0] =>
class Sabberworm\CSS\Rule\Rule#16 (4) {
- private $sRule =>
+ private $rule =>
string(9) "font-size"
- private $mValue =>
+ private $value =>
class Sabberworm\CSS\Value\Size#17 (4) {
- private $fSize =>
+ private $size =>
double(1.6)
- private $sUnit =>
+ private $unit =>
string(2) "em"
- private $bIsColorComponent =>
+ private $isColorComponent =>
bool(false)
- protected $iLineNo =>
+ protected $lineNumber =>
int(9)
}
- private $bIsImportant =>
+ private $isImportant =>
bool(false)
- protected $iLineNo =>
+ protected $lineNumber =>
int(9)
}
}
}
- protected $iLineNo =>
+ protected $lineNumber =>
int(8)
}
[3] =>
@@ -339,100 +344,101 @@ class Sabberworm\CSS\CSSList\Document#4 (2) {
string(9) "keyframes"
private $animationName =>
string(6) "mymove"
- protected $aContents =>
+ protected $contents =>
array(2) {
[0] =>
class Sabberworm\CSS\RuleSet\DeclarationBlock#19 (3) {
- private $aSelectors =>
+ private $selectors =>
array(1) {
[0] =>
class Sabberworm\CSS\Property\Selector#20 (2) {
- private $sSelector =>
+ private $selector =>
string(4) "from"
- private $iSpecificity =>
+ private $specificity =>
NULL
}
}
- private $aRules =>
+ private $rules =>
array(1) {
'top' =>
array(1) {
[0] =>
class Sabberworm\CSS\Rule\Rule#21 (4) {
- private $sRule =>
+ private $rule =>
string(3) "top"
- private $mValue =>
+ private $value =>
class Sabberworm\CSS\Value\Size#22 (4) {
- private $fSize =>
+ private $size =>
double(0)
- private $sUnit =>
+ private $unit =>
string(2) "px"
- private $bIsColorComponent =>
+ private $isColorComponent =>
bool(false)
- protected $iLineNo =>
+ protected $lineNumber =>
int(13)
}
- private $bIsImportant =>
+ private $isImportant =>
bool(false)
- protected $iLineNo =>
+ protected $lineNumber =>
int(13)
}
}
}
- protected $iLineNo =>
+ protected $lineNumber =>
int(13)
}
[1] =>
class Sabberworm\CSS\RuleSet\DeclarationBlock#23 (3) {
- private $aSelectors =>
+ private $selectors =>
array(1) {
[0] =>
class Sabberworm\CSS\Property\Selector#24 (2) {
- private $sSelector =>
+ private $selector =>
string(2) "to"
- private $iSpecificity =>
+ private $specificity =>
NULL
}
}
- private $aRules =>
+ private $rules =>
array(1) {
'top' =>
array(1) {
[0] =>
class Sabberworm\CSS\Rule\Rule#25 (4) {
- private $sRule =>
+ private $rule =>
string(3) "top"
- private $mValue =>
+ private $value =>
class Sabberworm\CSS\Value\Size#26 (4) {
- private $fSize =>
+ private $size =>
double(200)
- private $sUnit =>
+ private $unit =>
string(2) "px"
- private $bIsColorComponent =>
+ private $isColorComponent =>
bool(false)
- protected $iLineNo =>
+ protected $lineNumber =>
int(14)
}
- private $bIsImportant =>
+ private $isImportant =>
bool(false)
- protected $iLineNo =>
+ protected $lineNumber =>
int(14)
}
}
}
- protected $iLineNo =>
+ protected $lineNumber =>
int(14)
}
}
- protected $iLineNo =>
+ protected $lineNumber =>
int(12)
}
}
- protected $iLineNo =>
+ protected $lineNumber =>
int(1)
}
```
+
#### Output (`render()`)
@@ -440,8 +446,7 @@ class Sabberworm\CSS\CSSList\Document#4 (2) {
@charset "utf-8";
@font-face {font-family: "CrassRoots";src: url("../media/cr.ttf");}
html, body {font-size: 1.6em;}
-@keyframes mymove {from {top: 0px;}
- to {top: 200px;}}
+@keyframes mymove {from {top: 0px;} to {top: 200px;}}
```
### Example 2 (Values)
@@ -450,96 +455,97 @@ html, body {font-size: 1.6em;}
```css
#header {
- margin: 10px 2em 1cm 2%;
- font-family: Verdana, Helvetica, "Gill Sans", sans-serif;
- color: red !important;
+ margin: 10px 2em 1cm 2%;
+ font-family: Verdana, Helvetica, "Gill Sans", sans-serif;
+ color: red !important;
}
```
-#### Structure (`var_dump()`)
+
+ Structure (var_dump()
)
```php
class Sabberworm\CSS\CSSList\Document#4 (2) {
- protected $aContents =>
+ protected $contents =>
array(1) {
[0] =>
class Sabberworm\CSS\RuleSet\DeclarationBlock#5 (3) {
- private $aSelectors =>
+ private $selectors =>
array(1) {
[0] =>
class Sabberworm\CSS\Property\Selector#6 (2) {
- private $sSelector =>
+ private $selector =>
string(7) "#header"
- private $iSpecificity =>
+ private $specificity =>
NULL
}
}
- private $aRules =>
+ private $rules =>
array(3) {
'margin' =>
array(1) {
[0] =>
class Sabberworm\CSS\Rule\Rule#7 (4) {
- private $sRule =>
+ private $rule =>
string(6) "margin"
- private $mValue =>
+ private $value =>
class Sabberworm\CSS\Value\RuleValueList#12 (3) {
- protected $aComponents =>
+ protected $components =>
array(4) {
[0] =>
class Sabberworm\CSS\Value\Size#8 (4) {
- private $fSize =>
+ private $size =>
double(10)
- private $sUnit =>
+ private $unit =>
string(2) "px"
- private $bIsColorComponent =>
+ private $isColorComponent =>
bool(false)
- protected $iLineNo =>
+ protected $lineNumber =>
int(2)
}
[1] =>
class Sabberworm\CSS\Value\Size#9 (4) {
- private $fSize =>
+ private $size =>
double(2)
- private $sUnit =>
+ private $unit =>
string(2) "em"
- private $bIsColorComponent =>
+ private $isColorComponent =>
bool(false)
- protected $iLineNo =>
+ protected $lineNumber =>
int(2)
}
[2] =>
class Sabberworm\CSS\Value\Size#10 (4) {
- private $fSize =>
+ private $size =>
double(1)
- private $sUnit =>
+ private $unit =>
string(2) "cm"
- private $bIsColorComponent =>
+ private $isColorComponent =>
bool(false)
- protected $iLineNo =>
+ protected $lineNumber =>
int(2)
}
[3] =>
class Sabberworm\CSS\Value\Size#11 (4) {
- private $fSize =>
+ private $size =>
double(2)
- private $sUnit =>
+ private $unit =>
string(1) "%"
- private $bIsColorComponent =>
+ private $isColorComponent =>
bool(false)
- protected $iLineNo =>
+ protected $lineNumber =>
int(2)
}
}
- protected $sSeparator =>
+ protected $separator =>
string(1) " "
- protected $iLineNo =>
+ protected $lineNumber =>
int(2)
}
- private $bIsImportant =>
+ private $isImportant =>
bool(false)
- protected $iLineNo =>
+ protected $lineNumber =>
int(2)
}
}
@@ -547,11 +553,11 @@ class Sabberworm\CSS\CSSList\Document#4 (2) {
array(1) {
[0] =>
class Sabberworm\CSS\Rule\Rule#13 (4) {
- private $sRule =>
+ private $rule =>
string(11) "font-family"
- private $mValue =>
+ private $value =>
class Sabberworm\CSS\Value\RuleValueList#15 (3) {
- protected $aComponents =>
+ protected $components =>
array(4) {
[0] =>
string(7) "Verdana"
@@ -559,9 +565,9 @@ class Sabberworm\CSS\CSSList\Document#4 (2) {
string(9) "Helvetica"
[2] =>
class Sabberworm\CSS\Value\CSSString#14 (2) {
- private $sString =>
+ private $string =>
string(9) "Gill Sans"
- protected $iLineNo =>
+ protected $lineNumber =>
int(3)
}
[3] =>
@@ -569,12 +575,12 @@ class Sabberworm\CSS\CSSList\Document#4 (2) {
}
protected $sSeparator =>
string(1) ","
- protected $iLineNo =>
+ protected $lineNumber =>
int(3)
}
- private $bIsImportant =>
+ private $isImportant =>
bool(false)
- protected $iLineNo =>
+ protected $lineNumber =>
int(3)
}
}
@@ -582,26 +588,27 @@ class Sabberworm\CSS\CSSList\Document#4 (2) {
array(1) {
[0] =>
class Sabberworm\CSS\Rule\Rule#16 (4) {
- private $sRule =>
+ private $rule =>
string(5) "color"
- private $mValue =>
+ private $value =>
string(3) "red"
- private $bIsImportant =>
+ private $isImportant =>
bool(true)
- protected $iLineNo =>
+ protected $lineNumber =>
int(4)
}
}
}
- protected $iLineNo =>
+ protected $lineNumber =>
int(1)
}
}
- protected $iLineNo =>
+ protected $lineNumber =>
int(1)
}
```
+
#### Output (`render()`)
@@ -609,8 +616,210 @@ class Sabberworm\CSS\CSSList\Document#4 (2) {
#header {margin: 10px 2em 1cm 2%;font-family: Verdana,Helvetica,"Gill Sans",sans-serif;color: red !important;}
```
+## Class diagram
+
+```mermaid
+classDiagram
+ direction LR
+
+ %% Start of the part originally generated from the PHP code using tasuku43/mermaid-class-diagram
+
+ class CSSElement {
+ <>
+ }
+ class Renderable {
+ <>
+ }
+ class Positionable {
+ <>
+ }
+ class CSSListItem {
+ <>
+ }
+ class RuleContainer {
+ <>
+ }
+ class DeclarationBlock {
+ }
+ class RuleSet {
+ <>
+ }
+ class AtRuleSet {
+ }
+ class KeyframeSelector {
+ }
+ class AtRule {
+ <>
+ }
+ class Charset {
+ }
+ class Import {
+ }
+ class Selector {
+ }
+ class CSSNamespace {
+ }
+ class Settings {
+ }
+ class Rule {
+ }
+ class Parser {
+ }
+ class OutputFormatter {
+ }
+ class OutputFormat {
+ }
+ class OutputException {
+ }
+ class UnexpectedEOFException {
+ }
+ class SourceException {
+ }
+ class UnexpectedTokenException {
+ }
+ class ParserState {
+ }
+ class Anchor {
+ }
+ class CSSBlockList {
+ <>
+ }
+ class Document {
+ }
+ class CSSList {
+ <>
+ }
+ class KeyFrame {
+ }
+ class AtRuleBlockList {
+ }
+ class Color {
+ }
+ class URL {
+ }
+ class CalcRuleValueList {
+ }
+ class ValueList {
+ <>
+ }
+ class CalcFunction {
+ }
+ class LineName {
+ }
+ class Value {
+ <>
+ }
+ class Size {
+ }
+ class CSSString {
+ }
+ class PrimitiveValue {
+ <>
+ }
+ class CSSFunction {
+ }
+ class RuleValueList {
+ }
+ class Commentable {
+ <>
+ }
+ class Comment {
+ }
+
+ RuleSet <|-- DeclarationBlock: inheritance
+ Renderable <|-- CSSElement: inheritance
+ Renderable <|-- CSSListItem: inheritance
+ Commentable <|-- CSSListItem: inheritance
+ Positionable <|.. RuleSet: realization
+ CSSElement <|.. RuleSet: realization
+ CSSListItem <|.. RuleSet: realization
+ RuleContainer <|.. RuleSet: realization
+ RuleSet <|-- AtRuleSet: inheritance
+ AtRule <|.. AtRuleSet: realization
+ Renderable <|.. Selector: realization
+ Selector <|-- KeyframeSelector: inheritance
+ CSSListItem <|-- AtRule: inheritance
+ Positionable <|.. Charset: realization
+ AtRule <|.. Charset: realization
+ Positionable <|.. Import: realization
+ AtRule <|.. Import: realization
+ Positionable <|.. CSSNamespace: realization
+ AtRule <|.. CSSNamespace: realization
+ CSSElement <|.. Rule: realization
+ Positionable <|.. Rule: realization
+ Commentable <|.. Rule: realization
+ SourceException <|-- OutputException: inheritance
+ UnexpectedTokenException <|-- UnexpectedEOFException: inheritance
+ Exception <|-- SourceException: inheritance
+ Positionable <|.. SourceException: realization
+ SourceException <|-- UnexpectedTokenException: inheritance
+ CSSList <|-- CSSBlockList: inheritance
+ CSSBlockList <|-- Document: inheritance
+ CSSElement <|.. CSSList: realization
+ Positionable <|.. CSSList: realization
+ CSSListItem <|.. CSSList: realization
+ CSSList <|-- KeyFrame: inheritance
+ AtRule <|.. KeyFrame: realization
+ CSSBlockList <|-- AtRuleBlockList: inheritance
+ AtRule <|.. AtRuleBlockList: realization
+ CSSFunction <|-- Color: inheritance
+ PrimitiveValue <|-- URL: inheritance
+ RuleValueList <|-- CalcRuleValueList: inheritance
+ Value <|-- ValueList: inheritance
+ CSSFunction <|-- CalcFunction: inheritance
+ ValueList <|-- LineName: inheritance
+ CSSElement <|.. Value: realization
+ Positionable <|.. Value: realization
+ PrimitiveValue <|-- Size: inheritance
+ PrimitiveValue <|-- CSSString: inheritance
+ Value <|-- PrimitiveValue: inheritance
+ ValueList <|-- CSSFunction: inheritance
+ ValueList <|-- RuleValueList: inheritance
+ Renderable <|.. Comment: realization
+ Positionable <|.. Comment: realization
+
+ %% end of the generated part
+
+
+ Anchor --> "1" ParserState : parserState
+ CSSList --> "*" CSSList : contents
+ CSSList --> "*" Charset : contents
+ CSSList --> "*" Comment : comments
+ CSSList --> "*" Import : contents
+ CSSList --> "*" RuleSet : contents
+ CSSNamespace --> "*" Comment : comments
+ Charset --> "*" Comment : comments
+ Charset --> "1" CSSString : charset
+ DeclarationBlock --> "*" Selector : selectors
+ Import --> "*" Comment : comments
+ OutputFormat --> "1" OutputFormat : nextLevelFormat
+ OutputFormat --> "1" OutputFormatter : outputFormatter
+ OutputFormatter --> "1" OutputFormat : outputFormat
+ Parser --> "1" ParserState : parserState
+ ParserState --> "1" Settings : parserSettings
+ Rule --> "*" Comment : comments
+ Rule --> "1" RuleValueList : value
+ RuleSet --> "*" Comment : comments
+ RuleSet --> "*" Rule : rules
+ URL --> "1" CSSString : url
+ ValueList --> "*" Value : components
+```
+
+## API and deprecation policy
+
+Please have a look at our
+[API and deprecation policy](docs/API-and-deprecation-policy.md).
+
+## Contributing
+
+Contributions in the form of bug reports, feature requests, or pull requests are
+more than welcome. :pray: Please have a look at our
+[contribution guidelines](CONTRIBUTING.md) to learn more about how to
+contribute to PHP-CSS-Parser.
+
## Contributors/Thanks to
+* [oliverklee](https://github.com/oliverklee) for lots of refactorings, code modernizations and CI integrations
* [raxbg](https://github.com/raxbg) for contributions to parse `calc`, grid lines, and various bugfixes.
* [westonruter](https://github.com/westonruter) for bugfixes and improvements.
* [FMCorz](https://github.com/FMCorz) for many patches and suggestions, for being able to parse comments and IE hacks (in lenient mode).
@@ -621,10 +830,12 @@ class Sabberworm\CSS\CSSList\Document#4 (2) {
* [docteurklein](https://github.com/docteurklein) for output formatting and `CSSList->remove()` inspiration.
* [nicolopignatelli](https://github.com/nicolopignatelli) for PSR-0 compatibility.
* [diegoembarcadero](https://github.com/diegoembarcadero) for keyframe at-rule parsing.
-* [goetas](https://github.com/goetas) for @namespace at-rule support.
+* [goetas](https://github.com/goetas) for `@namespace` at-rule support.
+* [ziegenberg](https://github.com/ziegenberg) for general housekeeping and cleanup.
* [View full list](https://github.com/sabberworm/PHP-CSS-Parser/contributors)
## Misc
-* Legacy Support: The latest pre-PSR-0 version of this project can be checked with the `0.9.0` tag.
-* Running Tests: To run all unit tests for this project, run `composer install` to install phpunit and use `./vendor/phpunit/phpunit/phpunit`.
+### Legacy Support
+
+The latest pre-PSR-0 version of this project can be checked with the `0.9.0` tag.
diff --git a/tests/quickdump.php b/bin/quickdump.php
similarity index 60%
rename from tests/quickdump.php
rename to bin/quickdump.php
index 123fa5bdb..c759d028a 100755
--- a/tests/quickdump.php
+++ b/bin/quickdump.php
@@ -1,9 +1,15 @@
#!/usr/bin/env php
parse();
@@ -11,7 +17,7 @@
print $sSource;
echo "\n```\n\n" . '#### Structure (`var_dump()`)' . "\n\n```php\n";
-var_dump($oDoc);
+\var_dump($oDoc);
echo "\n```\n\n" . '#### Output (`render()`)' . "\n\n```css\n";
print $oDoc->render();
diff --git a/composer.json b/composer.json
index 6f162ab92..e4ea9c43d 100644
--- a/composer.json
+++ b/composer.json
@@ -1,34 +1,126 @@
{
"name": "sabberworm/php-css-parser",
- "type": "library",
"description": "Parser for CSS Files written in PHP",
- "keywords": ["parser", "css", "stylesheet"],
- "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"license": "MIT",
+ "type": "library",
+ "keywords": [
+ "parser",
+ "css",
+ "stylesheet"
+ ],
"authors": [
- {"name": "Raphael Schweikert"}
+ {
+ "name": "Raphael Schweikert"
+ },
+ {
+ "name": "Oliver Klee",
+ "email": "github@oliverklee.de"
+ },
+ {
+ "name": "Jake Hotson",
+ "email": "jake.github@qzdesign.co.uk"
+ }
],
+ "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"require": {
- "php": ">=5.6.20"
+ "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0",
+ "ext-iconv": "*"
},
"require-dev": {
- "phpunit/phpunit": "^4.8.36",
- "codacy/coverage": "^1.4"
+ "php-parallel-lint/php-parallel-lint": "1.4.0",
+ "phpstan/extension-installer": "1.4.3",
+ "phpstan/phpstan": "1.12.16 || 2.1.2",
+ "phpstan/phpstan-phpunit": "1.4.2 || 2.0.4",
+ "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.3",
+ "phpunit/phpunit": "8.5.42",
+ "rawr/phpunit-data-provider": "3.3.1",
+ "rector/rector": "1.2.10 || 2.0.7",
+ "rector/type-perfect": "1.0.0 || 2.0.2"
+ },
+ "suggest": {
+ "ext-mbstring": "for parsing UTF-8 CSS"
},
"autoload": {
- "psr-4": { "Sabberworm\\CSS\\": "lib/Sabberworm/CSS/" }
+ "psr-4": {
+ "Sabberworm\\CSS\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Sabberworm\\CSS\\Tests\\": "tests/"
+ }
+ },
+ "config": {
+ "allow-plugins": {
+ "phpstan/extension-installer": true
+ },
+ "preferred-install": {
+ "*": "dist"
+ },
+ "sort-packages": true
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-main": "9.0.x-dev"
+ }
},
"scripts": {
"ci": [
- "@ci:static"
+ "@ci:static",
+ "@ci:dynamic"
+ ],
+ "ci:composer:normalize": "\"./.phive/composer-normalize\" --dry-run",
+ "ci:dynamic": [
+ "@ci:tests"
],
- "ci:php:sniff": "@php ./.phive/phpcs.phar lib 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:php:sniff"
+ "@ci:composer:normalize",
+ "@ci:php:fixer",
+ "@ci:php:lint",
+ "@ci:php:rector",
+ "@ci:php:stan"
],
+ "ci:tests": [
+ "@ci: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",
+ "fix": [
+ "@fix:php"
+ ],
+ "fix:composer:normalize": "\"./.phive/composer-normalize\" --no-check-lock",
"fix:php": [
- "@fix:php:sniff"
+ "@fix:composer:normalize",
+ "@fix:php:rector",
+ "@fix:php:fixer"
],
- "fix:php:sniff": "@php ./.phive/phpcbf.phar lib 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.",
+ "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: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/Doxyfile b/config/Doxyfile
similarity index 100%
rename from Doxyfile
rename to config/Doxyfile
diff --git a/config/php-cs-fixer.php b/config/php-cs-fixer.php
new file mode 100644
index 000000000..96b39bbbe
--- /dev/null
+++ b/config/php-cs-fixer.php
@@ -0,0 +1,103 @@
+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,
+
+ // overwrite the PER2 defaults to restore compatibility with PHP 7.x
+ 'trailing_comma_in_multiline' => ['elements' => ['arrays']],
+
+ // casing
+ 'magic_constant_casing' => true,
+ 'native_function_casing' => true,
+
+ // cast notation
+ 'modernize_types_casting' => true,
+ 'no_short_bool_cast' => true,
+
+ // class notation
+ 'no_php4_constructor' => true,
+
+ // comment
+ 'no_empty_comment' => true,
+
+ // control structure
+ 'yoda_style' => ['equal' => false, 'identical' => false, 'less_and_greater' => false],
+
+ // function notation
+ 'native_function_invocation' => ['include' => ['@all']],
+ 'nullable_type_declaration_for_default_null_value' => true,
+
+ // import
+ 'no_unused_imports' => true,
+
+ // language construct
+ 'combine_consecutive_issets' => true,
+ 'combine_consecutive_unsets' => true,
+ 'dir_constant' => true,
+ 'is_null' => true,
+ 'nullable_type_declaration' => true,
+
+ // namespace notation
+ 'no_leading_namespace_whitespace' => true,
+
+ // operator
+ 'standardize_not_equals' => true,
+ 'ternary_to_null_coalescing' => true,
+
+ // PHP tag
+ 'linebreak_after_opening_tag' => true,
+
+ // PHPUnit
+ 'php_unit_construct' => true,
+ 'php_unit_dedicate_assert' => ['target' => 'newest'],
+ 'php_unit_expectation' => ['target' => 'newest'],
+ 'php_unit_fqcn_annotation' => true,
+ 'php_unit_mock_short_will_return' => true,
+ 'php_unit_set_up_tear_down_visibility' => true,
+ 'php_unit_test_annotation' => ['style' => 'annotation'],
+ 'php_unit_test_case_static_method_calls' => ['call_type' => 'self'],
+
+ // PHPDoc
+ 'no_blank_lines_after_phpdoc' => true,
+ 'no_empty_phpdoc' => true,
+ 'phpdoc_indent' => true,
+ 'phpdoc_no_package' => true,
+ 'phpdoc_trim' => true,
+ 'phpdoc_types_order' => ['null_adjustment' => 'always_last', 'sort_algorithm' => 'none'],
+
+ // return notation
+ 'no_useless_return' => true,
+
+ // semicolon
+ 'no_empty_statement' => true,
+ 'no_singleline_whitespace_before_semicolons' => true,
+ 'semicolon_after_instruction' => true,
+
+ // strict
+ 'declare_strict_types' => true,
+ 'strict_param' => true,
+
+ // string notation
+ 'single_quote' => true,
+ 'string_implicit_backslashes' => ['single_quoted' => 'escape'],
+
+ // whitespace
+ 'statement_indentation' => false,
+ ]
+ );
diff --git a/config/phpstan-baseline.neon b/config/phpstan-baseline.neon
new file mode 100644
index 000000000..9be2ebd7e
--- /dev/null
+++ b/config/phpstan-baseline.neon
@@ -0,0 +1,85 @@
+parameters:
+ ignoreErrors:
+ -
+ message: '#^Only booleans are allowed in an if condition, string given\.$#'
+ identifier: if.condNotBoolean
+ count: 1
+ path: ../src/CSSList/AtRuleBlockList.php
+
+ -
+ message: '#^Loose comparison via "\!\=" is not allowed\.$#'
+ identifier: notEqual.notAllowed
+ count: 1
+ path: ../src/CSSList/CSSList.php
+
+ -
+ message: '#^Loose comparison via "\=\=" is not allowed\.$#'
+ identifier: equal.notAllowed
+ count: 1
+ path: ../src/CSSList/CSSList.php
+
+ -
+ message: '#^Parameters should have "Sabberworm\\CSS\\CSSList\\CSSListItem\|array" types as the only types passed to this method$#'
+ identifier: typePerfect.narrowPublicClassMethodParamType
+ count: 1
+ path: ../src/CSSList/CSSList.php
+
+ -
+ message: '#^Short ternary operator is not allowed\. Use null coalesce operator if applicable or consider using long ternary\.$#'
+ identifier: ternary.shortNotAllowed
+ count: 1
+ path: ../src/CSSList/CSSList.php
+
+ -
+ message: '#^Parameters should have "string\|null" types as the only types passed to this method$#'
+ identifier: typePerfect.narrowPublicClassMethodParamType
+ count: 1
+ path: ../src/CSSList/Document.php
+
+ -
+ message: '#^Only booleans are allowed in an if condition, Sabberworm\\CSS\\Value\\RuleValueList\|string\|null given\.$#'
+ identifier: if.condNotBoolean
+ count: 1
+ path: ../src/Rule/Rule.php
+
+ -
+ message: '#^Loose comparison via "\!\=" is not allowed\.$#'
+ identifier: notEqual.notAllowed
+ count: 1
+ path: ../src/RuleSet/DeclarationBlock.php
+
+ -
+ message: '#^Parameters should have "string" types as the only types passed to this method$#'
+ identifier: typePerfect.narrowPublicClassMethodParamType
+ count: 1
+ path: ../src/RuleSet/DeclarationBlock.php
+
+ -
+ message: '#^Loose comparison via "\!\=" is not allowed\.$#'
+ identifier: notEqual.notAllowed
+ count: 3
+ path: ../src/Value/CalcFunction.php
+
+ -
+ message: '#^Cannot call method getSize\(\) on Sabberworm\\CSS\\Value\\Value\|string\.$#'
+ identifier: method.nonObject
+ count: 3
+ path: ../src/Value/Color.php
+
+ -
+ message: '#^Loose comparison via "\=\=" is not allowed\.$#'
+ identifier: equal.notAllowed
+ count: 3
+ path: ../src/Value/Color.php
+
+ -
+ message: '#^Loose comparison via "\!\=" is not allowed\.$#'
+ identifier: notEqual.notAllowed
+ count: 1
+ path: ../src/Value/Size.php
+
+ -
+ message: '#^Parameters should have "float" types as the only types passed to this method$#'
+ identifier: typePerfect.narrowPublicClassMethodParamType
+ count: 1
+ path: ../src/Value/Size.php
diff --git a/config/phpstan.neon b/config/phpstan.neon
new file mode 100644
index 000000000..6a410ac24
--- /dev/null
+++ b/config/phpstan.neon
@@ -0,0 +1,23 @@
+includes:
+ - phpstan-baseline.neon
+
+parameters:
+ parallel:
+ # Don't be overly greedy on machines with more CPU's to be a good neighbor especially on CI
+ maximumNumberOfProcesses: 5
+
+ phpVersion: 70200
+
+ level: 3
+
+ paths:
+ - %currentWorkingDirectory%/bin/
+ - %currentWorkingDirectory%/src/
+ - %currentWorkingDirectory%/tests/
+
+ type_perfect:
+ no_mixed_property: true
+ no_mixed_caller: true
+ null_over_false: true
+ narrow_param: true
+ narrow_return: true
diff --git a/config/rector.php b/config/rector.php
new file mode 100644
index 000000000..57c98235a
--- /dev/null
+++ b/config/rector.php
@@ -0,0 +1,41 @@
+withPaths(
+ [
+ __DIR__ . '/../src',
+ __DIR__ . '/../tests',
+ ]
+ )
+ ->withSets([
+ // Rector sets
+
+ LevelSetList::UP_TO_PHP_72,
+
+ // SetList::CODE_QUALITY,
+ // SetList::CODING_STYLE,
+ // SetList::DEAD_CODE,
+ // SetList::EARLY_RETURN,
+ // SetList::INSTANCEOF,
+ // SetList::NAMING,
+ // SetList::PRIVATIZATION,
+ SetList::STRICT_BOOLEANS,
+ SetList::TYPE_DECLARATION,
+
+ // PHPUnit sets
+
+ PHPUnitSetList::PHPUNIT_80,
+ // PHPUnitSetList::PHPUNIT_CODE_QUALITY,
+ ])
+ ->withRules([
+ AddVoidReturnTypeWhereNoReturnRector::class,
+ ])
+ ->withImportNames(true, true, false);
diff --git a/docs/API-and-deprecation-policy.md b/docs/API-and-deprecation-policy.md
new file mode 100644
index 000000000..57e2acec7
--- /dev/null
+++ b/docs/API-and-deprecation-policy.md
@@ -0,0 +1,52 @@
+# API and Deprecation Policy
+
+## API Policy
+
+The code in this library is intended to be called by other projects. It is not
+intended to be extended. If you want to extend any classes, you're on your own,
+and your code might break with any new release of this library.
+
+Any classes, methods and properties that are `public` and not marked as
+`@internal` are considered to be part of the API. Those methods will continue
+working in a compatible way over minor and bug-fix releases according
+to [Semantic Versioning](https://semver.org/), though we might change the native
+type declarations in a way that could break subclasses.
+
+Any classes, methods and properties that are `protected` or `private` are _not_
+considered part of the API. Please do not rely on them. If you do, you're on
+your own.
+
+Any code that is marked as `@internal` is subject to change or removal without
+notice. Please do not call it. There be dragons.
+
+If a class is marked as `@internal`, all properties and methods of this class
+are by definition considered to be internal as well.
+
+When we change some code from public to `@internal` in a release, the first
+release that might change that code in a breaking way will be the next major
+release after that. This will allow you to change your code accordingly. We'll
+also add since which version the code is internal.
+
+For example, we might mark some code as `@internal` in version 8.7.0. The first
+version that possibly changes this code in a breaking way will then be version
+9.0.0.
+
+Before you upgrade your code to the next major version of this library, please
+update to the latest release of the previous major version and make sure that
+your code does not reference any code that is marked as `@internal`.
+
+## Deprecation Policy
+
+Code that we plan to remove is marked as `@deprecated`. In the corresponding
+annotation, we also note in which release the code will be removed.
+
+When we mark some code as `@deprecated` in a release, we'll usually remove it in
+the next major release. We'll also add since which version the code is
+deprecated.
+
+For example, when we mark some code as `@deprecated` in version 8.7.0, we'll
+remove it in version 9.0.0 (or sometimes a later major release).
+
+Before you upgrade your code to the next major version of this library, please
+update to the latest release of the previous major version and make sure that
+your code does not reference any code that is marked as `@deprecated`.
diff --git a/docs/release-checklist.md b/docs/release-checklist.md
new file mode 100644
index 000000000..48d50b7e6
--- /dev/null
+++ b/docs/release-checklist.md
@@ -0,0 +1,15 @@
+# Steps to release a new version
+
+1. In the [composer.json](../composer.json), update the `branch-alias` entry to
+ point to the release _after_ the upcoming release.
+1. In the [CHANGELOG.md](../CHANGELOG.md), create a new section with subheadings
+ for changes _after_ the upcoming release, set the version number for the
+ upcoming release, and remove any empty sections.
+1. Update the target milestone in the Dependabot configuration.
+1. Create a pull request "Prepare release of version x.y.z" with those changes.
+1. Have the pull request reviewed and merged.
+1. Tag the new release.
+1. In the
+ [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.
diff --git a/lib/Sabberworm/CSS/CSSList/AtRuleBlockList.php b/lib/Sabberworm/CSS/CSSList/AtRuleBlockList.php
deleted file mode 100644
index 37fa3140f..000000000
--- a/lib/Sabberworm/CSS/CSSList/AtRuleBlockList.php
+++ /dev/null
@@ -1,76 +0,0 @@
-sType = $sType;
- $this->sArgs = $sArgs;
- }
-
- /**
- * @return string
- */
- public function atRuleName()
- {
- return $this->sType;
- }
-
- /**
- * @return string
- */
- public function atRuleArgs()
- {
- return $this->sArgs;
- }
-
- public function __toString()
- {
- return $this->render(new \Sabberworm\CSS\OutputFormat());
- }
-
- /**
- * @return string
- */
- public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat)
- {
- $sArgs = $this->sArgs;
- if ($sArgs) {
- $sArgs = ' ' . $sArgs;
- }
- $sResult = $oOutputFormat->sBeforeAtRuleBlock;
- $sResult .= "@{$this->sType}$sArgs{$oOutputFormat->spaceBeforeOpeningBrace()}{";
- $sResult .= parent::render($oOutputFormat);
- $sResult .= '}';
- $sResult .= $oOutputFormat->sAfterAtRuleBlock;
- return $sResult;
- }
-
- public function isRootList()
- {
- return false;
- }
-}
diff --git a/lib/Sabberworm/CSS/CSSList/CSSBlockList.php b/lib/Sabberworm/CSS/CSSList/CSSBlockList.php
deleted file mode 100644
index f8065e8a0..000000000
--- a/lib/Sabberworm/CSS/CSSList/CSSBlockList.php
+++ /dev/null
@@ -1,112 +0,0 @@
-aContents as $mContent) {
- if ($mContent instanceof DeclarationBlock) {
- $aResult[] = $mContent;
- } elseif ($mContent instanceof CSSBlockList) {
- $mContent->allDeclarationBlocks($aResult);
- }
- }
- }
-
- protected function allRuleSets(&$aResult)
- {
- foreach ($this->aContents as $mContent) {
- if ($mContent instanceof RuleSet) {
- $aResult[] = $mContent;
- } elseif ($mContent instanceof CSSBlockList) {
- $mContent->allRuleSets($aResult);
- }
- }
- }
-
- protected function allValues($oElement, &$aResult, $sSearchString = null, $bSearchInFunctionArguments = false)
- {
- if ($oElement instanceof CSSBlockList) {
- foreach ($oElement->getContents() as $oContent) {
- $this->allValues($oContent, $aResult, $sSearchString, $bSearchInFunctionArguments);
- }
- } elseif ($oElement instanceof RuleSet) {
- foreach ($oElement->getRules($sSearchString) as $oRule) {
- $this->allValues($oRule, $aResult, $sSearchString, $bSearchInFunctionArguments);
- }
- } elseif ($oElement instanceof Rule) {
- $this->allValues($oElement->getValue(), $aResult, $sSearchString, $bSearchInFunctionArguments);
- } elseif ($oElement instanceof ValueList) {
- if ($bSearchInFunctionArguments || !($oElement instanceof CSSFunction)) {
- foreach ($oElement->getListComponents() as $mComponent) {
- $this->allValues($mComponent, $aResult, $sSearchString, $bSearchInFunctionArguments);
- }
- }
- } else {
- //Non-List Value or CSSString (CSS identifier)
- $aResult[] = $oElement;
- }
- }
-
- protected function allSelectors(&$aResult, $sSpecificitySearch = null)
- {
- $aDeclarationBlocks = [];
- $this->allDeclarationBlocks($aDeclarationBlocks);
- foreach ($aDeclarationBlocks as $oBlock) {
- foreach ($oBlock->getSelectors() as $oSelector) {
- if ($sSpecificitySearch === null) {
- $aResult[] = $oSelector;
- } else {
- $sComparator = '===';
- $aSpecificitySearch = explode(' ', $sSpecificitySearch);
- $iTargetSpecificity = $aSpecificitySearch[0];
- if (count($aSpecificitySearch) > 1) {
- $sComparator = $aSpecificitySearch[0];
- $iTargetSpecificity = $aSpecificitySearch[1];
- }
- $iTargetSpecificity = (int)$iTargetSpecificity;
- $iSelectorSpecificity = $oSelector->getSpecificity();
- $bMatches = false;
- switch ($sComparator) {
- case '<=':
- $bMatches = $iSelectorSpecificity <= $iTargetSpecificity;
- break;
- case '<':
- $bMatches = $iSelectorSpecificity < $iTargetSpecificity;
- break;
- case '>=':
- $bMatches = $iSelectorSpecificity >= $iTargetSpecificity;
- break;
- case '>':
- $bMatches = $iSelectorSpecificity > $iTargetSpecificity;
- break;
- default:
- $bMatches = $iSelectorSpecificity === $iTargetSpecificity;
- break;
- }
- if ($bMatches) {
- $aResult[] = $oSelector;
- }
- }
- }
- }
- }
-}
diff --git a/lib/Sabberworm/CSS/CSSList/CSSList.php b/lib/Sabberworm/CSS/CSSList/CSSList.php
deleted file mode 100644
index 7a4ef22c8..000000000
--- a/lib/Sabberworm/CSS/CSSList/CSSList.php
+++ /dev/null
@@ -1,403 +0,0 @@
-
- */
- protected $aContents;
-
- /**
- * @var int
- */
- protected $iLineNo;
-
- /**
- * @param int $iLineNo
- */
- public function __construct($iLineNo = 0)
- {
- $this->aComments = [];
- $this->aContents = [];
- $this->iLineNo = $iLineNo;
- }
-
- public static function parseList(ParserState $oParserState, CSSList $oList)
- {
- $bIsRoot = $oList instanceof Document;
- if (is_string($oParserState)) {
- $oParserState = new ParserState($oParserState);
- }
- $bLenientParsing = $oParserState->getSettings()->bLenientParsing;
- while (!$oParserState->isEnd()) {
- $comments = $oParserState->consumeWhiteSpace();
- $oListItem = null;
- if ($bLenientParsing) {
- try {
- $oListItem = self::parseListItem($oParserState, $oList);
- } catch (UnexpectedTokenException $e) {
- $oListItem = false;
- }
- } else {
- $oListItem = self::parseListItem($oParserState, $oList);
- }
- if ($oListItem === null) {
- // List parsing finished
- return;
- }
- if ($oListItem) {
- $oListItem->setComments($comments);
- $oList->append($oListItem);
- }
- $oParserState->consumeWhiteSpace();
- }
- if (!$bIsRoot && !$bLenientParsing) {
- throw new SourceException("Unexpected end of document", $oParserState->currentLine());
- }
- }
-
- private static function parseListItem(ParserState $oParserState, CSSList $oList)
- {
- $bIsRoot = $oList instanceof Document;
- if ($oParserState->comes('@')) {
- $oAtRule = self::parseAtRule($oParserState);
- if ($oAtRule instanceof Charset) {
- if (!$bIsRoot) {
- throw new UnexpectedTokenException('@charset may only occur in root document', '', 'custom', $oParserState->currentLine());
- }
- if (count($oList->getContents()) > 0) {
- throw new UnexpectedTokenException('@charset must be the first parseable token in a document', '', 'custom', $oParserState->currentLine());
- }
- $oParserState->setCharset($oAtRule->getCharset()->getString());
- }
- return $oAtRule;
- } elseif ($oParserState->comes('}')) {
- if (!$oParserState->getSettings()->bLenientParsing) {
- throw new UnexpectedTokenException('CSS selector', '}', 'identifier', $oParserState->currentLine());
- } else {
- if ($bIsRoot) {
- if ($oParserState->getSettings()->bLenientParsing) {
- return DeclarationBlock::parse($oParserState);
- } else {
- throw new SourceException("Unopened {", $oParserState->currentLine());
- }
- } else {
- return null;
- }
- }
- } else {
- return DeclarationBlock::parse($oParserState, $oList);
- }
- }
-
- private static function parseAtRule(ParserState $oParserState)
- {
- $oParserState->consume('@');
- $sIdentifier = $oParserState->parseIdentifier();
- $iIdentifierLineNum = $oParserState->currentLine();
- $oParserState->consumeWhiteSpace();
- if ($sIdentifier === 'import') {
- $oLocation = URL::parse($oParserState);
- $oParserState->consumeWhiteSpace();
- $sMediaQuery = null;
- if (!$oParserState->comes(';')) {
- $sMediaQuery = trim($oParserState->consumeUntil([';', ParserState::EOF]));
- }
- $oParserState->consumeUntil([';', ParserState::EOF], true, true);
- return new Import($oLocation, $sMediaQuery ?: null, $iIdentifierLineNum);
- } elseif ($sIdentifier === 'charset') {
- $sCharset = CSSString::parse($oParserState);
- $oParserState->consumeWhiteSpace();
- $oParserState->consumeUntil([';', ParserState::EOF], true, true);
- return new Charset($sCharset, $iIdentifierLineNum);
- } elseif (self::identifierIs($sIdentifier, 'keyframes')) {
- $oResult = new KeyFrame($iIdentifierLineNum);
- $oResult->setVendorKeyFrame($sIdentifier);
- $oResult->setAnimationName(trim($oParserState->consumeUntil('{', false, true)));
- CSSList::parseList($oParserState, $oResult);
- if ($oParserState->comes('}')) {
- $oParserState->consume('}');
- }
- return $oResult;
- } elseif ($sIdentifier === 'namespace') {
- $sPrefix = null;
- $mUrl = Value::parsePrimitiveValue($oParserState);
- if (!$oParserState->comes(';')) {
- $sPrefix = $mUrl;
- $mUrl = Value::parsePrimitiveValue($oParserState);
- }
- $oParserState->consumeUntil([';', ParserState::EOF], true, true);
- if ($sPrefix !== null && !is_string($sPrefix)) {
- throw new UnexpectedTokenException('Wrong namespace prefix', $sPrefix, 'custom', $iIdentifierLineNum);
- }
- if (!($mUrl instanceof CSSString || $mUrl instanceof URL)) {
- throw new UnexpectedTokenException('Wrong namespace url of invalid type', $mUrl, 'custom', $iIdentifierLineNum);
- }
- return new CSSNamespace($mUrl, $sPrefix, $iIdentifierLineNum);
- } else {
- //Unknown other at rule (font-face or such)
- $sArgs = trim($oParserState->consumeUntil('{', false, true));
- if (substr_count($sArgs, "(") != substr_count($sArgs, ")")) {
- if ($oParserState->getSettings()->bLenientParsing) {
- return null;
- } else {
- throw new SourceException("Unmatched brace count in media query", $oParserState->currentLine());
- }
- }
- $bUseRuleSet = true;
- foreach (explode('/', AtRule::BLOCK_RULES) as $sBlockRuleName) {
- if (self::identifierIs($sIdentifier, $sBlockRuleName)) {
- $bUseRuleSet = false;
- break;
- }
- }
- if ($bUseRuleSet) {
- $oAtRule = new AtRuleSet($sIdentifier, $sArgs, $iIdentifierLineNum);
- RuleSet::parseRuleSet($oParserState, $oAtRule);
- } else {
- $oAtRule = new AtRuleBlockList($sIdentifier, $sArgs, $iIdentifierLineNum);
- CSSList::parseList($oParserState, $oAtRule);
- if ($oParserState->comes('}')) {
- $oParserState->consume('}');
- }
- }
- return $oAtRule;
- }
- }
-
- /**
- * Tests an identifier for a given value. Since identifiers are all keywords, they can be vendor-prefixed. We need to check for these versions too.
- */
- private static function identifierIs($sIdentifier, $sMatch)
- {
- return (strcasecmp($sIdentifier, $sMatch) === 0)
- ?: preg_match("/^(-\\w+-)?$sMatch$/i", $sIdentifier) === 1;
- }
-
-
- /**
- * @return int
- */
- public function getLineNo()
- {
- return $this->iLineNo;
- }
-
- /**
- * Prepend item to list of contents.
- *
- * @param RuleSet|Import|Charset|CSSList $oItem Item.
- */
- public function prepend($oItem)
- {
- array_unshift($this->aContents, $oItem);
- }
-
- /**
- * Append item to list of contents.
- *
- * @param RuleSet|Import|Charset|CSSList $oItem Item.
- */
- public function append($oItem)
- {
- $this->aContents[] = $oItem;
- }
-
- /**
- * Splice the list of contents.
- *
- * @param int $iOffset Offset.
- * @param int $iLength Length. Optional.
- * @param RuleSet[] $mReplacement Replacement. Optional.
- */
- public function splice($iOffset, $iLength = null, $mReplacement = null)
- {
- array_splice($this->aContents, $iOffset, $iLength, $mReplacement);
- }
-
- /**
- * Removes an item from the CSS list.
- * @param RuleSet|Import|Charset|CSSList $oItemToRemove May be a RuleSet (most likely a DeclarationBlock), a Import, a Charset or another CSSList (most likely a MediaQuery)
- * @return bool Whether the item was removed.
- */
- public function remove($oItemToRemove)
- {
- $iKey = array_search($oItemToRemove, $this->aContents, true);
- if ($iKey !== false) {
- unset($this->aContents[$iKey]);
- return true;
- }
- return false;
- }
-
- /**
- * Replaces an item from the CSS list.
- *
- * @param RuleSet|Import|Charset|CSSList $oItemToRemove May be a RuleSet (most likely a DeclarationBlock), a Import, a Charset or another CSSList (most likely a MediaQuery)
- */
- public function replace($oOldItem, $mNewItem)
- {
- $iKey = array_search($oOldItem, $this->aContents, true);
- if ($iKey !== false) {
- if (is_array($mNewItem)) {
- array_splice($this->aContents, $iKey, 1, $mNewItem);
- } else {
- array_splice($this->aContents, $iKey, 1, [$mNewItem]);
- }
- return true;
- }
- return false;
- }
-
- /**
- * Set the contents.
- * @param array $aContents Objects to set as content.
- */
- public function setContents(array $aContents)
- {
- $this->aContents = [];
- foreach ($aContents as $content) {
- $this->append($content);
- }
- }
-
- /**
- * Removes a declaration block from the CSS list if it matches all given selectors.
- * @param array|string $mSelector The selectors to match.
- * @param boolean $bRemoveAll Whether to stop at the first declaration block found or remove all blocks
- */
- public function removeDeclarationBlockBySelector($mSelector, $bRemoveAll = false)
- {
- if ($mSelector instanceof DeclarationBlock) {
- $mSelector = $mSelector->getSelectors();
- }
- if (!is_array($mSelector)) {
- $mSelector = explode(',', $mSelector);
- }
- foreach ($mSelector as $iKey => &$mSel) {
- if (!($mSel instanceof Selector)) {
- if (!Selector::isValid($mSel)) {
- throw new UnexpectedTokenException("Selector did not match '" . Selector::SELECTOR_VALIDATION_RX . "'.", $mSel, "custom");
- }
- $mSel = new Selector($mSel);
- }
- }
- foreach ($this->aContents as $iKey => $mItem) {
- if (!($mItem instanceof DeclarationBlock)) {
- continue;
- }
- if ($mItem->getSelectors() == $mSelector) {
- unset($this->aContents[$iKey]);
- if (!$bRemoveAll) {
- return;
- }
- }
- }
- }
-
- public function __toString()
- {
- return $this->render(new \Sabberworm\CSS\OutputFormat());
- }
-
- /**
- * @return string
- */
- public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat)
- {
- $sResult = '';
- $bIsFirst = true;
- $oNextLevel = $oOutputFormat;
- if (!$this->isRootList()) {
- $oNextLevel = $oOutputFormat->nextLevel();
- }
- foreach ($this->aContents as $oContent) {
- $sRendered = $oOutputFormat->safely(function () use ($oNextLevel, $oContent) {
- return $oContent->render($oNextLevel);
- });
- if ($sRendered === null) {
- continue;
- }
- if ($bIsFirst) {
- $bIsFirst = false;
- $sResult .= $oNextLevel->spaceBeforeBlocks();
- } else {
- $sResult .= $oNextLevel->spaceBetweenBlocks();
- }
- $sResult .= $sRendered;
- }
-
- if (!$bIsFirst) {
- // Had some output
- $sResult .= $oOutputFormat->spaceAfterBlocks();
- }
-
- return $sResult;
- }
-
- /**
- * Return true if the list can not be further outdented. Only important when rendering.
- */
- abstract public function isRootList();
-
- /**
- * @return array
- */
- public function getContents()
- {
- return $this->aContents;
- }
-
- /**
- * @param array $aComments Array of comments.
- */
- public function addComments(array $aComments)
- {
- $this->aComments = array_merge($this->aComments, $aComments);
- }
-
- /**
- * @return array
- */
- public function getComments()
- {
- return $this->aComments;
- }
-
- /**
- * @param array $aComments Array containing Comment objects.
- */
- public function setComments(array $aComments)
- {
- $this->aComments = $aComments;
- }
-}
diff --git a/lib/Sabberworm/CSS/CSSList/Document.php b/lib/Sabberworm/CSS/CSSList/Document.php
deleted file mode 100644
index 0b07f1ff2..000000000
--- a/lib/Sabberworm/CSS/CSSList/Document.php
+++ /dev/null
@@ -1,135 +0,0 @@
-currentLine());
- CSSList::parseList($oParserState, $oDocument);
- return $oDocument;
- }
-
- /**
- * Gets all DeclarationBlock objects recursively.
- */
- public function getAllDeclarationBlocks()
- {
- $aResult = [];
- $this->allDeclarationBlocks($aResult);
- return $aResult;
- }
-
- /**
- * @deprecated use getAllDeclarationBlocks()
- */
- public function getAllSelectors()
- {
- return $this->getAllDeclarationBlocks();
- }
-
- /**
- * Returns all RuleSet objects found recursively in the tree.
- */
- public function getAllRuleSets()
- {
- $aResult = [];
- $this->allRuleSets($aResult);
- return $aResult;
- }
-
- /**
- * Returns all Value objects found recursively in the tree.
- * @param (object|string) $mElement the CSSList or RuleSet to start the search from (defaults to the whole document). If a string is given, it is used as rule name filter (@see{RuleSet->getRules()}).
- * @param (bool) $bSearchInFunctionArguments whether to also return Value objects used as Function arguments.
- */
- public function getAllValues($mElement = null, $bSearchInFunctionArguments = false)
- {
- $sSearchString = null;
- if ($mElement === null) {
- $mElement = $this;
- } elseif (is_string($mElement)) {
- $sSearchString = $mElement;
- $mElement = $this;
- }
- $aResult = [];
- $this->allValues($mElement, $aResult, $sSearchString, $bSearchInFunctionArguments);
- return $aResult;
- }
-
- /**
- * Returns all Selector objects found recursively in the tree.
- * Note that this does not yield the full DeclarationBlock that the selector belongs to (and, currently, there is no way to get to that).
- * @param $sSpecificitySearch An optional filter by specificity. May contain a comparison operator and a number or just a number (defaults to "==").
- * @example getSelectorsBySpecificity('>= 100')
- */
- public function getSelectorsBySpecificity($sSpecificitySearch = null)
- {
- $aResult = [];
- $this->allSelectors($aResult, $sSpecificitySearch);
- return $aResult;
- }
-
- /**
- * Expands all shorthand properties to their long value
- */
- public function expandShorthands()
- {
- foreach ($this->getAllDeclarationBlocks() as $oDeclaration) {
- $oDeclaration->expandShorthands();
- }
- }
-
- /**
- * Create shorthands properties whenever possible
- */
- public function createShorthands()
- {
- foreach ($this->getAllDeclarationBlocks() as $oDeclaration) {
- $oDeclaration->createShorthands();
- }
- }
-
-
- /**
- * Override render() to make format argument optional
- *
- * @param \Sabberworm\CSS\OutputFormat|null $oOutputFormat
- *
- * @return string
- */
- public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat = null)
- {
- if ($oOutputFormat === null) {
- $oOutputFormat = new \Sabberworm\CSS\OutputFormat();
- }
- return parent::render($oOutputFormat);
- }
-
- public function isRootList()
- {
- return true;
- }
-}
diff --git a/lib/Sabberworm/CSS/CSSList/KeyFrame.php b/lib/Sabberworm/CSS/CSSList/KeyFrame.php
deleted file mode 100644
index 0d7449332..000000000
--- a/lib/Sabberworm/CSS/CSSList/KeyFrame.php
+++ /dev/null
@@ -1,84 +0,0 @@
-vendorKeyFrame = null;
- $this->animationName = null;
- }
-
- public function setVendorKeyFrame($vendorKeyFrame)
- {
- $this->vendorKeyFrame = $vendorKeyFrame;
- }
-
- public function getVendorKeyFrame()
- {
- return $this->vendorKeyFrame;
- }
-
- public function setAnimationName($animationName)
- {
- $this->animationName = $animationName;
- }
-
- public function getAnimationName()
- {
- return $this->animationName;
- }
-
- public function __toString()
- {
- return $this->render(new \Sabberworm\CSS\OutputFormat());
- }
-
- /**
- * @param \Sabberworm\CSS\OutputFormat $oOutputFormat
- *
- * @return string
- */
- public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat)
- {
- $sResult = "@{$this->vendorKeyFrame} {$this->animationName}{$oOutputFormat->spaceBeforeOpeningBrace()}{";
- $sResult .= parent::render($oOutputFormat);
- $sResult .= '}';
- return $sResult;
- }
-
- public function isRootList()
- {
- return false;
- }
-
- /**
- * @return string|null
- */
- public function atRuleName()
- {
- return $this->vendorKeyFrame;
- }
-
- /**
- * @return string|null
- */
- public function atRuleArgs()
- {
- return $this->animationName;
- }
-}
diff --git a/lib/Sabberworm/CSS/Comment/Comment.php b/lib/Sabberworm/CSS/Comment/Comment.php
deleted file mode 100644
index 973b3d312..000000000
--- a/lib/Sabberworm/CSS/Comment/Comment.php
+++ /dev/null
@@ -1,57 +0,0 @@
-sComment = $sComment;
- $this->iLineNo = $iLineNo;
- }
-
- /**
- * @return string
- */
- public function getComment()
- {
- return $this->sComment;
- }
-
- /**
- * @return int
- */
- public function getLineNo()
- {
- return $this->iLineNo;
- }
-
- /**
- * @return string
- */
- public function setComment($sComment)
- {
- $this->sComment = $sComment;
- }
-
- /**
- * @return string
- */
- public function __toString()
- {
- return $this->render(new \Sabberworm\CSS\OutputFormat());
- }
-
- /**
- * @return string
- */
- public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat)
- {
- return '/*' . $this->sComment . '*/';
- }
-}
diff --git a/lib/Sabberworm/CSS/Comment/Commentable.php b/lib/Sabberworm/CSS/Comment/Commentable.php
deleted file mode 100644
index 8ecf8dcc2..000000000
--- a/lib/Sabberworm/CSS/Comment/Commentable.php
+++ /dev/null
@@ -1,22 +0,0 @@
-set('Space*Rules', "\n");`)
- */
- public $sSpaceAfterRuleName = ' ';
-
- public $sSpaceBeforeRules = '';
- public $sSpaceAfterRules = '';
- public $sSpaceBetweenRules = '';
-
- public $sSpaceBeforeBlocks = '';
- public $sSpaceAfterBlocks = '';
- public $sSpaceBetweenBlocks = "\n";
-
- // Content injected in and around @-rule blocks.
- public $sBeforeAtRuleBlock = '';
- public $sAfterAtRuleBlock = '';
-
- // This is what’s printed before and after the comma if a declaration block contains multiple selectors.
- public $sSpaceBeforeSelectorSeparator = '';
- public $sSpaceAfterSelectorSeparator = ' ';
- // This is what’s printed after the comma of value lists
- public $sSpaceBeforeListArgumentSeparator = '';
- public $sSpaceAfterListArgumentSeparator = '';
-
- public $sSpaceBeforeOpeningBrace = ' ';
-
- // Content injected in and around declaration blocks.
- public $sBeforeDeclarationBlock = '';
- public $sAfterDeclarationBlockSelectors = '';
- public $sAfterDeclarationBlock = '';
-
- /**
- * Indentation
- */
- // Indentation character(s) per level. Only applicable if newlines are used in any of the spacing settings.
- public $sIndentation = "\t";
-
- /**
- * Output exceptions.
- */
- public $bIgnoreExceptions = false;
-
-
- private $oFormatter = null;
- private $oNextLevelFormat = null;
- private $iIndentationLevel = 0;
-
- public function __construct()
- {
- }
-
- public function get($sName)
- {
- $aVarPrefixes = ['a', 's', 'm', 'b', 'f', 'o', 'c', 'i'];
- foreach ($aVarPrefixes as $sPrefix) {
- $sFieldName = $sPrefix . ucfirst($sName);
- if (isset($this->$sFieldName)) {
- return $this->$sFieldName;
- }
- }
- return null;
- }
-
- public function set($aNames, $mValue)
- {
- $aVarPrefixes = ['a', 's', 'm', 'b', 'f', 'o', 'c', 'i'];
- if (is_string($aNames) && strpos($aNames, '*') !== false) {
- $aNames = [str_replace('*', 'Before', $aNames), str_replace('*', 'Between', $aNames), str_replace('*', 'After', $aNames)];
- } elseif (!is_array($aNames)) {
- $aNames = [$aNames];
- }
- foreach ($aVarPrefixes as $sPrefix) {
- $bDidReplace = false;
- foreach ($aNames as $sName) {
- $sFieldName = $sPrefix . ucfirst($sName);
- if (isset($this->$sFieldName)) {
- $this->$sFieldName = $mValue;
- $bDidReplace = true;
- }
- }
- if ($bDidReplace) {
- return $this;
- }
- }
- // Break the chain so the user knows this option is invalid
- return false;
- }
-
- public function __call($sMethodName, $aArguments)
- {
- if (strpos($sMethodName, 'set') === 0) {
- return $this->set(substr($sMethodName, 3), $aArguments[0]);
- } elseif (strpos($sMethodName, 'get') === 0) {
- return $this->get(substr($sMethodName, 3));
- } elseif (method_exists(OutputFormatter::class, $sMethodName)) {
- return call_user_func_array([$this->getFormatter(), $sMethodName], $aArguments);
- } else {
- throw new \Exception('Unknown OutputFormat method called: ' . $sMethodName);
- }
- }
-
- public function indentWithTabs($iNumber = 1)
- {
- return $this->setIndentation(str_repeat("\t", $iNumber));
- }
-
- public function indentWithSpaces($iNumber = 2)
- {
- return $this->setIndentation(str_repeat(" ", $iNumber));
- }
-
- public function nextLevel()
- {
- if ($this->oNextLevelFormat === null) {
- $this->oNextLevelFormat = clone $this;
- $this->oNextLevelFormat->iIndentationLevel++;
- $this->oNextLevelFormat->oFormatter = null;
- }
- return $this->oNextLevelFormat;
- }
-
- public function beLenient()
- {
- $this->bIgnoreExceptions = true;
- }
-
- public function getFormatter()
- {
- if ($this->oFormatter === null) {
- $this->oFormatter = new OutputFormatter($this);
- }
- return $this->oFormatter;
- }
-
- public function level()
- {
- return $this->iIndentationLevel;
- }
-
- /**
- * Create format.
- *
- * @return OutputFormat Format.
- */
- public static function create()
- {
- return new OutputFormat();
- }
-
- /**
- * Create compact format.
- *
- * @return OutputFormat Format.
- */
- public static function createCompact()
- {
- $format = self::create();
- $format->set('Space*Rules', "")->set('Space*Blocks', "")->setSpaceAfterRuleName('')->setSpaceBeforeOpeningBrace('')->setSpaceAfterSelectorSeparator('');
- return $format;
- }
-
- /**
- * Create pretty format.
- *
- * @return OutputFormat Format.
- */
- public static function createPretty()
- {
- $format = self::create();
- $format->set('Space*Rules', "\n")->set('Space*Blocks', "\n")->setSpaceBetweenBlocks("\n\n")->set('SpaceAfterListArgumentSeparator', ['default' => '', ',' => ' ']);
- return $format;
- }
-}
-
-class OutputFormatter
-{
- private $oFormat;
-
- public function __construct(OutputFormat $oFormat)
- {
- $this->oFormat = $oFormat;
- }
-
- public function space($sName, $sType = null)
- {
- $sSpaceString = $this->oFormat->get("Space$sName");
- // If $sSpaceString is an array, we have multple values configured depending on the type of object the space applies to
- if (is_array($sSpaceString)) {
- if ($sType !== null && isset($sSpaceString[$sType])) {
- $sSpaceString = $sSpaceString[$sType];
- } else {
- $sSpaceString = reset($sSpaceString);
- }
- }
- return $this->prepareSpace($sSpaceString);
- }
-
- public function spaceAfterRuleName()
- {
- return $this->space('AfterRuleName');
- }
-
- public function spaceBeforeRules()
- {
- return $this->space('BeforeRules');
- }
-
- public function spaceAfterRules()
- {
- return $this->space('AfterRules');
- }
-
- public function spaceBetweenRules()
- {
- return $this->space('BetweenRules');
- }
-
- public function spaceBeforeBlocks()
- {
- return $this->space('BeforeBlocks');
- }
-
- public function spaceAfterBlocks()
- {
- return $this->space('AfterBlocks');
- }
-
- public function spaceBetweenBlocks()
- {
- return $this->space('BetweenBlocks');
- }
-
- public function spaceBeforeSelectorSeparator()
- {
- return $this->space('BeforeSelectorSeparator');
- }
-
- public function spaceAfterSelectorSeparator()
- {
- return $this->space('AfterSelectorSeparator');
- }
-
- public function spaceBeforeListArgumentSeparator($sSeparator)
- {
- return $this->space('BeforeListArgumentSeparator', $sSeparator);
- }
-
- public function spaceAfterListArgumentSeparator($sSeparator)
- {
- return $this->space('AfterListArgumentSeparator', $sSeparator);
- }
-
- public function spaceBeforeOpeningBrace()
- {
- return $this->space('BeforeOpeningBrace');
- }
-
- /**
- * Runs the given code, either swallowing or passing exceptions, depending on the bIgnoreExceptions setting.
- */
- public function safely($cCode)
- {
- if ($this->oFormat->get('IgnoreExceptions')) {
- // If output exceptions are ignored, run the code with exception guards
- try {
- return $cCode();
- } catch (OutputException $e) {
- return null;
- } //Do nothing
- } else {
- // Run the code as-is
- return $cCode();
- }
- }
-
- /**
- * Clone of the implode function but calls ->render with the current output format instead of __toString()
- */
- public function implode($sSeparator, $aValues, $bIncreaseLevel = false)
- {
- $sResult = '';
- $oFormat = $this->oFormat;
- if ($bIncreaseLevel) {
- $oFormat = $oFormat->nextLevel();
- }
- $bIsFirst = true;
- foreach ($aValues as $mValue) {
- if ($bIsFirst) {
- $bIsFirst = false;
- } else {
- $sResult .= $sSeparator;
- }
- if ($mValue instanceof \Sabberworm\CSS\Renderable) {
- $sResult .= $mValue->render($oFormat);
- } else {
- $sResult .= $mValue;
- }
- }
- return $sResult;
- }
-
- public function removeLastSemicolon($sString)
- {
- if ($this->oFormat->get('SemicolonAfterLastRule')) {
- return $sString;
- }
- $sString = explode(';', $sString);
- if (count($sString) < 2) {
- return $sString[0];
- }
- $sLast = array_pop($sString);
- $sNextToLast = array_pop($sString);
- array_push($sString, $sNextToLast . $sLast);
- return implode(';', $sString);
- }
-
- private function prepareSpace($sSpaceString)
- {
- return str_replace("\n", "\n" . $this->indent(), $sSpaceString);
- }
-
- private function indent()
- {
- return str_repeat($this->oFormat->sIndentation, $this->oFormat->level());
- }
-}
diff --git a/lib/Sabberworm/CSS/Parser.php b/lib/Sabberworm/CSS/Parser.php
deleted file mode 100644
index 6f017c89d..000000000
--- a/lib/Sabberworm/CSS/Parser.php
+++ /dev/null
@@ -1,53 +0,0 @@
-oParserState = new ParserState($sText, $oParserSettings, $iLineNo);
- }
-
- public function setCharset($sCharset)
- {
- $this->oParserState->setCharset($sCharset);
- }
-
- public function getCharset()
- {
- $this->oParserState->getCharset();
- }
-
- /**
- * @return Document
- *
- * @throws Parsing\SourceException
- */
- public function parse()
- {
- return Document::parse($this->oParserState);
- }
-}
diff --git a/lib/Sabberworm/CSS/Parsing/OutputException.php b/lib/Sabberworm/CSS/Parsing/OutputException.php
deleted file mode 100644
index a8b906fb4..000000000
--- a/lib/Sabberworm/CSS/Parsing/OutputException.php
+++ /dev/null
@@ -1,14 +0,0 @@
-oParserSettings = $oParserSettings;
- $this->sText = $sText;
- $this->iCurrentPosition = 0;
- $this->iLineNo = $iLineNo;
- $this->setCharset($this->oParserSettings->sDefaultCharset);
- }
-
- public function setCharset($sCharset)
- {
- $this->sCharset = $sCharset;
- $this->aText = $this->strsplit($this->sText);
- if (is_array($this->aText)) {
- $this->iLength = count($this->aText);
- }
- }
-
- public function getCharset()
- {
- $this->oParserHelper->getCharset();
- return $this->sCharset;
- }
-
- public function currentLine()
- {
- return $this->iLineNo;
- }
-
- public function currentColumn()
- {
- return $this->iCurrentPosition;
- }
-
- public function getSettings()
- {
- return $this->oParserSettings;
- }
-
- public function parseIdentifier($bIgnoreCase = true)
- {
- $sResult = $this->parseCharacter(true);
- if ($sResult === null) {
- throw new UnexpectedTokenException($sResult, $this->peek(5), 'identifier', $this->iLineNo);
- }
- $sCharacter = null;
- while (($sCharacter = $this->parseCharacter(true)) !== null) {
- if (preg_match('/[a-zA-Z0-9\x{00A0}-\x{FFFF}_-]/Sux', $sCharacter)) {
- $sResult .= $sCharacter;
- } else {
- $sResult .= '\\' . $sCharacter;
- }
- }
- if ($bIgnoreCase) {
- $sResult = $this->strtolower($sResult);
- }
- return $sResult;
- }
-
- public function parseCharacter($bIsForIdentifier)
- {
- if ($this->peek() === '\\') {
- if ($bIsForIdentifier && $this->oParserSettings->bLenientParsing && ($this->comes('\0') || $this->comes('\9'))) {
- // Non-strings can contain \0 or \9 which is an IE hack supported in lenient parsing.
- return null;
- }
- $this->consume('\\');
- if ($this->comes('\n') || $this->comes('\r')) {
- return '';
- }
- if (preg_match('/[0-9a-fA-F]/Su', $this->peek()) === 0) {
- return $this->consume(1);
- }
- $sUnicode = $this->consumeExpression('/^[0-9a-fA-F]{1,6}/u', 6);
- if ($this->strlen($sUnicode) < 6) {
- //Consume whitespace after incomplete unicode escape
- if (preg_match('/\\s/isSu', $this->peek())) {
- if ($this->comes('\r\n')) {
- $this->consume(2);
- } else {
- $this->consume(1);
- }
- }
- }
- $iUnicode = intval($sUnicode, 16);
- $sUtf32 = "";
- for ($i = 0; $i < 4; ++$i) {
- $sUtf32 .= chr($iUnicode & 0xff);
- $iUnicode = $iUnicode >> 8;
- }
- return iconv('utf-32le', $this->sCharset, $sUtf32);
- }
- if ($bIsForIdentifier) {
- $peek = ord($this->peek());
- // Ranges: a-z A-Z 0-9 - _
- if (
- ($peek >= 97 && $peek <= 122) ||
- ($peek >= 65 && $peek <= 90) ||
- ($peek >= 48 && $peek <= 57) ||
- ($peek === 45) ||
- ($peek === 95) ||
- ($peek > 0xa1)
- ) {
- return $this->consume(1);
- }
- } else {
- return $this->consume(1);
- }
- return null;
- }
-
- public function consumeWhiteSpace()
- {
- $comments = [];
- do {
- while (preg_match('/\\s/isSu', $this->peek()) === 1) {
- $this->consume(1);
- }
- if ($this->oParserSettings->bLenientParsing) {
- try {
- $oComment = $this->consumeComment();
- } catch (UnexpectedEOFException $e) {
- $this->iCurrentPosition = $this->iLength;
- return;
- }
- } else {
- $oComment = $this->consumeComment();
- }
- if ($oComment !== false) {
- $comments[] = $oComment;
- }
- } while ($oComment !== false);
- return $comments;
- }
-
- public function comes($sString, $bCaseInsensitive = false)
- {
- $sPeek = $this->peek(strlen($sString));
- return ($sPeek == '')
- ? false
- : $this->streql($sPeek, $sString, $bCaseInsensitive);
- }
-
- public function peek($iLength = 1, $iOffset = 0)
- {
- $iOffset += $this->iCurrentPosition;
- if ($iOffset >= $this->iLength) {
- return '';
- }
- return $this->substr($iOffset, $iLength);
- }
-
- public function consume($mValue = 1)
- {
- if (is_string($mValue)) {
- $iLineCount = substr_count($mValue, "\n");
- $iLength = $this->strlen($mValue);
- if (!$this->streql($this->substr($this->iCurrentPosition, $iLength), $mValue)) {
- throw new UnexpectedTokenException($mValue, $this->peek(max($iLength, 5)), $this->iLineNo);
- }
- $this->iLineNo += $iLineCount;
- $this->iCurrentPosition += $this->strlen($mValue);
- return $mValue;
- } else {
- if ($this->iCurrentPosition + $mValue > $this->iLength) {
- throw new UnexpectedEOFException($mValue, $this->peek(5), 'count', $this->iLineNo);
- }
- $sResult = $this->substr($this->iCurrentPosition, $mValue);
- $iLineCount = substr_count($sResult, "\n");
- $this->iLineNo += $iLineCount;
- $this->iCurrentPosition += $mValue;
- return $sResult;
- }
- }
-
- public function consumeExpression($mExpression, $iMaxLength = null)
- {
- $aMatches = null;
- $sInput = $iMaxLength !== null ? $this->peek($iMaxLength) : $this->inputLeft();
- if (preg_match($mExpression, $sInput, $aMatches, PREG_OFFSET_CAPTURE) === 1) {
- return $this->consume($aMatches[0][0]);
- }
- throw new UnexpectedTokenException($mExpression, $this->peek(5), 'expression', $this->iLineNo);
- }
-
- /**
- * @return false|Comment
- */
- public function consumeComment()
- {
- $mComment = false;
- if ($this->comes('/*')) {
- $iLineNo = $this->iLineNo;
- $this->consume(1);
- $mComment = '';
- while (($char = $this->consume(1)) !== '') {
- $mComment .= $char;
- if ($this->comes('*/')) {
- $this->consume(2);
- break;
- }
- }
- }
-
- if ($mComment !== false) {
- // We skip the * which was included in the comment.
- return new Comment(substr($mComment, 1), $iLineNo);
- }
-
- return $mComment;
- }
-
- public function isEnd()
- {
- return $this->iCurrentPosition >= $this->iLength;
- }
-
- public function consumeUntil($aEnd, $bIncludeEnd = false, $consumeEnd = false, array &$comments = [])
- {
- $aEnd = is_array($aEnd) ? $aEnd : [$aEnd];
- $out = '';
- $start = $this->iCurrentPosition;
-
- while (!$this->isEnd()) {
- $char = $this->consume(1);
- if (in_array($char, $aEnd)) {
- if ($bIncludeEnd) {
- $out .= $char;
- } elseif (!$consumeEnd) {
- $this->iCurrentPosition -= $this->strlen($char);
- }
- return $out;
- }
- $out .= $char;
- if ($comment = $this->consumeComment()) {
- $comments[] = $comment;
- }
- }
-
- if (in_array(self::EOF, $aEnd)) {
- return $out;
- }
-
- $this->iCurrentPosition = $start;
- throw new UnexpectedEOFException('One of ("' . implode('","', $aEnd) . '")', $this->peek(5), 'search', $this->iLineNo);
- }
-
- private function inputLeft()
- {
- return $this->substr($this->iCurrentPosition, -1);
- }
-
- public function streql($sString1, $sString2, $bCaseInsensitive = true)
- {
- if ($bCaseInsensitive) {
- return $this->strtolower($sString1) === $this->strtolower($sString2);
- } else {
- return $sString1 === $sString2;
- }
- }
-
- public function backtrack($iAmount)
- {
- $this->iCurrentPosition -= $iAmount;
- }
-
- public function strlen($sString)
- {
- if ($this->oParserSettings->bMultibyteSupport) {
- return mb_strlen($sString, $this->sCharset);
- } else {
- return strlen($sString);
- }
- }
-
- private function substr($iStart, $iLength)
- {
- if ($iLength < 0) {
- $iLength = $this->iLength - $iStart + $iLength;
- }
- if ($iStart + $iLength > $this->iLength) {
- $iLength = $this->iLength - $iStart;
- }
- $sResult = '';
- while ($iLength > 0) {
- $sResult .= $this->aText[$iStart];
- $iStart++;
- $iLength--;
- }
- return $sResult;
- }
-
- private function strtolower($sString)
- {
- if ($this->oParserSettings->bMultibyteSupport) {
- return mb_strtolower($sString, $this->sCharset);
- } else {
- return strtolower($sString);
- }
- }
-
- private function strsplit($sString)
- {
- if ($this->oParserSettings->bMultibyteSupport) {
- if ($this->streql($this->sCharset, 'utf-8')) {
- return preg_split('//u', $sString, null, PREG_SPLIT_NO_EMPTY);
- } else {
- $iLength = mb_strlen($sString, $this->sCharset);
- $aResult = [];
- for ($i = 0; $i < $iLength; ++$i) {
- $aResult[] = mb_substr($sString, $i, 1, $this->sCharset);
- }
- return $aResult;
- }
- } else {
- if ($sString === '') {
- return [];
- } else {
- return str_split($sString);
- }
- }
- }
-
- private function strpos($sString, $sNeedle, $iOffset)
- {
- if ($this->oParserSettings->bMultibyteSupport) {
- return mb_strpos($sString, $sNeedle, $iOffset, $this->sCharset);
- } else {
- return strpos($sString, $sNeedle, $iOffset);
- }
- }
-}
diff --git a/lib/Sabberworm/CSS/Parsing/SourceException.php b/lib/Sabberworm/CSS/Parsing/SourceException.php
deleted file mode 100644
index 4021a6029..000000000
--- a/lib/Sabberworm/CSS/Parsing/SourceException.php
+++ /dev/null
@@ -1,21 +0,0 @@
-iLineNo = $iLineNo;
- if (!empty($iLineNo)) {
- $sMessage .= " [line no: $iLineNo]";
- }
- parent::__construct($sMessage);
- }
-
- public function getLineNo()
- {
- return $this->iLineNo;
- }
-}
diff --git a/lib/Sabberworm/CSS/Parsing/UnexpectedEOFException.php b/lib/Sabberworm/CSS/Parsing/UnexpectedEOFException.php
deleted file mode 100644
index c08e90b52..000000000
--- a/lib/Sabberworm/CSS/Parsing/UnexpectedEOFException.php
+++ /dev/null
@@ -1,11 +0,0 @@
-sExpected = $sExpected;
- $this->sFound = $sFound;
- $this->sMatchType = $sMatchType;
- $sMessage = "Token “{$sExpected}” ({$sMatchType}) not found. Got “{$sFound}”.";
- if ($this->sMatchType === 'search') {
- $sMessage = "Search for “{$sExpected}” returned no results. Context: “{$sFound}”.";
- } elseif ($this->sMatchType === 'count') {
- $sMessage = "Next token was expected to have {$sExpected} chars. Context: “{$sFound}”.";
- } elseif ($this->sMatchType === 'identifier') {
- $sMessage = "Identifier expected. Got “{$sFound}”";
- } elseif ($this->sMatchType === 'custom') {
- $sMessage = trim("$sExpected $sFound");
- }
-
- parent::__construct($sMessage, $iLineNo);
- }
-}
diff --git a/lib/Sabberworm/CSS/Property/AtRule.php b/lib/Sabberworm/CSS/Property/AtRule.php
deleted file mode 100644
index 291c388bf..000000000
--- a/lib/Sabberworm/CSS/Property/AtRule.php
+++ /dev/null
@@ -1,24 +0,0 @@
-mUrl = $mUrl;
- $this->sPrefix = $sPrefix;
- $this->iLineNo = $iLineNo;
- $this->aComments = [];
- }
-
- /**
- * @return int
- */
- public function getLineNo()
- {
- return $this->iLineNo;
- }
-
- public function __toString()
- {
- return $this->render(new \Sabberworm\CSS\OutputFormat());
- }
-
- /**
- * @param \Sabberworm\CSS\OutputFormat $oOutputFormat
- *
- * @return string
- */
- public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat)
- {
- return '@namespace ' . ($this->sPrefix === null ? '' : $this->sPrefix . ' ') . $this->mUrl->render($oOutputFormat) . ';';
- }
-
- public function getUrl()
- {
- return $this->mUrl;
- }
-
- public function getPrefix()
- {
- return $this->sPrefix;
- }
-
- public function setUrl($mUrl)
- {
- $this->mUrl = $mUrl;
- }
-
- public function setPrefix($sPrefix)
- {
- $this->sPrefix = $sPrefix;
- }
-
- /**
- * @return string
- */
- public function atRuleName()
- {
- return 'namespace';
- }
-
- /**
- * @return array
- */
- public function atRuleArgs()
- {
- $aResult = [$this->mUrl];
- if ($this->sPrefix) {
- array_unshift($aResult, $this->sPrefix);
- }
- return $aResult;
- }
-
- public function addComments(array $aComments)
- {
- $this->aComments = array_merge($this->aComments, $aComments);
- }
-
- public function getComments()
- {
- return $this->aComments;
- }
-
- public function setComments(array $aComments)
- {
- $this->aComments = $aComments;
- }
-}
diff --git a/lib/Sabberworm/CSS/Property/Charset.php b/lib/Sabberworm/CSS/Property/Charset.php
deleted file mode 100644
index 37a810771..000000000
--- a/lib/Sabberworm/CSS/Property/Charset.php
+++ /dev/null
@@ -1,103 +0,0 @@
-sCharset = $sCharset;
- $this->iLineNo = $iLineNo;
- $this->aComments = [];
- }
-
- /**
- * @return int
- */
- public function getLineNo()
- {
- return $this->iLineNo;
- }
-
- public function setCharset($sCharset)
- {
- $this->sCharset = $sCharset;
- }
-
- public function getCharset()
- {
- return $this->sCharset;
- }
-
- public function __toString()
- {
- return $this->render(new \Sabberworm\CSS\OutputFormat());
- }
-
- /**
- * @param \Sabberworm\CSS\OutputFormat $oOutputFormat
- *
- * @return string
- */
- public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat)
- {
- return "@charset {$this->sCharset->render($oOutputFormat)};";
- }
-
- /**
- * @return string
- */
- public function atRuleName()
- {
- return 'charset';
- }
-
- /**
- * @return string
- */
- public function atRuleArgs()
- {
- return $this->sCharset;
- }
-
- public function addComments(array $aComments)
- {
- $this->aComments = array_merge($this->aComments, $aComments);
- }
-
- public function getComments()
- {
- return $this->aComments;
- }
-
- public function setComments(array $aComments)
- {
- $this->aComments = $aComments;
- }
-}
diff --git a/lib/Sabberworm/CSS/Property/Import.php b/lib/Sabberworm/CSS/Property/Import.php
deleted file mode 100644
index 4cd5a10b7..000000000
--- a/lib/Sabberworm/CSS/Property/Import.php
+++ /dev/null
@@ -1,112 +0,0 @@
-oLocation = $oLocation;
- $this->sMediaQuery = $sMediaQuery;
- $this->iLineNo = $iLineNo;
- $this->aComments = [];
- }
-
- /**
- * @return int
- */
- public function getLineNo()
- {
- return $this->iLineNo;
- }
-
- public function setLocation($oLocation)
- {
- $this->oLocation = $oLocation;
- }
-
- public function getLocation()
- {
- return $this->oLocation;
- }
-
- public function __toString()
- {
- return $this->render(new \Sabberworm\CSS\OutputFormat());
- }
-
- /**
- * @param \Sabberworm\CSS\OutputFormat $oOutputFormat
- *
- * @return string
- */
- public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat)
- {
- return "@import " . $this->oLocation->render($oOutputFormat) . ($this->sMediaQuery === null ? '' : ' ' . $this->sMediaQuery) . ';';
- }
-
- /**
- * @return string
- */
- public function atRuleName()
- {
- return 'import';
- }
-
- /**
- * @return array
- */
- public function atRuleArgs()
- {
- $aResult = [$this->oLocation];
- if ($this->sMediaQuery) {
- array_push($aResult, $this->sMediaQuery);
- }
- return $aResult;
- }
-
- public function addComments(array $aComments)
- {
- $this->aComments = array_merge($this->aComments, $aComments);
- }
-
- public function getComments()
- {
- return $this->aComments;
- }
-
- public function setComments(array $aComments)
- {
- $this->aComments = $aComments;
- }
-}
diff --git a/lib/Sabberworm/CSS/Property/KeyframeSelector.php b/lib/Sabberworm/CSS/Property/KeyframeSelector.php
deleted file mode 100644
index e8f4ee854..000000000
--- a/lib/Sabberworm/CSS/Property/KeyframeSelector.php
+++ /dev/null
@@ -1,23 +0,0 @@
-]* # any sequence of valid unescaped characters
- (?:\\\\.)? # a single escaped character
- (?:([\'"]).*?(?\~]+)[\w]+ # elements
- |
- \:{1,2}( # pseudo-elements
- after|before|first-letter|first-line|selection
- ))
- /ix';
-
- const SELECTOR_VALIDATION_RX = '/
- ^(
- (?:
- [a-zA-Z0-9\x{00A0}-\x{FFFF}_^$|*="\'~\[\]()\-\s\.:#+>]* # any sequence of valid unescaped characters
- (?:\\\\.)? # a single escaped character
- (?:([\'"]).*?(?setSelector($sSelector);
- if ($bCalculateSpecificity) {
- $this->getSpecificity();
- }
- }
-
- public function getSelector()
- {
- return $this->sSelector;
- }
-
- public function setSelector($sSelector)
- {
- $this->sSelector = trim($sSelector);
- $this->iSpecificity = null;
- }
-
- public function __toString()
- {
- return $this->getSelector();
- }
-
- public function getSpecificity()
- {
- if ($this->iSpecificity === null) {
- $a = 0;
- /// @todo should exclude \# as well as "#"
- $aMatches = null;
- $b = substr_count($this->sSelector, '#');
- $c = preg_match_all(self::NON_ID_ATTRIBUTES_AND_PSEUDO_CLASSES_RX, $this->sSelector, $aMatches);
- $d = preg_match_all(self::ELEMENTS_AND_PSEUDO_ELEMENTS_RX, $this->sSelector, $aMatches);
- $this->iSpecificity = ($a * 1000) + ($b * 100) + ($c * 10) + $d;
- }
- return $this->iSpecificity;
- }
-}
diff --git a/lib/Sabberworm/CSS/Renderable.php b/lib/Sabberworm/CSS/Renderable.php
deleted file mode 100644
index b2cc3467c..000000000
--- a/lib/Sabberworm/CSS/Renderable.php
+++ /dev/null
@@ -1,17 +0,0 @@
-sRule = $sRule;
- $this->mValue = null;
- $this->bIsImportant = false;
- $this->aIeHack = [];
- $this->iLineNo = $iLineNo;
- $this->iColNo = $iColNo;
- $this->aComments = [];
- }
-
- public static function parse(ParserState $oParserState)
- {
- $aComments = $oParserState->consumeWhiteSpace();
- $oRule = new Rule($oParserState->parseIdentifier(!$oParserState->comes("--")), $oParserState->currentLine(), $oParserState->currentColumn());
- $oRule->setComments($aComments);
- $oRule->addComments($oParserState->consumeWhiteSpace());
- $oParserState->consume(':');
- $oValue = Value::parseValue($oParserState, self::listDelimiterForRule($oRule->getRule()));
- $oRule->setValue($oValue);
- if ($oParserState->getSettings()->bLenientParsing) {
- while ($oParserState->comes('\\')) {
- $oParserState->consume('\\');
- $oRule->addIeHack($oParserState->consume());
- $oParserState->consumeWhiteSpace();
- }
- }
- $oParserState->consumeWhiteSpace();
- if ($oParserState->comes('!')) {
- $oParserState->consume('!');
- $oParserState->consumeWhiteSpace();
- $oParserState->consume('important');
- $oRule->setIsImportant(true);
- }
- $oParserState->consumeWhiteSpace();
- while ($oParserState->comes(';')) {
- $oParserState->consume(';');
- }
- $oParserState->consumeWhiteSpace();
-
- return $oRule;
- }
-
- private static function listDelimiterForRule($sRule)
- {
- if (preg_match('/^font($|-)/', $sRule)) {
- return [',', '/', ' '];
- }
- return [',', ' ', '/'];
- }
-
- /**
- * @return int
- */
- public function getLineNo()
- {
- return $this->iLineNo;
- }
-
- /**
- * @return int
- */
- public function getColNo()
- {
- return $this->iColNo;
- }
-
- public function setPosition($iLine, $iColumn)
- {
- $this->iColNo = $iColumn;
- $this->iLineNo = $iLine;
- }
-
- public function setRule($sRule)
- {
- $this->sRule = $sRule;
- }
-
- public function getRule()
- {
- return $this->sRule;
- }
-
- public function getValue()
- {
- return $this->mValue;
- }
-
- public function setValue($mValue)
- {
- $this->mValue = $mValue;
- }
-
- /**
- * @deprecated Old-Style 2-dimensional array given. Retained for (some) backwards-compatibility. Use setValue() instead and wrapp the value inside a RuleValueList if necessary.
- */
- public function setValues($aSpaceSeparatedValues)
- {
- $oSpaceSeparatedList = null;
- if (count($aSpaceSeparatedValues) > 1) {
- $oSpaceSeparatedList = new RuleValueList(' ', $this->iLineNo);
- }
- foreach ($aSpaceSeparatedValues as $aCommaSeparatedValues) {
- $oCommaSeparatedList = null;
- if (count($aCommaSeparatedValues) > 1) {
- $oCommaSeparatedList = new RuleValueList(',', $this->iLineNo);
- }
- foreach ($aCommaSeparatedValues as $mValue) {
- if (!$oSpaceSeparatedList && !$oCommaSeparatedList) {
- $this->mValue = $mValue;
- return $mValue;
- }
- if ($oCommaSeparatedList) {
- $oCommaSeparatedList->addListComponent($mValue);
- } else {
- $oSpaceSeparatedList->addListComponent($mValue);
- }
- }
- if (!$oSpaceSeparatedList) {
- $this->mValue = $oCommaSeparatedList;
- return $oCommaSeparatedList;
- } else {
- $oSpaceSeparatedList->addListComponent($oCommaSeparatedList);
- }
- }
- $this->mValue = $oSpaceSeparatedList;
- return $oSpaceSeparatedList;
- }
-
- /**
- * @deprecated Old-Style 2-dimensional array returned. Retained for (some) backwards-compatibility. Use getValue() instead and check for the existance of a (nested set of) ValueList object(s).
- */
- public function getValues()
- {
- if (!$this->mValue instanceof RuleValueList) {
- return [[$this->mValue]];
- }
- if ($this->mValue->getListSeparator() === ',') {
- return [$this->mValue->getListComponents()];
- }
- $aResult = [];
- foreach ($this->mValue->getListComponents() as $mValue) {
- if (!$mValue instanceof RuleValueList || $mValue->getListSeparator() !== ',') {
- $aResult[] = [$mValue];
- continue;
- }
- if ($this->mValue->getListSeparator() === ' ' || count($aResult) === 0) {
- $aResult[] = [];
- }
- foreach ($mValue->getListComponents() as $mValue) {
- $aResult[count($aResult) - 1][] = $mValue;
- }
- }
- return $aResult;
- }
-
- /**
- * Adds a value to the existing value. Value will be appended if a RuleValueList exists of the given type. Otherwise, the existing value will be wrapped by one.
- */
- public function addValue($mValue, $sType = ' ')
- {
- if (!is_array($mValue)) {
- $mValue = [$mValue];
- }
- if (!$this->mValue instanceof RuleValueList || $this->mValue->getListSeparator() !== $sType) {
- $mCurrentValue = $this->mValue;
- $this->mValue = new RuleValueList($sType, $this->iLineNo);
- if ($mCurrentValue) {
- $this->mValue->addListComponent($mCurrentValue);
- }
- }
- foreach ($mValue as $mValueItem) {
- $this->mValue->addListComponent($mValueItem);
- }
- }
-
- public function addIeHack($iModifier)
- {
- $this->aIeHack[] = $iModifier;
- }
-
- public function setIeHack(array $aModifiers)
- {
- $this->aIeHack = $aModifiers;
- }
-
- public function getIeHack()
- {
- return $this->aIeHack;
- }
-
- public function setIsImportant($bIsImportant)
- {
- $this->bIsImportant = $bIsImportant;
- }
-
- public function getIsImportant()
- {
- return $this->bIsImportant;
- }
-
- public function __toString()
- {
- return $this->render(new \Sabberworm\CSS\OutputFormat());
- }
-
- /**
- * @param \Sabberworm\CSS\OutputFormat $oOutputFormat
- *
- * @return string
- */
- public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat)
- {
- $sResult = "{$this->sRule}:{$oOutputFormat->spaceAfterRuleName()}";
- if ($this->mValue instanceof Value) { //Can also be a ValueList
- $sResult .= $this->mValue->render($oOutputFormat);
- } else {
- $sResult .= $this->mValue;
- }
- if (!empty($this->aIeHack)) {
- $sResult .= ' \\' . implode('\\', $this->aIeHack);
- }
- if ($this->bIsImportant) {
- $sResult .= ' !important';
- }
- $sResult .= ';';
- return $sResult;
- }
-
- /**
- * @param array $aComments Array of comments.
- */
- public function addComments(array $aComments)
- {
- $this->aComments = array_merge($this->aComments, $aComments);
- }
-
- /**
- * @return array
- */
- public function getComments()
- {
- return $this->aComments;
- }
-
- /**
- * @param array $aComments Array containing Comment objects.
- */
- public function setComments(array $aComments)
- {
- $this->aComments = $aComments;
- }
-}
diff --git a/lib/Sabberworm/CSS/RuleSet/AtRuleSet.php b/lib/Sabberworm/CSS/RuleSet/AtRuleSet.php
deleted file mode 100644
index 20c906b55..000000000
--- a/lib/Sabberworm/CSS/RuleSet/AtRuleSet.php
+++ /dev/null
@@ -1,71 +0,0 @@
-sType = $sType;
- $this->sArgs = $sArgs;
- }
-
- /**
- * @return string
- */
- public function atRuleName()
- {
- return $this->sType;
- }
-
- /**
- * @return string
- */
- public function atRuleArgs()
- {
- return $this->sArgs;
- }
-
- public function __toString()
- {
- return $this->render(new \Sabberworm\CSS\OutputFormat());
- }
-
- /**
- * @param \Sabberworm\CSS\OutputFormat $oOutputFormat
- *
- * @return string
- */
- public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat)
- {
- $sArgs = $this->sArgs;
- if ($sArgs) {
- $sArgs = ' ' . $sArgs;
- }
- $sResult = "@{$this->sType}$sArgs{$oOutputFormat->spaceBeforeOpeningBrace()}{";
- $sResult .= parent::render($oOutputFormat);
- $sResult .= '}';
- return $sResult;
- }
-}
diff --git a/lib/Sabberworm/CSS/RuleSet/DeclarationBlock.php b/lib/Sabberworm/CSS/RuleSet/DeclarationBlock.php
deleted file mode 100644
index 9d395c9ee..000000000
--- a/lib/Sabberworm/CSS/RuleSet/DeclarationBlock.php
+++ /dev/null
@@ -1,707 +0,0 @@
-
- */
- private $aSelectors;
-
- public function __construct($iLineNo = 0)
- {
- parent::__construct($iLineNo);
- $this->aSelectors = [];
- }
-
- public static function parse(ParserState $oParserState, $oList = null)
- {
- $aComments = [];
- $oResult = new DeclarationBlock($oParserState->currentLine());
- try {
- $aSelectorParts = [];
- $sStringWrapperChar = false;
- do {
- $aSelectorParts[] = $oParserState->consume(1) . $oParserState->consumeUntil(['{', '}', '\'', '"'], false, false, $aComments);
- if (in_array($oParserState->peek(), ['\'', '"']) && substr(end($aSelectorParts), -1) != "\\") {
- if ($sStringWrapperChar === false) {
- $sStringWrapperChar = $oParserState->peek();
- } elseif ($sStringWrapperChar == $oParserState->peek()) {
- $sStringWrapperChar = false;
- }
- }
- } while (!in_array($oParserState->peek(), ['{', '}']) || $sStringWrapperChar !== false);
- $oResult->setSelector(implode('', $aSelectorParts), $oList);
- if ($oParserState->comes('{')) {
- $oParserState->consume(1);
- }
- } catch (UnexpectedTokenException $e) {
- if ($oParserState->getSettings()->bLenientParsing) {
- if (!$oParserState->comes('}')) {
- $oParserState->consumeUntil('}', false, true);
- }
- return false;
- } else {
- throw $e;
- }
- }
- $oResult->setComments($aComments);
- RuleSet::parseRuleSet($oParserState, $oResult);
- return $oResult;
- }
-
-
- public function setSelectors($mSelector, $oList = null)
- {
- if (is_array($mSelector)) {
- $this->aSelectors = $mSelector;
- } else {
- $this->aSelectors = explode(',', $mSelector);
- }
- foreach ($this->aSelectors as $iKey => $mSelector) {
- if (!($mSelector instanceof Selector)) {
- if ($oList === null || !($oList instanceof KeyFrame)) {
- if (!Selector::isValid($mSelector)) {
- throw new UnexpectedTokenException("Selector did not match '" . Selector::SELECTOR_VALIDATION_RX . "'.", $mSelector, "custom");
- }
- $this->aSelectors[$iKey] = new Selector($mSelector);
- } else {
- if (!KeyframeSelector::isValid($mSelector)) {
- throw new UnexpectedTokenException("Selector did not match '" . KeyframeSelector::SELECTOR_VALIDATION_RX . "'.", $mSelector, "custom");
- }
- $this->aSelectors[$iKey] = new KeyframeSelector($mSelector);
- }
- }
- }
- }
-
- // remove one of the selector of the block
- public function removeSelector($mSelector)
- {
- if ($mSelector instanceof Selector) {
- $mSelector = $mSelector->getSelector();
- }
- foreach ($this->aSelectors as $iKey => $oSelector) {
- if ($oSelector->getSelector() === $mSelector) {
- unset($this->aSelectors[$iKey]);
- return true;
- }
- }
- return false;
- }
-
- /**
- * @deprecated use getSelectors()
- */
- public function getSelector()
- {
- return $this->getSelectors();
- }
-
- /**
- * @deprecated use setSelectors()
- */
- public function setSelector($mSelector, $oList = null)
- {
- $this->setSelectors($mSelector, $oList);
- }
-
- /**
- * Get selectors.
- *
- * @return array Selectors.
- */
- public function getSelectors()
- {
- return $this->aSelectors;
- }
-
- /**
- * Split shorthand declarations (e.g. +margin+ or +font+) into their constituent parts.
- * */
- public function expandShorthands()
- {
- // border must be expanded before dimensions
- $this->expandBorderShorthand();
- $this->expandDimensionsShorthand();
- $this->expandFontShorthand();
- $this->expandBackgroundShorthand();
- $this->expandListStyleShorthand();
- }
-
- /**
- * Create shorthand declarations (e.g. +margin+ or +font+) whenever possible.
- * */
- public function createShorthands()
- {
- $this->createBackgroundShorthand();
- $this->createDimensionsShorthand();
- // border must be shortened after dimensions
- $this->createBorderShorthand();
- $this->createFontShorthand();
- $this->createListStyleShorthand();
- }
-
- /**
- * Split shorthand border declarations (e.g. border: 1px red;)
- * Additional splitting happens in expandDimensionsShorthand
- * Multiple borders are not yet supported as of 3
- * */
- public function expandBorderShorthand()
- {
- $aBorderRules = [
- 'border', 'border-left', 'border-right', 'border-top', 'border-bottom'
- ];
- $aBorderSizes = [
- 'thin', 'medium', 'thick'
- ];
- $aRules = $this->getRulesAssoc();
- foreach ($aBorderRules as $sBorderRule) {
- if (!isset($aRules[$sBorderRule])) {
- continue;
- }
- $oRule = $aRules[$sBorderRule];
- $mRuleValue = $oRule->getValue();
- $aValues = [];
- if (!$mRuleValue instanceof RuleValueList) {
- $aValues[] = $mRuleValue;
- } else {
- $aValues = $mRuleValue->getListComponents();
- }
- foreach ($aValues as $mValue) {
- if ($mValue instanceof Value) {
- $mNewValue = clone $mValue;
- } else {
- $mNewValue = $mValue;
- }
- if ($mValue instanceof Size) {
- $sNewRuleName = $sBorderRule . "-width";
- } elseif ($mValue instanceof Color) {
- $sNewRuleName = $sBorderRule . "-color";
- } else {
- if (in_array($mValue, $aBorderSizes)) {
- $sNewRuleName = $sBorderRule . "-width";
- } else /* if(in_array($mValue, $aBorderStyles)) */ {
- $sNewRuleName = $sBorderRule . "-style";
- }
- }
- $oNewRule = new Rule($sNewRuleName, $oRule->getLineNo(), $oRule->getColNo());
- $oNewRule->setIsImportant($oRule->getIsImportant());
- $oNewRule->addValue([$mNewValue]);
- $this->addRule($oNewRule);
- }
- $this->removeRule($sBorderRule);
- }
- }
-
- /**
- * Split shorthand dimensional declarations (e.g. margin: 0px auto;)
- * into their constituent parts.
- * Handles margin, padding, border-color, border-style and border-width.
- * */
- public function expandDimensionsShorthand()
- {
- $aExpansions = [
- 'margin' => 'margin-%s',
- 'padding' => 'padding-%s',
- 'border-color' => 'border-%s-color',
- 'border-style' => 'border-%s-style',
- 'border-width' => 'border-%s-width'
- ];
- $aRules = $this->getRulesAssoc();
- foreach ($aExpansions as $sProperty => $sExpanded) {
- if (!isset($aRules[$sProperty])) {
- continue;
- }
- $oRule = $aRules[$sProperty];
- $mRuleValue = $oRule->getValue();
- $aValues = [];
- if (!$mRuleValue instanceof RuleValueList) {
- $aValues[] = $mRuleValue;
- } else {
- $aValues = $mRuleValue->getListComponents();
- }
- $top = $right = $bottom = $left = null;
- switch (count($aValues)) {
- case 1:
- $top = $right = $bottom = $left = $aValues[0];
- break;
- case 2:
- $top = $bottom = $aValues[0];
- $left = $right = $aValues[1];
- break;
- case 3:
- $top = $aValues[0];
- $left = $right = $aValues[1];
- $bottom = $aValues[2];
- break;
- case 4:
- $top = $aValues[0];
- $right = $aValues[1];
- $bottom = $aValues[2];
- $left = $aValues[3];
- break;
- }
- foreach (['top', 'right', 'bottom', 'left'] as $sPosition) {
- $oNewRule = new Rule(sprintf($sExpanded, $sPosition), $oRule->getLineNo(), $oRule->getColNo());
- $oNewRule->setIsImportant($oRule->getIsImportant());
- $oNewRule->addValue(${$sPosition});
- $this->addRule($oNewRule);
- }
- $this->removeRule($sProperty);
- }
- }
-
- /**
- * Convert shorthand font declarations
- * (e.g. font: 300 italic 11px/14px verdana, helvetica, sans-serif;)
- * into their constituent parts.
- * */
- public function expandFontShorthand()
- {
- $aRules = $this->getRulesAssoc();
- if (!isset($aRules['font'])) {
- return;
- }
- $oRule = $aRules['font'];
- // reset properties to 'normal' per http://www.w3.org/TR/21/fonts.html#font-shorthand
- $aFontProperties = [
- 'font-style' => 'normal',
- 'font-variant' => 'normal',
- 'font-weight' => 'normal',
- 'font-size' => 'normal',
- 'line-height' => 'normal'
- ];
- $mRuleValue = $oRule->getValue();
- $aValues = [];
- if (!$mRuleValue instanceof RuleValueList) {
- $aValues[] = $mRuleValue;
- } else {
- $aValues = $mRuleValue->getListComponents();
- }
- foreach ($aValues as $mValue) {
- if (!$mValue instanceof Value) {
- $mValue = mb_strtolower($mValue);
- }
- if (in_array($mValue, ['normal', 'inherit'])) {
- foreach (['font-style', 'font-weight', 'font-variant'] as $sProperty) {
- if (!isset($aFontProperties[$sProperty])) {
- $aFontProperties[$sProperty] = $mValue;
- }
- }
- } elseif (in_array($mValue, ['italic', 'oblique'])) {
- $aFontProperties['font-style'] = $mValue;
- } elseif ($mValue == 'small-caps') {
- $aFontProperties['font-variant'] = $mValue;
- } elseif (
- in_array($mValue, ['bold', 'bolder', 'lighter'])
- || ($mValue instanceof Size
- && in_array($mValue->getSize(), range(100, 900, 100)))
- ) {
- $aFontProperties['font-weight'] = $mValue;
- } elseif ($mValue instanceof RuleValueList && $mValue->getListSeparator() == '/') {
- list($oSize, $oHeight) = $mValue->getListComponents();
- $aFontProperties['font-size'] = $oSize;
- $aFontProperties['line-height'] = $oHeight;
- } elseif ($mValue instanceof Size && $mValue->getUnit() !== null) {
- $aFontProperties['font-size'] = $mValue;
- } else {
- $aFontProperties['font-family'] = $mValue;
- }
- }
- foreach ($aFontProperties as $sProperty => $mValue) {
- $oNewRule = new Rule($sProperty, $oRule->getLineNo(), $oRule->getColNo());
- $oNewRule->addValue($mValue);
- $oNewRule->setIsImportant($oRule->getIsImportant());
- $this->addRule($oNewRule);
- }
- $this->removeRule('font');
- }
-
- /*
- * Convert shorthand background declarations
- * (e.g. background: url("chess.png") gray 50% repeat fixed;)
- * into their constituent parts.
- * @see http://www.w3.org/TR/21/colors.html#propdef-background
- * */
-
- public function expandBackgroundShorthand()
- {
- $aRules = $this->getRulesAssoc();
- if (!isset($aRules['background'])) {
- return;
- }
- $oRule = $aRules['background'];
- $aBgProperties = [
- 'background-color' => ['transparent'], 'background-image' => ['none'],
- 'background-repeat' => ['repeat'], 'background-attachment' => ['scroll'],
- 'background-position' => [new Size(0, '%', null, false, $this->iLineNo), new Size(0, '%', null, false, $this->iLineNo)]
- ];
- $mRuleValue = $oRule->getValue();
- $aValues = [];
- if (!$mRuleValue instanceof RuleValueList) {
- $aValues[] = $mRuleValue;
- } else {
- $aValues = $mRuleValue->getListComponents();
- }
- if (count($aValues) == 1 && $aValues[0] == 'inherit') {
- foreach ($aBgProperties as $sProperty => $mValue) {
- $oNewRule = new Rule($sProperty, $oRule->getLineNo(), $oRule->getColNo());
- $oNewRule->addValue('inherit');
- $oNewRule->setIsImportant($oRule->getIsImportant());
- $this->addRule($oNewRule);
- }
- $this->removeRule('background');
- return;
- }
- $iNumBgPos = 0;
- foreach ($aValues as $mValue) {
- if (!$mValue instanceof Value) {
- $mValue = mb_strtolower($mValue);
- }
- if ($mValue instanceof URL) {
- $aBgProperties['background-image'] = $mValue;
- } elseif ($mValue instanceof Color) {
- $aBgProperties['background-color'] = $mValue;
- } elseif (in_array($mValue, ['scroll', 'fixed'])) {
- $aBgProperties['background-attachment'] = $mValue;
- } elseif (in_array($mValue, ['repeat', 'no-repeat', 'repeat-x', 'repeat-y'])) {
- $aBgProperties['background-repeat'] = $mValue;
- } elseif (
- in_array($mValue, ['left', 'center', 'right', 'top', 'bottom'])
- || $mValue instanceof Size
- ) {
- if ($iNumBgPos == 0) {
- $aBgProperties['background-position'][0] = $mValue;
- $aBgProperties['background-position'][1] = 'center';
- } else {
- $aBgProperties['background-position'][$iNumBgPos] = $mValue;
- }
- $iNumBgPos++;
- }
- }
- foreach ($aBgProperties as $sProperty => $mValue) {
- $oNewRule = new Rule($sProperty, $oRule->getLineNo(), $oRule->getColNo());
- $oNewRule->setIsImportant($oRule->getIsImportant());
- $oNewRule->addValue($mValue);
- $this->addRule($oNewRule);
- }
- $this->removeRule('background');
- }
-
- public function expandListStyleShorthand()
- {
- $aListProperties = [
- 'list-style-type' => 'disc',
- 'list-style-position' => 'outside',
- 'list-style-image' => 'none'
- ];
- $aListStyleTypes = [
- 'none', 'disc', 'circle', 'square', 'decimal-leading-zero', 'decimal',
- 'lower-roman', 'upper-roman', 'lower-greek', 'lower-alpha', 'lower-latin',
- 'upper-alpha', 'upper-latin', 'hebrew', 'armenian', 'georgian', 'cjk-ideographic',
- 'hiragana', 'hira-gana-iroha', 'katakana-iroha', 'katakana'
- ];
- $aListStylePositions = [
- 'inside', 'outside'
- ];
- $aRules = $this->getRulesAssoc();
- if (!isset($aRules['list-style'])) {
- return;
- }
- $oRule = $aRules['list-style'];
- $mRuleValue = $oRule->getValue();
- $aValues = [];
- if (!$mRuleValue instanceof RuleValueList) {
- $aValues[] = $mRuleValue;
- } else {
- $aValues = $mRuleValue->getListComponents();
- }
- if (count($aValues) == 1 && $aValues[0] == 'inherit') {
- foreach ($aListProperties as $sProperty => $mValue) {
- $oNewRule = new Rule($sProperty, $oRule->getLineNo(), $oRule->getColNo());
- $oNewRule->addValue('inherit');
- $oNewRule->setIsImportant($oRule->getIsImportant());
- $this->addRule($oNewRule);
- }
- $this->removeRule('list-style');
- return;
- }
- foreach ($aValues as $mValue) {
- if (!$mValue instanceof Value) {
- $mValue = mb_strtolower($mValue);
- }
- if ($mValue instanceof Url) {
- $aListProperties['list-style-image'] = $mValue;
- } elseif (in_array($mValue, $aListStyleTypes)) {
- $aListProperties['list-style-types'] = $mValue;
- } elseif (in_array($mValue, $aListStylePositions)) {
- $aListProperties['list-style-position'] = $mValue;
- }
- }
- foreach ($aListProperties as $sProperty => $mValue) {
- $oNewRule = new Rule($sProperty, $oRule->getLineNo(), $oRule->getColNo());
- $oNewRule->setIsImportant($oRule->getIsImportant());
- $oNewRule->addValue($mValue);
- $this->addRule($oNewRule);
- }
- $this->removeRule('list-style');
- }
-
- public function createShorthandProperties(array $aProperties, $sShorthand)
- {
- $aRules = $this->getRulesAssoc();
- $aNewValues = [];
- foreach ($aProperties as $sProperty) {
- if (!isset($aRules[$sProperty])) {
- continue;
- }
- $oRule = $aRules[$sProperty];
- if (!$oRule->getIsImportant()) {
- $mRuleValue = $oRule->getValue();
- $aValues = [];
- if (!$mRuleValue instanceof RuleValueList) {
- $aValues[] = $mRuleValue;
- } else {
- $aValues = $mRuleValue->getListComponents();
- }
- foreach ($aValues as $mValue) {
- $aNewValues[] = $mValue;
- }
- $this->removeRule($sProperty);
- }
- }
- if (count($aNewValues)) {
- $oNewRule = new Rule($sShorthand, $oRule->getLineNo(), $oRule->getColNo());
- foreach ($aNewValues as $mValue) {
- $oNewRule->addValue($mValue);
- }
- $this->addRule($oNewRule);
- }
- }
-
- public function createBackgroundShorthand()
- {
- $aProperties = [
- 'background-color', 'background-image', 'background-repeat',
- 'background-position', 'background-attachment'
- ];
- $this->createShorthandProperties($aProperties, 'background');
- }
-
- public function createListStyleShorthand()
- {
- $aProperties = [
- 'list-style-type', 'list-style-position', 'list-style-image'
- ];
- $this->createShorthandProperties($aProperties, 'list-style');
- }
-
- /**
- * Combine border-color, border-style and border-width into border
- * Should be run after create_dimensions_shorthand!
- * */
- public function createBorderShorthand()
- {
- $aProperties = [
- 'border-width', 'border-style', 'border-color'
- ];
- $this->createShorthandProperties($aProperties, 'border');
- }
-
- /*
- * Looks for long format CSS dimensional properties
- * (margin, padding, border-color, border-style and border-width)
- * and converts them into shorthand CSS properties.
- * */
-
- public function createDimensionsShorthand()
- {
- $aPositions = ['top', 'right', 'bottom', 'left'];
- $aExpansions = [
- 'margin' => 'margin-%s',
- 'padding' => 'padding-%s',
- 'border-color' => 'border-%s-color',
- 'border-style' => 'border-%s-style',
- 'border-width' => 'border-%s-width'
- ];
- $aRules = $this->getRulesAssoc();
- foreach ($aExpansions as $sProperty => $sExpanded) {
- $aFoldable = [];
- foreach ($aRules as $sRuleName => $oRule) {
- foreach ($aPositions as $sPosition) {
- if ($sRuleName == sprintf($sExpanded, $sPosition)) {
- $aFoldable[$sRuleName] = $oRule;
- }
- }
- }
- // All four dimensions must be present
- if (count($aFoldable) == 4) {
- $aValues = [];
- foreach ($aPositions as $sPosition) {
- $oRule = $aRules[sprintf($sExpanded, $sPosition)];
- $mRuleValue = $oRule->getValue();
- $aRuleValues = [];
- if (!$mRuleValue instanceof RuleValueList) {
- $aRuleValues[] = $mRuleValue;
- } else {
- $aRuleValues = $mRuleValue->getListComponents();
- }
- $aValues[$sPosition] = $aRuleValues;
- }
- $oNewRule = new Rule($sProperty, $oRule->getLineNo(), $oRule->getColNo());
- if ((string) $aValues['left'][0] == (string) $aValues['right'][0]) {
- if ((string) $aValues['top'][0] == (string) $aValues['bottom'][0]) {
- if ((string) $aValues['top'][0] == (string) $aValues['left'][0]) {
- // All 4 sides are equal
- $oNewRule->addValue($aValues['top']);
- } else {
- // Top and bottom are equal, left and right are equal
- $oNewRule->addValue($aValues['top']);
- $oNewRule->addValue($aValues['left']);
- }
- } else {
- // Only left and right are equal
- $oNewRule->addValue($aValues['top']);
- $oNewRule->addValue($aValues['left']);
- $oNewRule->addValue($aValues['bottom']);
- }
- } else {
- // No sides are equal
- $oNewRule->addValue($aValues['top']);
- $oNewRule->addValue($aValues['left']);
- $oNewRule->addValue($aValues['bottom']);
- $oNewRule->addValue($aValues['right']);
- }
- $this->addRule($oNewRule);
- foreach ($aPositions as $sPosition) {
- $this->removeRule(sprintf($sExpanded, $sPosition));
- }
- }
- }
- }
-
- /**
- * Looks for long format CSS font properties (e.g. font-weight) and
- * tries to convert them into a shorthand CSS font property.
- * At least font-size AND font-family must be present in order to create a shorthand declaration.
- * */
- public function createFontShorthand()
- {
- $aFontProperties = [
- 'font-style', 'font-variant', 'font-weight', 'font-size', 'line-height', 'font-family'
- ];
- $aRules = $this->getRulesAssoc();
- if (!isset($aRules['font-size']) || !isset($aRules['font-family'])) {
- return;
- }
- $oOldRule = isset($aRules['font-size']) ? $aRules['font-size'] : $aRules['font-family'];
- $oNewRule = new Rule('font', $oOldRule->getLineNo(), $oOldRule->getColNo());
- unset($oOldRule);
- foreach (['font-style', 'font-variant', 'font-weight'] as $sProperty) {
- if (isset($aRules[$sProperty])) {
- $oRule = $aRules[$sProperty];
- $mRuleValue = $oRule->getValue();
- $aValues = [];
- if (!$mRuleValue instanceof RuleValueList) {
- $aValues[] = $mRuleValue;
- } else {
- $aValues = $mRuleValue->getListComponents();
- }
- if ($aValues[0] !== 'normal') {
- $oNewRule->addValue($aValues[0]);
- }
- }
- }
- // Get the font-size value
- $oRule = $aRules['font-size'];
- $mRuleValue = $oRule->getValue();
- $aFSValues = [];
- if (!$mRuleValue instanceof RuleValueList) {
- $aFSValues[] = $mRuleValue;
- } else {
- $aFSValues = $mRuleValue->getListComponents();
- }
- // But wait to know if we have line-height to add it
- if (isset($aRules['line-height'])) {
- $oRule = $aRules['line-height'];
- $mRuleValue = $oRule->getValue();
- $aLHValues = [];
- if (!$mRuleValue instanceof RuleValueList) {
- $aLHValues[] = $mRuleValue;
- } else {
- $aLHValues = $mRuleValue->getListComponents();
- }
- if ($aLHValues[0] !== 'normal') {
- $val = new RuleValueList('/', $this->iLineNo);
- $val->addListComponent($aFSValues[0]);
- $val->addListComponent($aLHValues[0]);
- $oNewRule->addValue($val);
- }
- } else {
- $oNewRule->addValue($aFSValues[0]);
- }
- $oRule = $aRules['font-family'];
- $mRuleValue = $oRule->getValue();
- $aFFValues = [];
- if (!$mRuleValue instanceof RuleValueList) {
- $aFFValues[] = $mRuleValue;
- } else {
- $aFFValues = $mRuleValue->getListComponents();
- }
- $oFFValue = new RuleValueList(',', $this->iLineNo);
- $oFFValue->setListComponents($aFFValues);
- $oNewRule->addValue($oFFValue);
-
- $this->addRule($oNewRule);
- foreach ($aFontProperties as $sProperty) {
- $this->removeRule($sProperty);
- }
- }
-
- public function __toString()
- {
- return $this->render(new \Sabberworm\CSS\OutputFormat());
- }
-
- /**
- * @param \Sabberworm\CSS\OutputFormat $oOutputFormat
- *
- * @return string
- *
- * @throws OutputException
- */
- public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat)
- {
- if (count($this->aSelectors) === 0) {
- // If all the selectors have been removed, this declaration block becomes invalid
- throw new OutputException("Attempt to print declaration block with missing selector", $this->iLineNo);
- }
- $sResult = $oOutputFormat->sBeforeDeclarationBlock;
- $sResult .= $oOutputFormat->implode($oOutputFormat->spaceBeforeSelectorSeparator() . ',' . $oOutputFormat->spaceAfterSelectorSeparator(), $this->aSelectors);
- $sResult .= $oOutputFormat->sAfterDeclarationBlockSelectors;
- $sResult .= $oOutputFormat->spaceBeforeOpeningBrace() . '{';
- $sResult .= parent::render($oOutputFormat);
- $sResult .= '}';
- $sResult .= $oOutputFormat->sAfterDeclarationBlock;
- return $sResult;
- }
-}
diff --git a/lib/Sabberworm/CSS/RuleSet/RuleSet.php b/lib/Sabberworm/CSS/RuleSet/RuleSet.php
deleted file mode 100644
index 8cec47b2a..000000000
--- a/lib/Sabberworm/CSS/RuleSet/RuleSet.php
+++ /dev/null
@@ -1,249 +0,0 @@
-aRules = [];
- $this->iLineNo = $iLineNo;
- $this->aComments = [];
- }
-
- public static function parseRuleSet(ParserState $oParserState, RuleSet $oRuleSet)
- {
- while ($oParserState->comes(';')) {
- $oParserState->consume(';');
- }
- while (!$oParserState->comes('}')) {
- $oRule = null;
- if ($oParserState->getSettings()->bLenientParsing) {
- try {
- $oRule = Rule::parse($oParserState);
- } catch (UnexpectedTokenException $e) {
- try {
- $sConsume = $oParserState->consumeUntil(["\n", ";", '}'], true);
- // We need to “unfind” the matches to the end of the ruleSet as this will be matched later
- if ($oParserState->streql(substr($sConsume, -1), '}')) {
- $oParserState->backtrack(1);
- } else {
- while ($oParserState->comes(';')) {
- $oParserState->consume(';');
- }
- }
- } catch (UnexpectedTokenException $e) {
- // We’ve reached the end of the document. Just close the RuleSet.
- return;
- }
- }
- } else {
- $oRule = Rule::parse($oParserState);
- }
- if ($oRule) {
- $oRuleSet->addRule($oRule);
- }
- }
- $oParserState->consume('}');
- }
-
- /**
- * @return int
- */
- public function getLineNo()
- {
- return $this->iLineNo;
- }
-
- public function addRule(Rule $oRule, Rule $oSibling = null)
- {
- $sRule = $oRule->getRule();
- if (!isset($this->aRules[$sRule])) {
- $this->aRules[$sRule] = [];
- }
-
- $iPosition = count($this->aRules[$sRule]);
-
- if ($oSibling !== null) {
- $iSiblingPos = array_search($oSibling, $this->aRules[$sRule], true);
- if ($iSiblingPos !== false) {
- $iPosition = $iSiblingPos;
- $oRule->setPosition($oSibling->getLineNo(), $oSibling->getColNo() - 1);
- }
- }
- if ($oRule->getLineNo() === 0 && $oRule->getColNo() === 0) {
- //this node is added manually, give it the next best line
- $rules = $this->getRules();
- $pos = count($rules);
- if ($pos > 0) {
- $last = $rules[$pos - 1];
- $oRule->setPosition($last->getLineNo() + 1, 0);
- }
- }
-
- array_splice($this->aRules[$sRule], $iPosition, 0, [$oRule]);
- }
-
- /**
- * Returns all rules matching the given rule name
- *
- * @param null|string|Rule $mRule pattern to search for. If null, returns all rules. if the pattern ends with a dash, all rules starting with the pattern are returned as well as one matching the pattern with the dash excluded. passing a Rule behaves like calling getRules($mRule->getRule()).
- *
- * @example $oRuleSet->getRules('font-') //returns an array of all rules either beginning with font- or matching font.
- * @example $oRuleSet->getRules('font') //returns array(0 => $oRule, …) or array().
- *
- * @return array Rules.
- */
- public function getRules($mRule = null)
- {
- if ($mRule instanceof Rule) {
- $mRule = $mRule->getRule();
- }
- $aResult = [];
- foreach ($this->aRules as $sName => $aRules) {
- // Either no search rule is given or the search rule matches the found rule exactly or the search rule ends in “-” and the found rule starts with the search rule.
- if (!$mRule || $sName === $mRule || (strrpos($mRule, '-') === strlen($mRule) - strlen('-') && (strpos($sName, $mRule) === 0 || $sName === substr($mRule, 0, -1)))) {
- $aResult = array_merge($aResult, $aRules);
- }
- }
- usort($aResult, function (Rule $first, Rule $second) {
- if ($first->getLineNo() === $second->getLineNo()) {
- return $first->getColNo() - $second->getColNo();
- }
- return $first->getLineNo() - $second->getLineNo();
- });
- return $aResult;
- }
-
- /**
- * Override all the rules of this set.
- * @param Rule[] $aRules The rules to override with.
- */
- public function setRules(array $aRules)
- {
- $this->aRules = [];
- foreach ($aRules as $rule) {
- $this->addRule($rule);
- }
- }
-
- /**
- * Returns all rules matching the given pattern and returns them in an associative array with the rule’s name as keys. This method exists mainly for backwards-compatibility and is really only partially useful.
- * @param (string) $mRule pattern to search for. If null, returns all rules. if the pattern ends with a dash, all rules starting with the pattern are returned as well as one matching the pattern with the dash excluded. passing a Rule behaves like calling getRules($mRule->getRule()).
- * Note: This method loses some information: Calling this (with an argument of 'background-') on a declaration block like { background-color: green; background-color; rgba(0, 127, 0, 0.7); } will only yield an associative array containing the rgba-valued rule while @link{getRules()} would yield an indexed array containing both.
- * @return Rule[] Rules.
- */
- public function getRulesAssoc($mRule = null)
- {
- $aResult = [];
- foreach ($this->getRules($mRule) as $oRule) {
- $aResult[$oRule->getRule()] = $oRule;
- }
- return $aResult;
- }
-
- /**
- * Remove a rule from this RuleSet. This accepts all the possible values that @link{getRules()} accepts. If given a Rule, it will only remove this particular rule (by identity). If given a name, it will remove all rules by that name. Note: this is different from pre-v.2.0 behaviour of PHP-CSS-Parser, where passing a Rule instance would remove all rules with the same name. To get the old behvaiour, use removeRule($oRule->getRule()).
- * @param (null|string|Rule) $mRule pattern to remove. If $mRule is null, all rules are removed. If the pattern ends in a dash, all rules starting with the pattern are removed as well as one matching the pattern with the dash excluded. Passing a Rule behaves matches by identity.
- */
- public function removeRule($mRule)
- {
- if ($mRule instanceof Rule) {
- $sRule = $mRule->getRule();
- if (!isset($this->aRules[$sRule])) {
- return;
- }
- foreach ($this->aRules[$sRule] as $iKey => $oRule) {
- if ($oRule === $mRule) {
- unset($this->aRules[$sRule][$iKey]);
- }
- }
- } else {
- foreach ($this->aRules as $sName => $aRules) {
- // Either no search rule is given or the search rule matches the found rule exactly or the search rule ends in “-” and the found rule starts with the search rule or equals it (without the trailing dash).
- if (!$mRule || $sName === $mRule || (strrpos($mRule, '-') === strlen($mRule) - strlen('-') && (strpos($sName, $mRule) === 0 || $sName === substr($mRule, 0, -1)))) {
- unset($this->aRules[$sName]);
- }
- }
- }
- }
-
- public function __toString()
- {
- return $this->render(new \Sabberworm\CSS\OutputFormat());
- }
-
- /**
- * @param \Sabberworm\CSS\OutputFormat $oOutputFormat
- *
- * @return string
- */
- public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat)
- {
- $sResult = '';
- $bIsFirst = true;
- foreach ($this->aRules as $aRules) {
- foreach ($aRules as $oRule) {
- $sRendered = $oOutputFormat->safely(function () use ($oRule, $oOutputFormat) {
- return $oRule->render($oOutputFormat->nextLevel());
- });
- if ($sRendered === null) {
- continue;
- }
- if ($bIsFirst) {
- $bIsFirst = false;
- $sResult .= $oOutputFormat->nextLevel()->spaceBeforeRules();
- } else {
- $sResult .= $oOutputFormat->nextLevel()->spaceBetweenRules();
- }
- $sResult .= $sRendered;
- }
- }
-
- if (!$bIsFirst) {
- // Had some output
- $sResult .= $oOutputFormat->spaceAfterRules();
- }
-
- return $oOutputFormat->removeLastSemicolon($sResult);
- }
-
- /**
- * @param array $aComments Array of comments.
- */
- public function addComments(array $aComments)
- {
- $this->aComments = array_merge($this->aComments, $aComments);
- }
-
- /**
- * @return array
- */
- public function getComments()
- {
- return $this->aComments;
- }
-
- /**
- * @param array $aComments Array containing Comment objects.
- */
- public function setComments(array $aComments)
- {
- $this->aComments = $aComments;
- }
-}
diff --git a/lib/Sabberworm/CSS/Settings.php b/lib/Sabberworm/CSS/Settings.php
deleted file mode 100644
index ad89d4ebf..000000000
--- a/lib/Sabberworm/CSS/Settings.php
+++ /dev/null
@@ -1,61 +0,0 @@
-bMultibyteSupport = extension_loaded('mbstring');
- }
-
- public static function create()
- {
- return new Settings();
- }
-
- public function withMultibyteSupport($bMultibyteSupport = true)
- {
- $this->bMultibyteSupport = $bMultibyteSupport;
- return $this;
- }
-
- public function withDefaultCharset($sDefaultCharset)
- {
- $this->sDefaultCharset = $sDefaultCharset;
- return $this;
- }
-
- public function withLenientParsing($bLenientParsing = true)
- {
- $this->bLenientParsing = $bLenientParsing;
- return $this;
- }
-
- public function beStrict()
- {
- return $this->withLenientParsing(false);
- }
-}
diff --git a/lib/Sabberworm/CSS/Value/CSSFunction.php b/lib/Sabberworm/CSS/Value/CSSFunction.php
deleted file mode 100644
index 9de442ea4..000000000
--- a/lib/Sabberworm/CSS/Value/CSSFunction.php
+++ /dev/null
@@ -1,51 +0,0 @@
-getListSeparator();
- $aArguments = $aArguments->getListComponents();
- }
- $this->sName = $sName;
- $this->iLineNo = $iLineNo;
- parent::__construct($aArguments, $sSeparator, $iLineNo);
- }
-
- public function getName()
- {
- return $this->sName;
- }
-
- public function setName($sName)
- {
- $this->sName = $sName;
- }
-
- public function getArguments()
- {
- return $this->aComponents;
- }
-
- public function __toString()
- {
- return $this->render(new \Sabberworm\CSS\OutputFormat());
- }
-
- /**
- * @param \Sabberworm\CSS\OutputFormat $oOutputFormat
- *
- * @return string
- */
- public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat)
- {
- $aArguments = parent::render($oOutputFormat);
- return "{$this->sName}({$aArguments})";
- }
-}
diff --git a/lib/Sabberworm/CSS/Value/CSSString.php b/lib/Sabberworm/CSS/Value/CSSString.php
deleted file mode 100644
index 3de9416a5..000000000
--- a/lib/Sabberworm/CSS/Value/CSSString.php
+++ /dev/null
@@ -1,77 +0,0 @@
-sString = $sString;
- parent::__construct($iLineNo);
- }
-
- public static function parse(ParserState $oParserState)
- {
- $sBegin = $oParserState->peek();
- $sQuote = null;
- if ($sBegin === "'") {
- $sQuote = "'";
- } elseif ($sBegin === '"') {
- $sQuote = '"';
- }
- if ($sQuote !== null) {
- $oParserState->consume($sQuote);
- }
- $sResult = "";
- $sContent = null;
- if ($sQuote === null) {
- // Unquoted strings end in whitespace or with braces, brackets, parentheses
- while (!preg_match('/[\\s{}()<>\\[\\]]/isu', $oParserState->peek())) {
- $sResult .= $oParserState->parseCharacter(false);
- }
- } else {
- while (!$oParserState->comes($sQuote)) {
- $sContent = $oParserState->parseCharacter(false);
- if ($sContent === null) {
- throw new SourceException("Non-well-formed quoted string {$oParserState->peek(3)}", $oParserState->currentLine());
- }
- $sResult .= $sContent;
- }
- $oParserState->consume($sQuote);
- }
- return new CSSString($sResult, $oParserState->currentLine());
- }
-
- public function setString($sString)
- {
- $this->sString = $sString;
- }
-
- public function getString()
- {
- return $this->sString;
- }
-
- public function __toString()
- {
- return $this->render(new \Sabberworm\CSS\OutputFormat());
- }
-
- /**
- * @param \Sabberworm\CSS\OutputFormat $oOutputFormat
- *
- * @return string
- */
- public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat)
- {
- $sString = addslashes($this->sString);
- $sString = str_replace("\n", '\A', $sString);
- return $oOutputFormat->getStringQuotingType() . $sString . $oOutputFormat->getStringQuotingType();
- }
-}
diff --git a/lib/Sabberworm/CSS/Value/CalcFunction.php b/lib/Sabberworm/CSS/Value/CalcFunction.php
deleted file mode 100644
index e03fd45fa..000000000
--- a/lib/Sabberworm/CSS/Value/CalcFunction.php
+++ /dev/null
@@ -1,66 +0,0 @@
-consumeUntil('(', false, true));
- $oCalcList = new CalcRuleValueList($oParserState->currentLine());
- $oList = new RuleValueList(',', $oParserState->currentLine());
- $iNestingLevel = 0;
- $iLastComponentType = null;
- while (!$oParserState->comes(')') || $iNestingLevel > 0) {
- $oParserState->consumeWhiteSpace();
- if ($oParserState->comes('(')) {
- $iNestingLevel++;
- $oCalcList->addListComponent($oParserState->consume(1));
- $oParserState->consumeWhiteSpace();
- continue;
- } elseif ($oParserState->comes(')')) {
- $iNestingLevel--;
- $oCalcList->addListComponent($oParserState->consume(1));
- $oParserState->consumeWhiteSpace();
- continue;
- }
- if ($iLastComponentType != CalcFunction::T_OPERAND) {
- $oVal = Value::parsePrimitiveValue($oParserState);
- $oCalcList->addListComponent($oVal);
- $iLastComponentType = CalcFunction::T_OPERAND;
- } else {
- if (in_array($oParserState->peek(), $aOperators)) {
- if (($oParserState->comes('-') || $oParserState->comes('+'))) {
- if ($oParserState->peek(1, -1) != ' ' || !($oParserState->comes('- ') || $oParserState->comes('+ '))) {
- throw new UnexpectedTokenException(" {$oParserState->peek()} ", $oParserState->peek(1, -1) . $oParserState->peek(2), 'literal', $oParserState->currentLine());
- }
- }
- $oCalcList->addListComponent($oParserState->consume(1));
- $iLastComponentType = CalcFunction::T_OPERATOR;
- } else {
- throw new UnexpectedTokenException(
- sprintf(
- 'Next token was expected to be an operand of type %s. Instead "%s" was found.',
- implode(', ', $aOperators),
- $oVal
- ),
- '',
- 'custom',
- $oParserState->currentLine()
- );
- }
- }
- $oParserState->consumeWhiteSpace();
- }
- $oList->addListComponent($oCalcList);
- $oParserState->consume(')');
- return new CalcFunction($sFunction, $oList, ',', $oParserState->currentLine());
- }
-}
diff --git a/lib/Sabberworm/CSS/Value/CalcRuleValueList.php b/lib/Sabberworm/CSS/Value/CalcRuleValueList.php
deleted file mode 100644
index 79e0cbcf2..000000000
--- a/lib/Sabberworm/CSS/Value/CalcRuleValueList.php
+++ /dev/null
@@ -1,21 +0,0 @@
-implode(' ', $this->aComponents);
- }
-}
diff --git a/lib/Sabberworm/CSS/Value/Color.php b/lib/Sabberworm/CSS/Value/Color.php
deleted file mode 100644
index 60637c13b..000000000
--- a/lib/Sabberworm/CSS/Value/Color.php
+++ /dev/null
@@ -1,126 +0,0 @@
-comes('#')) {
- $oParserState->consume('#');
- $sValue = $oParserState->parseIdentifier(false);
- if ($oParserState->strlen($sValue) === 3) {
- $sValue = $sValue[0] . $sValue[0] . $sValue[1] . $sValue[1] . $sValue[2] . $sValue[2];
- } elseif ($oParserState->strlen($sValue) === 4) {
- $sValue = $sValue[0] . $sValue[0] . $sValue[1] . $sValue[1] . $sValue[2] . $sValue[2] . $sValue[3] . $sValue[3];
- }
-
- if ($oParserState->strlen($sValue) === 8) {
- $aColor = [
- 'r' => new Size(intval($sValue[0] . $sValue[1], 16), null, true, $oParserState->currentLine()),
- 'g' => new Size(intval($sValue[2] . $sValue[3], 16), null, true, $oParserState->currentLine()),
- 'b' => new Size(intval($sValue[4] . $sValue[5], 16), null, true, $oParserState->currentLine()),
- 'a' => new Size(round(self::mapRange(intval($sValue[6] . $sValue[7], 16), 0, 255, 0, 1), 2), null, true, $oParserState->currentLine())
- ];
- } else {
- $aColor = [
- 'r' => new Size(intval($sValue[0] . $sValue[1], 16), null, true, $oParserState->currentLine()),
- 'g' => new Size(intval($sValue[2] . $sValue[3], 16), null, true, $oParserState->currentLine()),
- 'b' => new Size(intval($sValue[4] . $sValue[5], 16), null, true, $oParserState->currentLine())
- ];
- }
- } else {
- $sColorMode = $oParserState->parseIdentifier(true);
- $oParserState->consumeWhiteSpace();
- $oParserState->consume('(');
-
- $bContainsVar = false;
- $iLength = $oParserState->strlen($sColorMode);
- for ($i = 0; $i < $iLength; ++$i) {
- $oParserState->consumeWhiteSpace();
- if ($oParserState->comes('var')) {
- $aColor[$sColorMode[$i]] = CSSFunction::parseIdentifierOrFunction($oParserState);
- $bContainsVar = true;
- } else {
- $aColor[$sColorMode[$i]] = Size::parse($oParserState, true);
- }
-
- if ($bContainsVar && $oParserState->comes(')')) {
- // With a var argument the function can have fewer arguments
- break;
- }
-
- $oParserState->consumeWhiteSpace();
- if ($i < ($iLength - 1)) {
- $oParserState->consume(',');
- }
- }
- $oParserState->consume(')');
-
- if ($bContainsVar) {
- return new CSSFunction($sColorMode, array_values($aColor), ',', $oParserState->currentLine());
- }
- }
- return new Color($aColor, $oParserState->currentLine());
- }
-
- private static function mapRange($fVal, $fFromMin, $fFromMax, $fToMin, $fToMax)
- {
- $fFromRange = $fFromMax - $fFromMin;
- $fToRange = $fToMax - $fToMin;
- $fMultiplier = $fToRange / $fFromRange;
- $fNewVal = $fVal - $fFromMin;
- $fNewVal *= $fMultiplier;
- return $fNewVal + $fToMin;
- }
-
- public function getColor()
- {
- return $this->aComponents;
- }
-
- public function setColor($aColor)
- {
- $this->setName(implode('', array_keys($aColor)));
- $this->aComponents = $aColor;
- }
-
- public function getColorDescription()
- {
- return $this->getName();
- }
-
- public function __toString()
- {
- return $this->render(new \Sabberworm\CSS\OutputFormat());
- }
-
- /**
- * @param \Sabberworm\CSS\OutputFormat $oOutputFormat
- *
- * @return string
- */
- public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat)
- {
- // Shorthand RGB color values
- if ($oOutputFormat->getRGBHashNotation() && implode('', array_keys($this->aComponents)) === 'rgb') {
- $sResult = sprintf(
- '%02x%02x%02x',
- $this->aComponents['r']->getSize(),
- $this->aComponents['g']->getSize(),
- $this->aComponents['b']->getSize()
- );
- return '#' . (($sResult[0] == $sResult[1]) && ($sResult[2] == $sResult[3]) && ($sResult[4] == $sResult[5]) ? "$sResult[0]$sResult[2]$sResult[4]" : $sResult);
- }
- return parent::render($oOutputFormat);
- }
-}
diff --git a/lib/Sabberworm/CSS/Value/LineName.php b/lib/Sabberworm/CSS/Value/LineName.php
deleted file mode 100644
index 06af73671..000000000
--- a/lib/Sabberworm/CSS/Value/LineName.php
+++ /dev/null
@@ -1,52 +0,0 @@
-consume('[');
- $oParserState->consumeWhiteSpace();
- $aNames = [];
- do {
- if ($oParserState->getSettings()->bLenientParsing) {
- try {
- $aNames[] = $oParserState->parseIdentifier();
- } catch (UnexpectedTokenException $e) {
- if (!$oParserState->comes(']')) {
- throw $e;
- }
- }
- } else {
- $aNames[] = $oParserState->parseIdentifier();
- }
- $oParserState->consumeWhiteSpace();
- } while (!$oParserState->comes(']'));
- $oParserState->consume(']');
- return new LineName($aNames, $oParserState->currentLine());
- }
-
- public function __toString()
- {
- return $this->render(new \Sabberworm\CSS\OutputFormat());
- }
-
- /**
- * @param \Sabberworm\CSS\OutputFormat $oOutputFormat
- *
- * @return string
- */
- public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat)
- {
- return '[' . parent::render(\Sabberworm\CSS\OutputFormat::createCompact()) . ']';
- }
-}
diff --git a/lib/Sabberworm/CSS/Value/PrimitiveValue.php b/lib/Sabberworm/CSS/Value/PrimitiveValue.php
deleted file mode 100644
index 05b099b51..000000000
--- a/lib/Sabberworm/CSS/Value/PrimitiveValue.php
+++ /dev/null
@@ -1,11 +0,0 @@
-fSize = (float)$fSize;
- $this->sUnit = $sUnit;
- $this->bIsColorComponent = $bIsColorComponent;
- }
-
- public static function parse(ParserState $oParserState, $bIsColorComponent = false)
- {
- $sSize = '';
- if ($oParserState->comes('-')) {
- $sSize .= $oParserState->consume('-');
- }
- while (is_numeric($oParserState->peek()) || $oParserState->comes('.')) {
- if ($oParserState->comes('.')) {
- $sSize .= $oParserState->consume('.');
- } else {
- $sSize .= $oParserState->consume(1);
- }
- }
-
- $sUnit = null;
- $aSizeUnits = self::getSizeUnits();
- foreach ($aSizeUnits as $iLength => &$aValues) {
- $sKey = strtolower($oParserState->peek($iLength));
- if (array_key_exists($sKey, $aValues)) {
- if (($sUnit = $aValues[$sKey]) !== null) {
- $oParserState->consume($iLength);
- break;
- }
- }
- }
- return new Size((float)$sSize, $sUnit, $bIsColorComponent, $oParserState->currentLine());
- }
-
- private static function getSizeUnits()
- {
- if (self::$SIZE_UNITS === null) {
- self::$SIZE_UNITS = [];
- foreach (explode('/', Size::ABSOLUTE_SIZE_UNITS . '/' . Size::RELATIVE_SIZE_UNITS . '/' . Size::NON_SIZE_UNITS) as $val) {
- $iSize = strlen($val);
- if (!isset(self::$SIZE_UNITS[$iSize])) {
- self::$SIZE_UNITS[$iSize] = [];
- }
- self::$SIZE_UNITS[$iSize][strtolower($val)] = $val;
- }
-
- krsort(self::$SIZE_UNITS, SORT_NUMERIC);
- }
-
- return self::$SIZE_UNITS;
- }
-
- public function setUnit($sUnit)
- {
- $this->sUnit = $sUnit;
- }
-
- public function getUnit()
- {
- return $this->sUnit;
- }
-
- public function setSize($fSize)
- {
- $this->fSize = (float)$fSize;
- }
-
- public function getSize()
- {
- return $this->fSize;
- }
-
- public function isColorComponent()
- {
- return $this->bIsColorComponent;
- }
-
- /**
- * Returns whether the number stored in this Size really represents a size (as in a length of something on screen).
- * @return false if the unit an angle, a duration, a frequency or the number is a component in a Color object.
- */
- public function isSize()
- {
- if (in_array($this->sUnit, explode('/', self::NON_SIZE_UNITS))) {
- return false;
- }
- return !$this->isColorComponent();
- }
-
- public function isRelative()
- {
- if (in_array($this->sUnit, explode('/', self::RELATIVE_SIZE_UNITS))) {
- return true;
- }
- if ($this->sUnit === null && $this->fSize != 0) {
- return true;
- }
- return false;
- }
-
- public function __toString()
- {
- return $this->render(new \Sabberworm\CSS\OutputFormat());
- }
-
- /**
- * @param \Sabberworm\CSS\OutputFormat $oOutputFormat
- *
- * @return string
- */
- public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat)
- {
- $l = localeconv();
- $sPoint = preg_quote($l['decimal_point'], '/');
- $sSize = preg_match("/[\d\.]+e[+-]?\d+/i", (string)$this->fSize) ? preg_replace("/$sPoint?0+$/", "", sprintf("%f", $this->fSize)) : $this->fSize;
- return preg_replace(["/$sPoint/", "/^(-?)0\./"], ['.', '$1.'], $sSize) . ($this->sUnit === null ? '' : $this->sUnit);
- }
-}
diff --git a/lib/Sabberworm/CSS/Value/URL.php b/lib/Sabberworm/CSS/Value/URL.php
deleted file mode 100644
index cc683d015..000000000
--- a/lib/Sabberworm/CSS/Value/URL.php
+++ /dev/null
@@ -1,60 +0,0 @@
-oURL = $oURL;
- }
-
- public static function parse(ParserState $oParserState)
- {
- $bUseUrl = $oParserState->comes('url', true);
- if ($bUseUrl) {
- $oParserState->consume('url');
- $oParserState->consumeWhiteSpace();
- $oParserState->consume('(');
- }
- $oParserState->consumeWhiteSpace();
- $oResult = new URL(CSSString::parse($oParserState), $oParserState->currentLine());
- if ($bUseUrl) {
- $oParserState->consumeWhiteSpace();
- $oParserState->consume(')');
- }
- return $oResult;
- }
-
-
- public function setURL(CSSString $oURL)
- {
- $this->oURL = $oURL;
- }
-
- public function getURL()
- {
- return $this->oURL;
- }
-
- public function __toString()
- {
- return $this->render(new \Sabberworm\CSS\OutputFormat());
- }
-
- /**
- * @param \Sabberworm\CSS\OutputFormat $oOutputFormat
- *
- * @return string
- */
- public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat)
- {
- return "url({$this->oURL->render($oOutputFormat)})";
- }
-}
diff --git a/lib/Sabberworm/CSS/Value/Value.php b/lib/Sabberworm/CSS/Value/Value.php
deleted file mode 100644
index ec339bfeb..000000000
--- a/lib/Sabberworm/CSS/Value/Value.php
+++ /dev/null
@@ -1,141 +0,0 @@
-iLineNo = $iLineNo;
- }
-
- public static function parseValue(ParserState $oParserState, $aListDelimiters = [])
- {
- $aStack = [];
- $oParserState->consumeWhiteSpace();
- //Build a list of delimiters and parsed values
- while (!($oParserState->comes('}') || $oParserState->comes(';') || $oParserState->comes('!') || $oParserState->comes(')') || $oParserState->comes('\\'))) {
- if (count($aStack) > 0) {
- $bFoundDelimiter = false;
- foreach ($aListDelimiters as $sDelimiter) {
- if ($oParserState->comes($sDelimiter)) {
- array_push($aStack, $oParserState->consume($sDelimiter));
- $oParserState->consumeWhiteSpace();
- $bFoundDelimiter = true;
- break;
- }
- }
- if (!$bFoundDelimiter) {
- //Whitespace was the list delimiter
- array_push($aStack, ' ');
- }
- }
- array_push($aStack, self::parsePrimitiveValue($oParserState));
- $oParserState->consumeWhiteSpace();
- }
- //Convert the list to list objects
- foreach ($aListDelimiters as $sDelimiter) {
- if (count($aStack) === 1) {
- return $aStack[0];
- }
- $iStartPosition = null;
- while (($iStartPosition = array_search($sDelimiter, $aStack, true)) !== false) {
- $iLength = 2; //Number of elements to be joined
- for ($i = $iStartPosition + 2; $i < count($aStack); $i += 2, ++$iLength) {
- if ($sDelimiter !== $aStack[$i]) {
- break;
- }
- }
- $oList = new RuleValueList($sDelimiter, $oParserState->currentLine());
- for ($i = $iStartPosition - 1; $i - $iStartPosition + 1 < $iLength * 2; $i += 2) {
- $oList->addListComponent($aStack[$i]);
- }
- array_splice($aStack, $iStartPosition - 1, $iLength * 2 - 1, [$oList]);
- }
- }
- if (!isset($aStack[0])) {
- throw new UnexpectedTokenException(" {$oParserState->peek()} ", $oParserState->peek(1, -1) . $oParserState->peek(2), 'literal', $oParserState->currentLine());
- }
- return $aStack[0];
- }
-
- public static function parseIdentifierOrFunction(ParserState $oParserState, $bIgnoreCase = false)
- {
- $sResult = $oParserState->parseIdentifier($bIgnoreCase);
-
- if ($oParserState->comes('(')) {
- $oParserState->consume('(');
- $aArguments = Value::parseValue($oParserState, ['=', ' ', ',']);
- $sResult = new CSSFunction($sResult, $aArguments, ',', $oParserState->currentLine());
- $oParserState->consume(')');
- }
-
- return $sResult;
- }
-
- public static function parsePrimitiveValue(ParserState $oParserState)
- {
- $oValue = null;
- $oParserState->consumeWhiteSpace();
- if (is_numeric($oParserState->peek()) || ($oParserState->comes('-.') && is_numeric($oParserState->peek(1, 2))) || (($oParserState->comes('-') || $oParserState->comes('.')) && is_numeric($oParserState->peek(1, 1)))) {
- $oValue = Size::parse($oParserState);
- } elseif ($oParserState->comes('#') || $oParserState->comes('rgb', true) || $oParserState->comes('hsl', true)) {
- $oValue = Color::parse($oParserState);
- } elseif ($oParserState->comes('url', true)) {
- $oValue = URL::parse($oParserState);
- } elseif ($oParserState->comes('calc', true) || $oParserState->comes('-webkit-calc', true) || $oParserState->comes('-moz-calc', true)) {
- $oValue = CalcFunction::parse($oParserState);
- } elseif ($oParserState->comes("'") || $oParserState->comes('"')) {
- $oValue = CSSString::parse($oParserState);
- } elseif ($oParserState->comes("progid:") && $oParserState->getSettings()->bLenientParsing) {
- $oValue = self::parseMicrosoftFilter($oParserState);
- } elseif ($oParserState->comes("[")) {
- $oValue = LineName::parse($oParserState);
- } elseif ($oParserState->comes("U+")) {
- $oValue = self::parseUnicodeRangeValue($oParserState);
- } else {
- $oValue = self::parseIdentifierOrFunction($oParserState);
- }
- $oParserState->consumeWhiteSpace();
- return $oValue;
- }
-
- private static function parseMicrosoftFilter(ParserState $oParserState)
- {
- $sFunction = $oParserState->consumeUntil('(', false, true);
- $aArguments = Value::parseValue($oParserState, [',', '=']);
- return new CSSFunction($sFunction, $aArguments, ',', $oParserState->currentLine());
- }
-
- private static function parseUnicodeRangeValue(ParserState $oParserState)
- {
- $iCodepointMaxLenth = 6; // Code points outside BMP can use up to six digits
- $sRange = "";
- $oParserState->consume("U+");
- do {
- if ($oParserState->comes('-')) {
- $iCodepointMaxLenth = 13; // Max length is 2 six digit code points + the dash(-) between them
- }
- $sRange .= $oParserState->consume(1);
- } while (strlen($sRange) < $iCodepointMaxLenth && preg_match("/[A-Fa-f0-9\?-]/", $oParserState->peek()));
- return "U+{$sRange}";
- }
-
- /**
- * @return int
- */
- public function getLineNo()
- {
- return $this->iLineNo;
- }
-
- //Methods are commented out because re-declaring them here is a fatal error in PHP < 5.3.9
- //public abstract function __toString();
- //public abstract function render(\Sabberworm\CSS\OutputFormat $oOutputFormat);
-}
diff --git a/lib/Sabberworm/CSS/Value/ValueList.php b/lib/Sabberworm/CSS/Value/ValueList.php
deleted file mode 100644
index 6eec4e115..000000000
--- a/lib/Sabberworm/CSS/Value/ValueList.php
+++ /dev/null
@@ -1,60 +0,0 @@
-aComponents = $aComponents;
- $this->sSeparator = $sSeparator;
- }
-
- public function addListComponent($mComponent)
- {
- $this->aComponents[] = $mComponent;
- }
-
- public function getListComponents()
- {
- return $this->aComponents;
- }
-
- public function setListComponents($aComponents)
- {
- $this->aComponents = $aComponents;
- }
-
- public function getListSeparator()
- {
- return $this->sSeparator;
- }
-
- public function setListSeparator($sSeparator)
- {
- $this->sSeparator = $sSeparator;
- }
-
- public function __toString()
- {
- return $this->render(new \Sabberworm\CSS\OutputFormat());
- }
-
- /**
- * @param \Sabberworm\CSS\OutputFormat $oOutputFormat
- *
- * @return string
- */
- public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat)
- {
- return $oOutputFormat->implode($oOutputFormat->spaceBeforeListArgumentSeparator($this->sSeparator) . $this->sSeparator . $oOutputFormat->spaceAfterListArgumentSeparator($this->sSeparator), $this->aComponents);
- }
-}
diff --git a/phpcs.xml b/phpcs.xml
deleted file mode 100644
index 630923018..000000000
--- a/phpcs.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
- This standard requires PHP_CodeSniffer >= 3.6.0.
-
-
-
-
-
-
-
-
-
-
-
diff --git a/phpunit.xml b/phpunit.xml
index 1a59ea4ec..aab1f10c4 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -1,7 +1,15 @@
+ xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/8.5/phpunit.xsd"
+ beStrictAboutChangesToGlobalState="true"
+ beStrictAboutOutputDuringTests="true"
+ beStrictAboutTodoAnnotatedTests="true"
+ cacheResult="false"
+ colors="true"
+ convertDeprecationsToExceptions="true"
+ forceCoversAnnotation="true"
+ verbose="true"
+>
tests
@@ -10,7 +18,7 @@
- lib/Sabberworm/CSS
+ src
diff --git a/src/CSSElement.php b/src/CSSElement.php
new file mode 100644
index 000000000..944aabe2c
--- /dev/null
+++ b/src/CSSElement.php
@@ -0,0 +1,17 @@
+ $lineNumber
+ */
+ public function __construct(string $type, string $arguments = '', int $lineNumber = 0)
+ {
+ parent::__construct($lineNumber);
+ $this->type = $type;
+ $this->arguments = $arguments;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function atRuleName(): string
+ {
+ return $this->type;
+ }
+
+ public function atRuleArgs(): string
+ {
+ return $this->arguments;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function render(OutputFormat $outputFormat): string
+ {
+ $formatter = $outputFormat->getFormatter();
+ $result = $formatter->comments($this);
+ $result .= $outputFormat->getContentBeforeAtRuleBlock();
+ $arguments = $this->arguments;
+ if ($arguments) {
+ $arguments = ' ' . $arguments;
+ }
+ $result .= "@{$this->type}$arguments{$formatter->spaceBeforeOpeningBrace()}{";
+ $result .= $this->renderListContents($outputFormat);
+ $result .= '}';
+ $result .= $outputFormat->getContentAfterAtRuleBlock();
+ return $result;
+ }
+
+ public function isRootList(): bool
+ {
+ return false;
+ }
+}
diff --git a/src/CSSList/CSSBlockList.php b/src/CSSList/CSSBlockList.php
new file mode 100644
index 000000000..2dfd284f0
--- /dev/null
+++ b/src/CSSList/CSSBlockList.php
@@ -0,0 +1,182 @@
+
+ */
+ public function getAllDeclarationBlocks(): array
+ {
+ $result = [];
+
+ foreach ($this->contents as $item) {
+ if ($item instanceof DeclarationBlock) {
+ $result[] = $item;
+ } elseif ($item instanceof CSSBlockList) {
+ $result = \array_merge($result, $item->getAllDeclarationBlocks());
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Returns all `RuleSet` objects recursively found in the tree, no matter how deeply nested the rule sets are.
+ *
+ * @return list
+ */
+ public function getAllRuleSets(): array
+ {
+ $result = [];
+
+ foreach ($this->contents as $item) {
+ if ($item instanceof RuleSet) {
+ $result[] = $item;
+ } elseif ($item instanceof CSSBlockList) {
+ $result = \array_merge($result, $item->getAllRuleSets());
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Returns all `Value` objects found recursively in `Rule`s in the tree.
+ *
+ * @param CSSElement|null $element
+ * This is the `CSSList` or `RuleSet` to start the search from (defaults to the whole document).
+ * @param string|null $ruleSearchPattern
+ * This allows filtering rules by property name
+ * (e.g. if "color" is passed, only `Value`s from `color` properties will be returned,
+ * or if "font-" is provided, `Value`s from all font rules, like `font-size`, and including `font` itself,
+ * will be returned).
+ * @param bool $searchInFunctionArguments whether to also return `Value` objects used as `CSSFunction` arguments.
+ *
+ * @return list
+ *
+ * @see RuleSet->getRules()
+ */
+ public function getAllValues(
+ ?CSSElement $element = null,
+ ?string $ruleSearchPattern = null,
+ bool $searchInFunctionArguments = false
+ ): array {
+ $element = $element ?? $this;
+
+ $result = [];
+ if ($element instanceof CSSBlockList) {
+ foreach ($element->getContents() as $contentItem) {
+ // Statement at-rules are skipped since they do not contain values.
+ if ($contentItem instanceof CSSElement) {
+ $result = \array_merge(
+ $result,
+ $this->getAllValues($contentItem, $ruleSearchPattern, $searchInFunctionArguments)
+ );
+ }
+ }
+ } elseif ($element instanceof RuleContainer) {
+ foreach ($element->getRules($ruleSearchPattern) as $rule) {
+ $result = \array_merge(
+ $result,
+ $this->getAllValues($rule, $ruleSearchPattern, $searchInFunctionArguments)
+ );
+ }
+ } elseif ($element instanceof Rule) {
+ $value = $element->getValue();
+ // `string` values are discarded.
+ if ($value instanceof CSSElement) {
+ $result = \array_merge(
+ $result,
+ $this->getAllValues($value, $ruleSearchPattern, $searchInFunctionArguments)
+ );
+ }
+ } elseif ($element instanceof ValueList) {
+ if ($searchInFunctionArguments || !($element instanceof CSSFunction)) {
+ foreach ($element->getListComponents() as $component) {
+ // `string` components are discarded.
+ if ($component instanceof CSSElement) {
+ $result = \array_merge(
+ $result,
+ $this->getAllValues($component, $ruleSearchPattern, $searchInFunctionArguments)
+ );
+ }
+ }
+ }
+ } elseif ($element instanceof Value) {
+ $result[] = $element;
+ }
+
+ return $result;
+ }
+
+ /**
+ * @return list
+ */
+ protected function getAllSelectors(?string $specificitySearch = null): array
+ {
+ $result = [];
+
+ foreach ($this->getAllDeclarationBlocks() as $declarationBlock) {
+ foreach ($declarationBlock->getSelectors() as $selector) {
+ if ($specificitySearch === null) {
+ $result[] = $selector;
+ } else {
+ $comparator = '===';
+ $expressionParts = \explode(' ', $specificitySearch);
+ $targetSpecificity = $expressionParts[0];
+ if (\count($expressionParts) > 1) {
+ $comparator = $expressionParts[0];
+ $targetSpecificity = $expressionParts[1];
+ }
+ $targetSpecificity = (int) $targetSpecificity;
+ $selectorSpecificity = $selector->getSpecificity();
+ $comparatorMatched = false;
+ switch ($comparator) {
+ case '<=':
+ $comparatorMatched = $selectorSpecificity <= $targetSpecificity;
+ break;
+ case '<':
+ $comparatorMatched = $selectorSpecificity < $targetSpecificity;
+ break;
+ case '>=':
+ $comparatorMatched = $selectorSpecificity >= $targetSpecificity;
+ break;
+ case '>':
+ $comparatorMatched = $selectorSpecificity > $targetSpecificity;
+ break;
+ default:
+ $comparatorMatched = $selectorSpecificity === $targetSpecificity;
+ break;
+ }
+ if ($comparatorMatched) {
+ $result[] = $selector;
+ }
+ }
+ }
+ }
+
+ return $result;
+ }
+}
diff --git a/src/CSSList/CSSList.php b/src/CSSList/CSSList.php
new file mode 100644
index 000000000..1942d8c95
--- /dev/null
+++ b/src/CSSList/CSSList.php
@@ -0,0 +1,429 @@
+, CSSListItem>
+ *
+ * @internal since 8.8.0
+ */
+ protected $contents = [];
+
+ /**
+ * @param int<0, max> $lineNumber
+ */
+ public function __construct(int $lineNumber = 0)
+ {
+ $this->setPosition($lineNumber);
+ }
+
+ /**
+ * @throws UnexpectedTokenException
+ * @throws SourceException
+ *
+ * @internal since V8.8.0
+ */
+ public static function parseList(ParserState $parserState, CSSList $list): void
+ {
+ $isRoot = $list instanceof Document;
+ if (\is_string($parserState)) {
+ $parserState = new ParserState($parserState, Settings::create());
+ }
+ $usesLenientParsing = $parserState->getSettings()->usesLenientParsing();
+ $comments = [];
+ while (!$parserState->isEnd()) {
+ $comments = \array_merge($comments, $parserState->consumeWhiteSpace());
+ $listItem = null;
+ if ($usesLenientParsing) {
+ try {
+ $listItem = self::parseListItem($parserState, $list);
+ } catch (UnexpectedTokenException $e) {
+ $listItem = false;
+ }
+ } else {
+ $listItem = self::parseListItem($parserState, $list);
+ }
+ if ($listItem === null) {
+ // List parsing finished
+ return;
+ }
+ if ($listItem) {
+ $listItem->addComments($comments);
+ $list->append($listItem);
+ }
+ $comments = $parserState->consumeWhiteSpace();
+ }
+ $list->addComments($comments);
+ if (!$isRoot && !$usesLenientParsing) {
+ throw new SourceException('Unexpected end of document', $parserState->currentLine());
+ }
+ }
+
+ /**
+ * @return CSSListItem|false|null
+ * If `null` is returned, it means the end of the list has been reached.
+ * If `false` is returned, it means an invalid item has been encountered,
+ * but parsing of the next item should proceed.
+ *
+ * @throws SourceException
+ * @throws UnexpectedEOFException
+ * @throws UnexpectedTokenException
+ */
+ private static function parseListItem(ParserState $parserState, CSSList $list)
+ {
+ $isRoot = $list instanceof Document;
+ if ($parserState->comes('@')) {
+ $atRule = self::parseAtRule($parserState);
+ if ($atRule instanceof Charset) {
+ if (!$isRoot) {
+ throw new UnexpectedTokenException(
+ '@charset may only occur in root document',
+ '',
+ 'custom',
+ $parserState->currentLine()
+ );
+ }
+ if (\count($list->getContents()) > 0) {
+ throw new UnexpectedTokenException(
+ '@charset must be the first parseable token in a document',
+ '',
+ 'custom',
+ $parserState->currentLine()
+ );
+ }
+ $parserState->setCharset($atRule->getCharset());
+ }
+ return $atRule;
+ } elseif ($parserState->comes('}')) {
+ if ($isRoot) {
+ if ($parserState->getSettings()->usesLenientParsing()) {
+ return DeclarationBlock::parse($parserState) ?? false;
+ } else {
+ throw new SourceException('Unopened {', $parserState->currentLine());
+ }
+ } else {
+ // End of list
+ return null;
+ }
+ } else {
+ return DeclarationBlock::parse($parserState, $list) ?? false;
+ }
+ }
+
+ /**
+ * @throws SourceException
+ * @throws UnexpectedTokenException
+ * @throws UnexpectedEOFException
+ */
+ private static function parseAtRule(ParserState $parserState): ?CSSListItem
+ {
+ $parserState->consume('@');
+ $identifier = $parserState->parseIdentifier();
+ $identifierLineNumber = $parserState->currentLine();
+ $parserState->consumeWhiteSpace();
+ if ($identifier === 'import') {
+ $location = URL::parse($parserState);
+ $parserState->consumeWhiteSpace();
+ $mediaQuery = null;
+ if (!$parserState->comes(';')) {
+ $mediaQuery = \trim($parserState->consumeUntil([';', ParserState::EOF]));
+ if ($mediaQuery === '') {
+ $mediaQuery = null;
+ }
+ }
+ $parserState->consumeUntil([';', ParserState::EOF], true, true);
+ return new Import($location, $mediaQuery, $identifierLineNumber);
+ } elseif ($identifier === 'charset') {
+ $charsetString = CSSString::parse($parserState);
+ $parserState->consumeWhiteSpace();
+ $parserState->consumeUntil([';', ParserState::EOF], true, true);
+ return new Charset($charsetString, $identifierLineNumber);
+ } elseif (self::identifierIs($identifier, 'keyframes')) {
+ $result = new KeyFrame($identifierLineNumber);
+ $result->setVendorKeyFrame($identifier);
+ $result->setAnimationName(\trim($parserState->consumeUntil('{', false, true)));
+ CSSList::parseList($parserState, $result);
+ if ($parserState->comes('}')) {
+ $parserState->consume('}');
+ }
+ return $result;
+ } elseif ($identifier === 'namespace') {
+ $prefix = null;
+ $url = Value::parsePrimitiveValue($parserState);
+ if (!$parserState->comes(';')) {
+ $prefix = $url;
+ $url = Value::parsePrimitiveValue($parserState);
+ }
+ $parserState->consumeUntil([';', ParserState::EOF], true, true);
+ if ($prefix !== null && !\is_string($prefix)) {
+ throw new UnexpectedTokenException('Wrong namespace prefix', $prefix, 'custom', $identifierLineNumber);
+ }
+ if (!($url instanceof CSSString || $url instanceof URL)) {
+ throw new UnexpectedTokenException(
+ 'Wrong namespace url of invalid type',
+ $url,
+ 'custom',
+ $identifierLineNumber
+ );
+ }
+ return new CSSNamespace($url, $prefix, $identifierLineNumber);
+ } else {
+ // Unknown other at rule (font-face or such)
+ $arguments = \trim($parserState->consumeUntil('{', false, true));
+ if (\substr_count($arguments, '(') != \substr_count($arguments, ')')) {
+ if ($parserState->getSettings()->usesLenientParsing()) {
+ return null;
+ } else {
+ throw new SourceException('Unmatched brace count in media query', $parserState->currentLine());
+ }
+ }
+ $useRuleSet = true;
+ foreach (\explode('/', AtRule::BLOCK_RULES) as $blockRuleName) {
+ if (self::identifierIs($identifier, $blockRuleName)) {
+ $useRuleSet = false;
+ break;
+ }
+ }
+ if ($useRuleSet) {
+ $atRule = new AtRuleSet($identifier, $arguments, $identifierLineNumber);
+ RuleSet::parseRuleSet($parserState, $atRule);
+ } else {
+ $atRule = new AtRuleBlockList($identifier, $arguments, $identifierLineNumber);
+ CSSList::parseList($parserState, $atRule);
+ if ($parserState->comes('}')) {
+ $parserState->consume('}');
+ }
+ }
+ return $atRule;
+ }
+ }
+
+ /**
+ * Tests an identifier for a given value. Since identifiers are all keywords, they can be vendor-prefixed.
+ * We need to check for these versions too.
+ */
+ private static function identifierIs(string $identifier, string $match): bool
+ {
+ return (\strcasecmp($identifier, $match) === 0)
+ ?: \preg_match("/^(-\\w+-)?$match$/i", $identifier) === 1;
+ }
+
+ /**
+ * Prepends an item to the list of contents.
+ */
+ public function prepend(CSSListItem $item): void
+ {
+ \array_unshift($this->contents, $item);
+ }
+
+ /**
+ * Appends an item to the list of contents.
+ */
+ public function append(CSSListItem $item): void
+ {
+ $this->contents[] = $item;
+ }
+
+ /**
+ * Splices the list of contents.
+ *
+ * @param array $replacement
+ */
+ public function splice(int $offset, ?int $length = null, ?array $replacement = null): void
+ {
+ \array_splice($this->contents, $offset, $length, $replacement);
+ }
+
+ /**
+ * Inserts an item in the CSS list before its sibling. If the desired sibling cannot be found,
+ * the item is appended at the end.
+ */
+ public function insertBefore(CSSListItem $item, CSSListItem $sibling): void
+ {
+ if (\in_array($sibling, $this->contents, true)) {
+ $this->replace($sibling, [$item, $sibling]);
+ } else {
+ $this->append($item);
+ }
+ }
+
+ /**
+ * Removes an item from the CSS list.
+ *
+ * @param CSSListItem $itemToRemove
+ * May be a `RuleSet` (most likely a `DeclarationBlock`), an `Import`,
+ * a `Charset` or another `CSSList` (most likely a `MediaQuery`)
+ *
+ * @return bool whether the item was removed
+ */
+ public function remove(CSSListItem $itemToRemove): bool
+ {
+ $key = \array_search($itemToRemove, $this->contents, true);
+ if ($key !== false) {
+ unset($this->contents[$key]);
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Replaces an item from the CSS list.
+ *
+ * @param CSSListItem $oldItem
+ * May be a `RuleSet` (most likely a `DeclarationBlock`), an `Import`, a `Charset`
+ * or another `CSSList` (most likely a `MediaQuery`)
+ * @param CSSListItem|array $newItem
+ */
+ public function replace(CSSListItem $oldItem, $newItem): bool
+ {
+ $key = \array_search($oldItem, $this->contents, true);
+ if ($key !== false) {
+ if (\is_array($newItem)) {
+ \array_splice($this->contents, $key, 1, $newItem);
+ } else {
+ \array_splice($this->contents, $key, 1, [$newItem]);
+ }
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param array $contents
+ */
+ public function setContents(array $contents): void
+ {
+ $this->contents = [];
+ foreach ($contents as $content) {
+ $this->append($content);
+ }
+ }
+
+ /**
+ * Removes a declaration block from the CSS list if it matches all given selectors.
+ *
+ * @param DeclarationBlock|array|string $selectors the selectors to match
+ * @param bool $removeAll whether to stop at the first declaration block found or remove all blocks
+ */
+ public function removeDeclarationBlockBySelector($selectors, bool $removeAll = false): void
+ {
+ if ($selectors instanceof DeclarationBlock) {
+ $selectors = $selectors->getSelectors();
+ }
+ if (!\is_array($selectors)) {
+ $selectors = \explode(',', $selectors);
+ }
+ foreach ($selectors as $key => &$selector) {
+ if (!($selector instanceof Selector)) {
+ if (!Selector::isValid($selector)) {
+ throw new UnexpectedTokenException(
+ "Selector did not match '" . Selector::SELECTOR_VALIDATION_RX . "'.",
+ $selector,
+ 'custom'
+ );
+ }
+ $selector = new Selector($selector);
+ }
+ }
+ foreach ($this->contents as $key => $item) {
+ if (!($item instanceof DeclarationBlock)) {
+ continue;
+ }
+ if ($item->getSelectors() == $selectors) {
+ unset($this->contents[$key]);
+ if (!$removeAll) {
+ return;
+ }
+ }
+ }
+ }
+
+ protected function renderListContents(OutputFormat $outputFormat): string
+ {
+ $result = '';
+ $isFirst = true;
+ $nextLevelFormat = $outputFormat;
+ if (!$this->isRootList()) {
+ $nextLevelFormat = $outputFormat->nextLevel();
+ }
+ $nextLevelFormatter = $nextLevelFormat->getFormatter();
+ $formatter = $outputFormat->getFormatter();
+ foreach ($this->contents as $listItem) {
+ $renderedCss = $formatter->safely(static function () use ($nextLevelFormat, $listItem): string {
+ return $listItem->render($nextLevelFormat);
+ });
+ if ($renderedCss === null) {
+ continue;
+ }
+ if ($isFirst) {
+ $isFirst = false;
+ $result .= $nextLevelFormatter->spaceBeforeBlocks();
+ } else {
+ $result .= $nextLevelFormatter->spaceBetweenBlocks();
+ }
+ $result .= $renderedCss;
+ }
+
+ if (!$isFirst) {
+ // Had some output
+ $result .= $formatter->spaceAfterBlocks();
+ }
+
+ return $result;
+ }
+
+ /**
+ * Return true if the list can not be further outdented. Only important when rendering.
+ */
+ abstract public function isRootList(): bool;
+
+ /**
+ * Returns the stored items.
+ *
+ * @return array, CSSListItem>
+ */
+ public function getContents(): array
+ {
+ return $this->contents;
+ }
+}
diff --git a/src/CSSList/CSSListItem.php b/src/CSSList/CSSListItem.php
new file mode 100644
index 000000000..3cf2509b6
--- /dev/null
+++ b/src/CSSList/CSSListItem.php
@@ -0,0 +1,18 @@
+currentLine());
+ CSSList::parseList($parserState, $document);
+
+ return $document;
+ }
+
+ /**
+ * Returns all `Selector` objects with the requested specificity found recursively in the tree.
+ *
+ * Note that this does not yield the full `DeclarationBlock` that the selector belongs to
+ * (and, currently, there is no way to get to that).
+ *
+ * @param string|null $specificitySearch
+ * An optional filter by specificity.
+ * May contain a comparison operator and a number or just a number (defaults to "==").
+ *
+ * @return list
+ *
+ * @example `getSelectorsBySpecificity('>= 100')`
+ */
+ public function getSelectorsBySpecificity(?string $specificitySearch = null): array
+ {
+ return $this->getAllSelectors($specificitySearch);
+ }
+
+ /**
+ * Overrides `render()` to make format argument optional.
+ */
+ public function render(?OutputFormat $outputFormat = null): string
+ {
+ if ($outputFormat === null) {
+ $outputFormat = new OutputFormat();
+ }
+ return $outputFormat->getFormatter()->comments($this) . $this->renderListContents($outputFormat);
+ }
+
+ public function isRootList(): bool
+ {
+ return true;
+ }
+}
diff --git a/src/CSSList/KeyFrame.php b/src/CSSList/KeyFrame.php
new file mode 100644
index 000000000..e632d088b
--- /dev/null
+++ b/src/CSSList/KeyFrame.php
@@ -0,0 +1,87 @@
+vendorKeyFrame = $vendorKeyFrame;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function getVendorKeyFrame(): string
+ {
+ return $this->vendorKeyFrame;
+ }
+
+ /**
+ * @param non-empty-string $animationName
+ */
+ public function setAnimationName(string $animationName): void
+ {
+ $this->animationName = $animationName;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function getAnimationName(): string
+ {
+ return $this->animationName;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function render(OutputFormat $outputFormat): string
+ {
+ $formatter = $outputFormat->getFormatter();
+ $result = $formatter->comments($this);
+ $result .= "@{$this->vendorKeyFrame} {$this->animationName}{$formatter->spaceBeforeOpeningBrace()}{";
+ $result .= $this->renderListContents($outputFormat);
+ $result .= '}';
+ return $result;
+ }
+
+ public function isRootList(): bool
+ {
+ return false;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function atRuleName(): string
+ {
+ return $this->vendorKeyFrame;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function atRuleArgs(): string
+ {
+ return $this->animationName;
+ }
+}
diff --git a/src/Comment/Comment.php b/src/Comment/Comment.php
new file mode 100644
index 000000000..7a56624c4
--- /dev/null
+++ b/src/Comment/Comment.php
@@ -0,0 +1,49 @@
+ $lineNumber
+ */
+ public function __construct(string $commentText = '', int $lineNumber = 0)
+ {
+ $this->commentText = $commentText;
+ $this->setPosition($lineNumber);
+ }
+
+ public function getComment(): string
+ {
+ return $this->commentText;
+ }
+
+ public function setComment(string $commentText): void
+ {
+ $this->commentText = $commentText;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function render(OutputFormat $outputFormat): string
+ {
+ return '/*' . $this->commentText . '*/';
+ }
+}
diff --git a/src/Comment/CommentContainer.php b/src/Comment/CommentContainer.php
new file mode 100644
index 000000000..87f6ff46c
--- /dev/null
+++ b/src/Comment/CommentContainer.php
@@ -0,0 +1,44 @@
+
+ */
+ protected $comments = [];
+
+ /**
+ * @param list $comments
+ */
+ public function addComments(array $comments): void
+ {
+ $this->comments = \array_merge($this->comments, $comments);
+ }
+
+ /**
+ * @return list
+ */
+ public function getComments(): array
+ {
+ return $this->comments;
+ }
+
+ /**
+ * @param list $comments
+ */
+ public function setComments(array $comments): void
+ {
+ $this->comments = $comments;
+ }
+}
diff --git a/src/Comment/Commentable.php b/src/Comment/Commentable.php
new file mode 100644
index 000000000..5f28021de
--- /dev/null
+++ b/src/Comment/Commentable.php
@@ -0,0 +1,26 @@
+ $comments
+ */
+ public function addComments(array $comments): void;
+
+ /**
+ * @return list
+ */
+ public function getComments(): array;
+
+ /**
+ * @param list $comments
+ */
+ public function setComments(array $comments): void;
+}
diff --git a/src/OutputFormat.php b/src/OutputFormat.php
new file mode 100644
index 000000000..6ad45aa40
--- /dev/null
+++ b/src/OutputFormat.php
@@ -0,0 +1,752 @@
+set('Space*Rules', "\n");`)
+ *
+ * @var string
+ */
+ private $spaceAfterRuleName = ' ';
+
+ /**
+ * @var string
+ */
+ private $spaceBeforeRules = '';
+
+ /**
+ * @var string
+ */
+ private $spaceAfterRules = '';
+
+ /**
+ * @var string
+ */
+ private $spaceBetweenRules = '';
+
+ /**
+ * @var string
+ */
+ private $spaceBeforeBlocks = '';
+
+ /**
+ * @var string
+ */
+ private $spaceAfterBlocks = '';
+
+ /**
+ * @var string
+ */
+ private $spaceBetweenBlocks = "\n";
+
+ /**
+ * Content injected in and around at-rule blocks.
+ *
+ * @var string
+ */
+ private $contentBeforeAtRuleBlock = '';
+
+ /**
+ * @var string
+ */
+ private $contentAfterAtRuleBlock = '';
+
+ /**
+ * This is what’s printed before and after the comma if a declaration block contains multiple selectors.
+ *
+ * @var string
+ */
+ private $spaceBeforeSelectorSeparator = '';
+
+ /**
+ * @var string
+ */
+ private $spaceAfterSelectorSeparator = ' ';
+
+ /**
+ * This is what’s inserted before the separator in value lists, by default.
+ *
+ * @var string
+ */
+ private $spaceBeforeListArgumentSeparator = '';
+
+ /**
+ * Keys are separators (e.g. `,`). Values are the space sequence to insert, or an empty string.
+ *
+ * @var array
+ */
+ private $spaceBeforeListArgumentSeparators = [];
+
+ /**
+ * This is what’s inserted after the separator in value lists, by default.
+ *
+ * @var string
+ */
+ private $spaceAfterListArgumentSeparator = '';
+
+ /**
+ * Keys are separators (e.g. `,`). Values are the space sequence to insert, or an empty string.
+ *
+ * @var array
+ */
+ private $spaceAfterListArgumentSeparators = [];
+
+ /**
+ * @var string
+ */
+ private $spaceBeforeOpeningBrace = ' ';
+
+ /**
+ * Content injected in and around declaration blocks.
+ *
+ * @var string
+ */
+ private $contentBeforeDeclarationBlock = '';
+
+ /**
+ * @var string
+ */
+ private $contentAfterDeclarationBlockSelectors = '';
+
+ /**
+ * @var string
+ */
+ private $contentAfterDeclarationBlock = '';
+
+ /**
+ * Indentation character(s) per level. Only applicable if newlines are used in any of the spacing settings.
+ *
+ * @var string
+ */
+ private $indentation = "\t";
+
+ /**
+ * Output exceptions.
+ *
+ * @var bool
+ */
+ private $shouldIgnoreExceptions = false;
+
+ /**
+ * Render comments for lists and RuleSets
+ *
+ * @var bool
+ */
+ private $shouldRenderComments = false;
+
+ /**
+ * @var OutputFormatter|null
+ */
+ private $outputFormatter;
+
+ /**
+ * @var OutputFormat|null
+ */
+ private $nextLevelFormat;
+
+ /**
+ * @var int<0, max>
+ */
+ private $indentationLevel = 0;
+
+ /**
+ * @return non-empty-string
+ *
+ * @internal
+ */
+ public function getStringQuotingType(): string
+ {
+ return $this->stringQuotingType;
+ }
+
+ /**
+ * @param non-empty-string $quotingType
+ *
+ * @return $this fluent interface
+ */
+ public function setStringQuotingType(string $quotingType): self
+ {
+ $this->stringQuotingType = $quotingType;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function usesRgbHashNotation(): bool
+ {
+ return $this->usesRgbHashNotation;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setRGBHashNotation(bool $usesRgbHashNotation): self
+ {
+ $this->usesRgbHashNotation = $usesRgbHashNotation;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function shouldRenderSemicolonAfterLastRule(): bool
+ {
+ return $this->renderSemicolonAfterLastRule;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setSemicolonAfterLastRule(bool $renderSemicolonAfterLastRule): self
+ {
+ $this->renderSemicolonAfterLastRule = $renderSemicolonAfterLastRule;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function getSpaceAfterRuleName(): string
+ {
+ return $this->spaceAfterRuleName;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setSpaceAfterRuleName(string $whitespace): self
+ {
+ $this->spaceAfterRuleName = $whitespace;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function getSpaceBeforeRules(): string
+ {
+ return $this->spaceBeforeRules;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setSpaceBeforeRules(string $whitespace): self
+ {
+ $this->spaceBeforeRules = $whitespace;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function getSpaceAfterRules(): string
+ {
+ return $this->spaceAfterRules;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setSpaceAfterRules(string $whitespace): self
+ {
+ $this->spaceAfterRules = $whitespace;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function getSpaceBetweenRules(): string
+ {
+ return $this->spaceBetweenRules;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setSpaceBetweenRules(string $whitespace): self
+ {
+ $this->spaceBetweenRules = $whitespace;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function getSpaceBeforeBlocks(): string
+ {
+ return $this->spaceBeforeBlocks;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setSpaceBeforeBlocks(string $whitespace): self
+ {
+ $this->spaceBeforeBlocks = $whitespace;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function getSpaceAfterBlocks(): string
+ {
+ return $this->spaceAfterBlocks;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setSpaceAfterBlocks(string $whitespace): self
+ {
+ $this->spaceAfterBlocks = $whitespace;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function getSpaceBetweenBlocks(): string
+ {
+ return $this->spaceBetweenBlocks;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setSpaceBetweenBlocks(string $whitespace): self
+ {
+ $this->spaceBetweenBlocks = $whitespace;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function getContentBeforeAtRuleBlock(): string
+ {
+ return $this->contentBeforeAtRuleBlock;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setBeforeAtRuleBlock(string $content): self
+ {
+ $this->contentBeforeAtRuleBlock = $content;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function getContentAfterAtRuleBlock(): string
+ {
+ return $this->contentAfterAtRuleBlock;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setAfterAtRuleBlock(string $content): self
+ {
+ $this->contentAfterAtRuleBlock = $content;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function getSpaceBeforeSelectorSeparator(): string
+ {
+ return $this->spaceBeforeSelectorSeparator;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setSpaceBeforeSelectorSeparator(string $whitespace): self
+ {
+ $this->spaceBeforeSelectorSeparator = $whitespace;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function getSpaceAfterSelectorSeparator(): string
+ {
+ return $this->spaceAfterSelectorSeparator;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setSpaceAfterSelectorSeparator(string $whitespace): self
+ {
+ $this->spaceAfterSelectorSeparator = $whitespace;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function getSpaceBeforeListArgumentSeparator(): string
+ {
+ return $this->spaceBeforeListArgumentSeparator;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setSpaceBeforeListArgumentSeparator(string $whitespace): self
+ {
+ $this->spaceBeforeListArgumentSeparator = $whitespace;
+
+ return $this;
+ }
+
+ /**
+ * @return array
+ *
+ * @internal
+ */
+ public function getSpaceBeforeListArgumentSeparators(): array
+ {
+ return $this->spaceBeforeListArgumentSeparators;
+ }
+
+ /**
+ * @param array $separatorSpaces
+ *
+ * @return $this fluent interface
+ */
+ public function setSpaceBeforeListArgumentSeparators(array $separatorSpaces): self
+ {
+ $this->spaceBeforeListArgumentSeparators = $separatorSpaces;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function getSpaceAfterListArgumentSeparator(): string
+ {
+ return $this->spaceAfterListArgumentSeparator;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setSpaceAfterListArgumentSeparator(string $whitespace): self
+ {
+ $this->spaceAfterListArgumentSeparator = $whitespace;
+
+ return $this;
+ }
+
+ /**
+ * @return array
+ *
+ * @internal
+ */
+ public function getSpaceAfterListArgumentSeparators(): array
+ {
+ return $this->spaceAfterListArgumentSeparators;
+ }
+
+ /**
+ * @param array $separatorSpaces
+ *
+ * @return $this fluent interface
+ */
+ public function setSpaceAfterListArgumentSeparators(array $separatorSpaces): self
+ {
+ $this->spaceAfterListArgumentSeparators = $separatorSpaces;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function getSpaceBeforeOpeningBrace(): string
+ {
+ return $this->spaceBeforeOpeningBrace;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setSpaceBeforeOpeningBrace(string $whitespace): self
+ {
+ $this->spaceBeforeOpeningBrace = $whitespace;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function getContentBeforeDeclarationBlock(): string
+ {
+ return $this->contentBeforeDeclarationBlock;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setBeforeDeclarationBlock(string $content): self
+ {
+ $this->contentBeforeDeclarationBlock = $content;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function getContentAfterDeclarationBlockSelectors(): string
+ {
+ return $this->contentAfterDeclarationBlockSelectors;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setAfterDeclarationBlockSelectors(string $content): self
+ {
+ $this->contentAfterDeclarationBlockSelectors = $content;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function getContentAfterDeclarationBlock(): string
+ {
+ return $this->contentAfterDeclarationBlock;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setAfterDeclarationBlock(string $content): self
+ {
+ $this->contentAfterDeclarationBlock = $content;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function getIndentation(): string
+ {
+ return $this->indentation;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setIndentation(string $indentation): self
+ {
+ $this->indentation = $indentation;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function shouldIgnoreExceptions(): bool
+ {
+ return $this->shouldIgnoreExceptions;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setIgnoreExceptions(bool $ignoreExceptions): self
+ {
+ $this->shouldIgnoreExceptions = $ignoreExceptions;
+
+ return $this;
+ }
+
+ /**
+ * @internal
+ */
+ public function shouldRenderComments(): bool
+ {
+ return $this->shouldRenderComments;
+ }
+
+ /**
+ * @return $this fluent interface
+ */
+ public function setRenderComments(bool $renderComments): self
+ {
+ $this->shouldRenderComments = $renderComments;
+
+ return $this;
+ }
+
+ /**
+ * @return int<0, max>
+ *
+ * @internal
+ */
+ public function getIndentationLevel(): int
+ {
+ return $this->indentationLevel;
+ }
+
+ /**
+ * @param int<1, max> $numberOfTabs
+ *
+ * @return $this fluent interface
+ */
+ public function indentWithTabs(int $numberOfTabs = 1): self
+ {
+ return $this->setIndentation(\str_repeat("\t", $numberOfTabs));
+ }
+
+ /**
+ * @param int<1, max> $numberOfSpaces
+ *
+ * @return $this fluent interface
+ */
+ public function indentWithSpaces(int $numberOfSpaces = 2): self
+ {
+ return $this->setIndentation(\str_repeat(' ', $numberOfSpaces));
+ }
+
+ /**
+ * @internal since V8.8.0
+ */
+ public function nextLevel(): self
+ {
+ if ($this->nextLevelFormat === null) {
+ $this->nextLevelFormat = clone $this;
+ $this->nextLevelFormat->indentationLevel++;
+ $this->nextLevelFormat->outputFormatter = null;
+ }
+ return $this->nextLevelFormat;
+ }
+
+ public function beLenient(): void
+ {
+ $this->shouldIgnoreExceptions = true;
+ }
+
+ /**
+ * @internal since 8.8.0
+ */
+ public function getFormatter(): OutputFormatter
+ {
+ if ($this->outputFormatter === null) {
+ $this->outputFormatter = new OutputFormatter($this);
+ }
+
+ return $this->outputFormatter;
+ }
+
+ /**
+ * Creates an instance of this class without any particular formatting settings.
+ */
+ public static function create(): self
+ {
+ return new OutputFormat();
+ }
+
+ /**
+ * Creates an instance of this class with a preset for compact formatting.
+ */
+ public static function createCompact(): self
+ {
+ $format = self::create();
+ $format
+ ->setSpaceBeforeRules('')
+ ->setSpaceBetweenRules('')
+ ->setSpaceAfterRules('')
+ ->setSpaceBeforeBlocks('')
+ ->setSpaceBetweenBlocks('')
+ ->setSpaceAfterBlocks('')
+ ->setSpaceAfterRuleName('')
+ ->setSpaceBeforeOpeningBrace('')
+ ->setSpaceAfterSelectorSeparator('')
+ ->setRenderComments(false);
+
+ return $format;
+ }
+
+ /**
+ * Creates an instance of this class with a preset for pretty formatting.
+ */
+ public static function createPretty(): self
+ {
+ $format = self::create();
+ $format
+ ->setSpaceBeforeRules("\n")
+ ->setSpaceBetweenRules("\n")
+ ->setSpaceAfterRules("\n")
+ ->setSpaceBeforeBlocks("\n")
+ ->setSpaceBetweenBlocks("\n\n")
+ ->setSpaceAfterBlocks("\n")
+ ->setSpaceAfterListArgumentSeparators([',' => ' '])
+ ->setRenderComments(true);
+
+ return $format;
+ }
+}
diff --git a/src/OutputFormatter.php b/src/OutputFormatter.php
new file mode 100644
index 000000000..09918c38d
--- /dev/null
+++ b/src/OutputFormatter.php
@@ -0,0 +1,235 @@
+outputFormat = $outputFormat;
+ }
+
+ /**
+ * @param non-empty-string $name
+ *
+ * @throws \InvalidArgumentException
+ */
+ public function space(string $name): string
+ {
+ switch ($name) {
+ case 'AfterRuleName':
+ $spaceString = $this->outputFormat->getSpaceAfterRuleName();
+ break;
+ case 'BeforeRules':
+ $spaceString = $this->outputFormat->getSpaceBeforeRules();
+ break;
+ case 'AfterRules':
+ $spaceString = $this->outputFormat->getSpaceAfterRules();
+ break;
+ case 'BetweenRules':
+ $spaceString = $this->outputFormat->getSpaceBetweenRules();
+ break;
+ case 'BeforeBlocks':
+ $spaceString = $this->outputFormat->getSpaceBeforeBlocks();
+ break;
+ case 'AfterBlocks':
+ $spaceString = $this->outputFormat->getSpaceAfterBlocks();
+ break;
+ case 'BetweenBlocks':
+ $spaceString = $this->outputFormat->getSpaceBetweenBlocks();
+ break;
+ case 'BeforeSelectorSeparator':
+ $spaceString = $this->outputFormat->getSpaceBeforeSelectorSeparator();
+ break;
+ case 'AfterSelectorSeparator':
+ $spaceString = $this->outputFormat->getSpaceAfterSelectorSeparator();
+ break;
+ case 'BeforeOpeningBrace':
+ $spaceString = $this->outputFormat->getSpaceBeforeOpeningBrace();
+ break;
+ case 'BeforeListArgumentSeparator':
+ $spaceString = $this->outputFormat->getSpaceBeforeListArgumentSeparator();
+ break;
+ case 'AfterListArgumentSeparator':
+ $spaceString = $this->outputFormat->getSpaceAfterListArgumentSeparator();
+ break;
+ default:
+ throw new \InvalidArgumentException("Unknown space type: $name", 1740049248);
+ }
+
+ return $this->prepareSpace($spaceString);
+ }
+
+ public function spaceAfterRuleName(): string
+ {
+ return $this->space('AfterRuleName');
+ }
+
+ public function spaceBeforeRules(): string
+ {
+ return $this->space('BeforeRules');
+ }
+
+ public function spaceAfterRules(): string
+ {
+ return $this->space('AfterRules');
+ }
+
+ public function spaceBetweenRules(): string
+ {
+ return $this->space('BetweenRules');
+ }
+
+ public function spaceBeforeBlocks(): string
+ {
+ return $this->space('BeforeBlocks');
+ }
+
+ public function spaceAfterBlocks(): string
+ {
+ return $this->space('AfterBlocks');
+ }
+
+ public function spaceBetweenBlocks(): string
+ {
+ return $this->space('BetweenBlocks');
+ }
+
+ public function spaceBeforeSelectorSeparator(): string
+ {
+ return $this->space('BeforeSelectorSeparator');
+ }
+
+ public function spaceAfterSelectorSeparator(): string
+ {
+ return $this->space('AfterSelectorSeparator');
+ }
+
+ /**
+ * @param non-empty-string $separator
+ */
+ public function spaceBeforeListArgumentSeparator(string $separator): string
+ {
+ $spaceForSeparator = $this->outputFormat->getSpaceBeforeListArgumentSeparators();
+
+ return $spaceForSeparator[$separator] ?? $this->space('BeforeListArgumentSeparator');
+ }
+
+ /**
+ * @param non-empty-string $separator
+ */
+ public function spaceAfterListArgumentSeparator(string $separator): string
+ {
+ $spaceForSeparator = $this->outputFormat->getSpaceAfterListArgumentSeparators();
+
+ return $spaceForSeparator[$separator] ?? $this->space('AfterListArgumentSeparator');
+ }
+
+ public function spaceBeforeOpeningBrace(): string
+ {
+ return $this->space('BeforeOpeningBrace');
+ }
+
+ /**
+ * Runs the given code, either swallowing or passing exceptions, depending on the `ignoreExceptions` setting.
+ */
+ public function safely(callable $callable): ?string
+ {
+ if ($this->outputFormat->shouldIgnoreExceptions()) {
+ // If output exceptions are ignored, run the code with exception guards
+ try {
+ return $callable();
+ } catch (OutputException $e) {
+ return null;
+ } // Do nothing
+ } else {
+ // Run the code as-is
+ return $callable();
+ }
+ }
+
+ /**
+ * Clone of the `implode` function, but calls `render` with the current output format.
+ *
+ * @param array $values
+ */
+ public function implode(string $separator, array $values, bool $increaseLevel = false): string
+ {
+ $result = '';
+ $outputFormat = $this->outputFormat;
+ if ($increaseLevel) {
+ $outputFormat = $outputFormat->nextLevel();
+ }
+ $isFirst = true;
+ foreach ($values as $value) {
+ if ($isFirst) {
+ $isFirst = false;
+ } else {
+ $result .= $separator;
+ }
+ if ($value instanceof Renderable) {
+ $result .= $value->render($outputFormat);
+ } else {
+ $result .= $value;
+ }
+ }
+ return $result;
+ }
+
+ public function removeLastSemicolon(string $string): string
+ {
+ if ($this->outputFormat->shouldRenderSemicolonAfterLastRule()) {
+ return $string;
+ }
+
+ $parts = \explode(';', $string);
+ if (\count($parts) < 2) {
+ return $parts[0];
+ }
+ $lastPart = \array_pop($parts);
+ $nextToLastPart = \array_pop($parts);
+ \array_push($parts, $nextToLastPart . $lastPart);
+
+ return \implode(';', $parts);
+ }
+
+ public function comments(Commentable $commentable): string
+ {
+ if (!$this->outputFormat->shouldRenderComments()) {
+ return '';
+ }
+
+ $result = '';
+ $comments = $commentable->getComments();
+ $lastCommentIndex = \count($comments) - 1;
+
+ foreach ($comments as $i => $comment) {
+ $result .= $comment->render($this->outputFormat);
+ $result .= $i === $lastCommentIndex ? $this->spaceAfterBlocks() : $this->spaceBetweenBlocks();
+ }
+ return $result;
+ }
+
+ private function prepareSpace(string $spaceString): string
+ {
+ return \str_replace("\n", "\n" . $this->indent(), $spaceString);
+ }
+
+ private function indent(): string
+ {
+ return \str_repeat($this->outputFormat->getIndentation(), $this->outputFormat->getIndentationLevel());
+ }
+}
diff --git a/src/Parser.php b/src/Parser.php
new file mode 100644
index 000000000..b34a5107c
--- /dev/null
+++ b/src/Parser.php
@@ -0,0 +1,42 @@
+ $lineNumber the line number (starting from 1, not from 0)
+ */
+ public function __construct(string $text, ?Settings $parserSettings = null, int $lineNumber = 1)
+ {
+ if ($parserSettings === null) {
+ $parserSettings = Settings::create();
+ }
+ $this->parserState = new ParserState($text, $parserSettings, $lineNumber);
+ }
+
+ /**
+ * Parses the CSS provided to the constructor and creates a `Document` from it.
+ *
+ * @throws SourceException
+ */
+ public function parse(): Document
+ {
+ return Document::parse($this->parserState);
+ }
+}
diff --git a/src/Parsing/Anchor.php b/src/Parsing/Anchor.php
new file mode 100644
index 000000000..c27f436ad
--- /dev/null
+++ b/src/Parsing/Anchor.php
@@ -0,0 +1,35 @@
+
+ */
+ private $position;
+
+ /**
+ * @var ParserState
+ */
+ private $parserState;
+
+ /**
+ * @param int<0, max> $position
+ */
+ public function __construct(int $position, ParserState $parserState)
+ {
+ $this->position = $position;
+ $this->parserState = $parserState;
+ }
+
+ public function backtrack(): void
+ {
+ $this->parserState->setPosition($this->position);
+ }
+}
diff --git a/src/Parsing/OutputException.php b/src/Parsing/OutputException.php
new file mode 100644
index 000000000..0a20dc967
--- /dev/null
+++ b/src/Parsing/OutputException.php
@@ -0,0 +1,10 @@
+
+ */
+ private $characters;
+
+ /**
+ * @var int<0, max>
+ */
+ private $currentPosition = 0;
+
+ /**
+ * will only be used if the CSS does not contain an `@charset` declaration
+ *
+ * @var string
+ */
+ private $charset;
+
+ /**
+ * @var int<1, max> $lineNumber
+ */
+ private $lineNumber;
+
+ /**
+ * @param string $text the complete CSS as text (i.e., usually the contents of a CSS file)
+ * @param int<1, max> $lineNumber
+ */
+ public function __construct(string $text, Settings $parserSettings, int $lineNumber = 1)
+ {
+ $this->parserSettings = $parserSettings;
+ $this->text = $text;
+ $this->lineNumber = $lineNumber;
+ $this->setCharset($this->parserSettings->getDefaultCharset());
+ }
+
+ /**
+ * Sets the charset to be used if the CSS does not contain an `@charset` declaration.
+ *
+ * @throws SourceException if the charset is UTF-8 and the content has invalid byte sequences
+ */
+ public function setCharset(string $charset): void
+ {
+ $this->charset = $charset;
+ $this->characters = $this->strsplit($this->text);
+ }
+
+ /**
+ * @return int<1, max>
+ */
+ public function currentLine(): int
+ {
+ return $this->lineNumber;
+ }
+
+ /**
+ * @return int<0, max>
+ */
+ public function currentColumn(): int
+ {
+ return $this->currentPosition;
+ }
+
+ public function getSettings(): Settings
+ {
+ return $this->parserSettings;
+ }
+
+ public function anchor(): Anchor
+ {
+ return new Anchor($this->currentPosition, $this);
+ }
+
+ /**
+ * @param int<0, max> $position
+ */
+ public function setPosition(int $position): void
+ {
+ $this->currentPosition = $position;
+ }
+
+ /**
+ * @return non-empty-string
+ *
+ * @throws UnexpectedTokenException
+ */
+ public function parseIdentifier(bool $ignoreCase = true): string
+ {
+ if ($this->isEnd()) {
+ throw new UnexpectedEOFException('', '', 'identifier', $this->lineNumber);
+ }
+ $result = $this->parseCharacter(true);
+ if ($result === null) {
+ throw new UnexpectedTokenException('', $this->peek(5), 'identifier', $this->lineNumber);
+ }
+ $character = null;
+ while (!$this->isEnd() && ($character = $this->parseCharacter(true)) !== null) {
+ if (\preg_match('/[a-zA-Z0-9\\x{00A0}-\\x{FFFF}_-]/Sux', $character)) {
+ $result .= $character;
+ } else {
+ $result .= '\\' . $character;
+ }
+ }
+ if ($ignoreCase) {
+ $result = $this->strtolower($result);
+ }
+
+ return $result;
+ }
+
+ /**
+ * @throws UnexpectedEOFException
+ * @throws UnexpectedTokenException
+ */
+ public function parseCharacter(bool $isForIdentifier): ?string
+ {
+ if ($this->peek() === '\\') {
+ $this->consume('\\');
+ if ($this->comes('\\n') || $this->comes('\\r')) {
+ return '';
+ }
+ if (\preg_match('/[0-9a-fA-F]/Su', $this->peek()) === 0) {
+ return $this->consume(1);
+ }
+ $hexCodePoint = $this->consumeExpression('/^[0-9a-fA-F]{1,6}/u', 6);
+ if ($this->strlen($hexCodePoint) < 6) {
+ // Consume whitespace after incomplete unicode escape
+ if (\preg_match('/\\s/isSu', $this->peek())) {
+ if ($this->comes('\\r\\n')) {
+ $this->consume(2);
+ } else {
+ $this->consume(1);
+ }
+ }
+ }
+ $codePoint = \intval($hexCodePoint, 16);
+ $utf32EncodedCharacter = '';
+ for ($i = 0; $i < 4; ++$i) {
+ $utf32EncodedCharacter .= \chr($codePoint & 0xff);
+ $codePoint = $codePoint >> 8;
+ }
+ return \iconv('utf-32le', $this->charset, $utf32EncodedCharacter);
+ }
+ if ($isForIdentifier) {
+ $peek = \ord($this->peek());
+ // Ranges: a-z A-Z 0-9 - _
+ if (
+ ($peek >= 97 && $peek <= 122)
+ || ($peek >= 65 && $peek <= 90)
+ || ($peek >= 48 && $peek <= 57)
+ || ($peek === 45)
+ || ($peek === 95)
+ || ($peek > 0xa1)
+ ) {
+ return $this->consume(1);
+ }
+ } else {
+ return $this->consume(1);
+ }
+
+ return null;
+ }
+
+ /**
+ * @return list
+ *
+ * @throws UnexpectedEOFException
+ * @throws UnexpectedTokenException
+ */
+ public function consumeWhiteSpace(): array
+ {
+ $comments = [];
+ do {
+ while (\preg_match('/\\s/isSu', $this->peek()) === 1) {
+ $this->consume(1);
+ }
+ if ($this->parserSettings->usesLenientParsing()) {
+ try {
+ $comment = $this->consumeComment();
+ } catch (UnexpectedEOFException $e) {
+ $this->currentPosition = \count($this->characters);
+ break;
+ }
+ } else {
+ $comment = $this->consumeComment();
+ }
+ if ($comment instanceof Comment) {
+ $comments[] = $comment;
+ }
+ } while ($comment instanceof Comment);
+
+ return $comments;
+ }
+
+ /**
+ * @param non-empty-string $string
+ */
+ public function comes(string $string, bool $caseInsensitive = false): bool
+ {
+ $peek = $this->peek(\strlen($string));
+
+ return ($peek !== '') && $this->streql($peek, $string, $caseInsensitive);
+ }
+
+ /**
+ * @param int<1, max> $length
+ * @param int<0, max> $offset
+ */
+ public function peek(int $length = 1, int $offset = 0): string
+ {
+ $offset += $this->currentPosition;
+ if ($offset >= \count($this->characters)) {
+ return '';
+ }
+
+ return $this->substr($offset, $length);
+ }
+
+ /**
+ * @param string|int<1, max> $value
+ *
+ * @throws UnexpectedEOFException
+ * @throws UnexpectedTokenException
+ */
+ public function consume($value = 1): string
+ {
+ if (\is_string($value)) {
+ $numberOfLines = \substr_count($value, "\n");
+ $length = $this->strlen($value);
+ if (!$this->streql($this->substr($this->currentPosition, $length), $value)) {
+ throw new UnexpectedTokenException(
+ $value,
+ $this->peek(\max($length, 5)),
+ 'literal',
+ $this->lineNumber
+ );
+ }
+
+ $this->lineNumber += $numberOfLines;
+ $this->currentPosition += $this->strlen($value);
+ $result = $value;
+ } else {
+ if ($this->currentPosition + $value > \count($this->characters)) {
+ throw new UnexpectedEOFException((string) $value, $this->peek(5), 'count', $this->lineNumber);
+ }
+
+ $result = $this->substr($this->currentPosition, $value);
+ $numberOfLines = \substr_count($result, "\n");
+ $this->lineNumber += $numberOfLines;
+ $this->currentPosition += $value;
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param string $expression
+ * @param int<1, max>|null $maximumLength
+ *
+ * @throws UnexpectedEOFException
+ * @throws UnexpectedTokenException
+ */
+ public function consumeExpression(string $expression, ?int $maximumLength = null): string
+ {
+ $matches = null;
+ $input = ($maximumLength !== null) ? $this->peek($maximumLength) : $this->inputLeft();
+ if (\preg_match($expression, $input, $matches, PREG_OFFSET_CAPTURE) !== 1) {
+ throw new UnexpectedTokenException($expression, $this->peek(5), 'expression', $this->lineNumber);
+ }
+
+ return $this->consume($matches[0][0]);
+ }
+
+ /**
+ * @return Comment|false
+ */
+ public function consumeComment()
+ {
+ $lineNumber = $this->lineNumber;
+ $comment = null;
+
+ if ($this->comes('/*')) {
+ $this->consume(1);
+ $comment = '';
+ while (($char = $this->consume(1)) !== '') {
+ $comment .= $char;
+ if ($this->comes('*/')) {
+ $this->consume(2);
+ break;
+ }
+ }
+ }
+
+ // We skip the * which was included in the comment.
+ return \is_string($comment) ? new Comment(\substr($comment, 1), $lineNumber) : false;
+ }
+
+ public function isEnd(): bool
+ {
+ return $this->currentPosition >= \count($this->characters);
+ }
+
+ /**
+ * @param list|string $stopCharacters
+ * @param array $comments
+ *
+ * @throws UnexpectedEOFException
+ * @throws UnexpectedTokenException
+ */
+ public function consumeUntil(
+ $stopCharacters,
+ bool $includeEnd = false,
+ bool $consumeEnd = false,
+ array &$comments = []
+ ): string {
+ $stopCharacters = \is_array($stopCharacters) ? $stopCharacters : [$stopCharacters];
+ $consumedCharacters = '';
+ $start = $this->currentPosition;
+
+ while (!$this->isEnd()) {
+ $character = $this->consume(1);
+ if (\in_array($character, $stopCharacters, true)) {
+ if ($includeEnd) {
+ $consumedCharacters .= $character;
+ } elseif (!$consumeEnd) {
+ $this->currentPosition -= $this->strlen($character);
+ }
+ return $consumedCharacters;
+ }
+ $consumedCharacters .= $character;
+ $comment = $this->consumeComment();
+ if ($comment instanceof Comment) {
+ $comments[] = $comment;
+ }
+ }
+
+ if (\in_array(self::EOF, $stopCharacters, true)) {
+ return $consumedCharacters;
+ }
+
+ $this->currentPosition = $start;
+ throw new UnexpectedEOFException(
+ 'One of ("' . \implode('","', $stopCharacters) . '")',
+ $this->peek(5),
+ 'search',
+ $this->lineNumber
+ );
+ }
+
+ private function inputLeft(): string
+ {
+ return $this->substr($this->currentPosition, -1);
+ }
+
+ public function streql(string $string1, string $string2, bool $caseInsensitive = true): bool
+ {
+ return $caseInsensitive
+ ? ($this->strtolower($string1) === $this->strtolower($string2))
+ : ($string1 === $string2);
+ }
+
+ /**
+ * @param int<1, max> $numberOfCharacters
+ */
+ public function backtrack(int $numberOfCharacters): void
+ {
+ $this->currentPosition -= $numberOfCharacters;
+ }
+
+ /**
+ * @return int<0, max>
+ */
+ public function strlen(string $string): int
+ {
+ return $this->parserSettings->hasMultibyteSupport()
+ ? \mb_strlen($string, $this->charset)
+ : \strlen($string);
+ }
+
+ /**
+ * @param int<0, max> $offset
+ */
+ private function substr(int $offset, int $length): string
+ {
+ if ($length < 0) {
+ $length = \count($this->characters) - $offset + $length;
+ }
+ if ($offset + $length > \count($this->characters)) {
+ $length = \count($this->characters) - $offset;
+ }
+ $result = '';
+ while ($length > 0) {
+ $result .= $this->characters[$offset];
+ $offset++;
+ $length--;
+ }
+
+ return $result;
+ }
+
+ /**
+ * @return ($string is non-empty-string ? non-empty-string : string)
+ */
+ private function strtolower(string $string): string
+ {
+ return $this->parserSettings->hasMultibyteSupport()
+ ? \mb_strtolower($string, $this->charset)
+ : \strtolower($string);
+ }
+
+ /**
+ * @return list
+ *
+ * @throws SourceException if the charset is UTF-8 and the string contains invalid byte sequences
+ */
+ private function strsplit(string $string): array
+ {
+ if ($this->parserSettings->hasMultibyteSupport()) {
+ if ($this->streql($this->charset, 'utf-8')) {
+ $result = \preg_split('//u', $string, -1, PREG_SPLIT_NO_EMPTY);
+ if (!\is_array($result)) {
+ throw new SourceException('`preg_split` failed with error ' . \preg_last_error());
+ }
+ } else {
+ $length = \mb_strlen($string, $this->charset);
+ $result = [];
+ for ($i = 0; $i < $length; ++$i) {
+ $result[] = \mb_substr($string, $i, 1, $this->charset);
+ }
+ }
+ } else {
+ $result = ($string !== '') ? \str_split($string) : [];
+ }
+
+ return $result;
+ }
+}
diff --git a/src/Parsing/SourceException.php b/src/Parsing/SourceException.php
new file mode 100644
index 000000000..ca07cc48d
--- /dev/null
+++ b/src/Parsing/SourceException.php
@@ -0,0 +1,25 @@
+ $lineNumber
+ */
+ public function __construct(string $message, int $lineNumber = 0)
+ {
+ $this->setPosition($lineNumber);
+ if ($lineNumber !== 0) {
+ $message .= " [line no: $lineNumber]";
+ }
+ parent::__construct($message);
+ }
+}
diff --git a/src/Parsing/UnexpectedEOFException.php b/src/Parsing/UnexpectedEOFException.php
new file mode 100644
index 000000000..17e2a2152
--- /dev/null
+++ b/src/Parsing/UnexpectedEOFException.php
@@ -0,0 +1,12 @@
+ $lineNumber
+ */
+ public function __construct(string $expected, string $found, string $matchType = 'literal', int $lineNumber = 0)
+ {
+ $message = "Token “{$expected}” ({$matchType}) not found. Got “{$found}”.";
+ if ($matchType === 'search') {
+ $message = "Search for “{$expected}” returned no results. Context: “{$found}”.";
+ } elseif ($matchType === 'count') {
+ $message = "Next token was expected to have {$expected} chars. Context: “{$found}”.";
+ } elseif ($matchType === 'identifier') {
+ $message = "Identifier expected. Got “{$found}”";
+ } elseif ($matchType === 'custom') {
+ $message = \trim("$expected $found");
+ }
+
+ parent::__construct($message, $lineNumber);
+ }
+}
diff --git a/src/Position/Position.php b/src/Position/Position.php
new file mode 100644
index 000000000..0771453b4
--- /dev/null
+++ b/src/Position/Position.php
@@ -0,0 +1,72 @@
+|null
+ */
+ protected $lineNumber;
+
+ /**
+ * @var int<0, max>|null
+ */
+ protected $columnNumber;
+
+ /**
+ * @return int<1, max>|null
+ */
+ public function getLineNumber(): ?int
+ {
+ return $this->lineNumber;
+ }
+
+ /**
+ * @return int<0, max>
+ */
+ public function getLineNo(): int
+ {
+ return $this->getLineNumber() ?? 0;
+ }
+
+ /**
+ * @return int<0, max>|null
+ */
+ public function getColumnNumber(): ?int
+ {
+ return $this->columnNumber;
+ }
+
+ /**
+ * @return int<0, max>
+ */
+ public function getColNo(): int
+ {
+ return $this->getColumnNumber() ?? 0;
+ }
+
+ /**
+ * @param int<0, max>|null $lineNumber
+ * @param int<0, max>|null $columnNumber
+ *
+ * @return $this fluent interface
+ */
+ public function setPosition(?int $lineNumber, ?int $columnNumber = null): Positionable
+ {
+ // The conditional is for backwards compatibility (backcompat); `0` will not be allowed in future.
+ $this->lineNumber = $lineNumber !== 0 ? $lineNumber : null;
+ $this->columnNumber = $columnNumber;
+
+ return $this;
+ }
+}
diff --git a/src/Position/Positionable.php b/src/Position/Positionable.php
new file mode 100644
index 000000000..675fb55fb
--- /dev/null
+++ b/src/Position/Positionable.php
@@ -0,0 +1,47 @@
+|null
+ */
+ public function getLineNumber(): ?int;
+
+ /**
+ * @return int<0, max>
+ *
+ * @deprecated in version 8.9.0, will be removed in v9.0. Use `getLineNumber()` instead.
+ */
+ public function getLineNo(): int;
+
+ /**
+ * @return int<0, max>|null
+ */
+ public function getColumnNumber(): ?int;
+
+ /**
+ * @return int<0, max>
+ *
+ * @deprecated in version 8.9.0, will be removed in v9.0. Use `getColumnNumber()` instead.
+ */
+ public function getColNo(): int;
+
+ /**
+ * @param int<0, max>|null $lineNumber
+ * Providing zero for this parameter is deprecated in version 8.9.0, and will not be supported from v9.0.
+ * Use `null` instead when no line number is available.
+ * @param int<0, max>|null $columnNumber
+ *
+ * @return $this fluent interface
+ */
+ public function setPosition(?int $lineNumber, ?int $columnNumber = null): Positionable;
+}
diff --git a/src/Property/AtRule.php b/src/Property/AtRule.php
new file mode 100644
index 000000000..49a160a1a
--- /dev/null
+++ b/src/Property/AtRule.php
@@ -0,0 +1,29 @@
+ $lineNumber
+ */
+ public function __construct($url, ?string $prefix = null, int $lineNumber = 0)
+ {
+ $this->url = $url;
+ $this->prefix = $prefix;
+ $this->setPosition($lineNumber);
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function render(OutputFormat $outputFormat): string
+ {
+ return '@namespace ' . ($this->prefix === null ? '' : $this->prefix . ' ')
+ . $this->url->render($outputFormat) . ';';
+ }
+
+ /**
+ * @return CSSString|URL
+ */
+ public function getUrl()
+ {
+ return $this->url;
+ }
+
+ public function getPrefix(): ?string
+ {
+ return $this->prefix;
+ }
+
+ /**
+ * @param CSSString|URL $url
+ */
+ public function setUrl($url): void
+ {
+ $this->url = $url;
+ }
+
+ public function setPrefix(string $prefix): void
+ {
+ $this->prefix = $prefix;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function atRuleName(): string
+ {
+ return 'namespace';
+ }
+
+ /**
+ * @return array{0: CSSString|URL|non-empty-string, 1?: CSSString|URL}
+ */
+ public function atRuleArgs(): array
+ {
+ $result = [$this->url];
+ if (\is_string($this->prefix) && $this->prefix !== '') {
+ \array_unshift($result, $this->prefix);
+ }
+ return $result;
+ }
+}
diff --git a/src/Property/Charset.php b/src/Property/Charset.php
new file mode 100644
index 000000000..90e7d5fbd
--- /dev/null
+++ b/src/Property/Charset.php
@@ -0,0 +1,74 @@
+ $lineNumber
+ */
+ public function __construct(CSSString $charset, int $lineNumber = 0)
+ {
+ $this->charset = $charset;
+ $this->setPosition($lineNumber);
+ }
+
+ /**
+ * @param string|CSSString $charset
+ */
+ public function setCharset($charset): void
+ {
+ $charset = $charset instanceof CSSString ? $charset : new CSSString($charset);
+ $this->charset = $charset;
+ }
+
+ public function getCharset(): string
+ {
+ return $this->charset->getString();
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function render(OutputFormat $outputFormat): string
+ {
+ return "{$outputFormat->getFormatter()->comments($this)}@charset {$this->charset->render($outputFormat)};";
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function atRuleName(): string
+ {
+ return 'charset';
+ }
+
+ public function atRuleArgs(): CSSString
+ {
+ return $this->charset;
+ }
+}
diff --git a/src/Property/Import.php b/src/Property/Import.php
new file mode 100644
index 000000000..51c0e4ea8
--- /dev/null
+++ b/src/Property/Import.php
@@ -0,0 +1,85 @@
+ $lineNumber
+ */
+ public function __construct(URL $location, ?string $mediaQuery, int $lineNumber = 0)
+ {
+ $this->location = $location;
+ $this->mediaQuery = $mediaQuery;
+ $this->setPosition($lineNumber);
+ }
+
+ public function setLocation(URL $location): void
+ {
+ $this->location = $location;
+ }
+
+ public function getLocation(): URL
+ {
+ return $this->location;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function render(OutputFormat $outputFormat): string
+ {
+ return $outputFormat->getFormatter()->comments($this) . '@import ' . $this->location->render($outputFormat)
+ . ($this->mediaQuery === null ? '' : ' ' . $this->mediaQuery) . ';';
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function atRuleName(): string
+ {
+ return 'import';
+ }
+
+ /**
+ * @return array{0: URL, 1?: non-empty-string}
+ */
+ public function atRuleArgs(): array
+ {
+ $result = [$this->location];
+ if (\is_string($this->mediaQuery) && $this->mediaQuery !== '') {
+ $result[] = $this->mediaQuery;
+ }
+
+ return $result;
+ }
+
+ public function getMediaQuery(): ?string
+ {
+ return $this->mediaQuery;
+ }
+}
diff --git a/src/Property/KeyframeSelector.php b/src/Property/KeyframeSelector.php
new file mode 100644
index 000000000..2ab8ca977
--- /dev/null
+++ b/src/Property/KeyframeSelector.php
@@ -0,0 +1,27 @@
+]* # any sequence of valid unescaped characters
+ (?:\\\\.)? # a single escaped character
+ (?:([\'"]).*?(?]* # any sequence of valid unescaped characters
+ (?:\\\\.)? # a single escaped character
+ (?:([\'"]).*?(?setSelector($selector);
+ }
+
+ public function getSelector(): string
+ {
+ return $this->selector;
+ }
+
+ public function setSelector(string $selector): void
+ {
+ $this->selector = \trim($selector);
+ }
+
+ /**
+ * @return int<0, max>
+ */
+ public function getSpecificity(): int
+ {
+ return SpecificityCalculator::calculate($this->selector);
+ }
+
+ public function render(OutputFormat $outputFormat): string
+ {
+ return $this->getSelector();
+ }
+}
diff --git a/src/Property/Selector/SpecificityCalculator.php b/src/Property/Selector/SpecificityCalculator.php
new file mode 100644
index 000000000..745f229d2
--- /dev/null
+++ b/src/Property/Selector/SpecificityCalculator.php
@@ -0,0 +1,85 @@
+\\~]+)[\\w]+ # elements
+ |
+ \\:{1,2}( # pseudo-elements
+ after|before|first-letter|first-line|selection
+ ))
+ /ix';
+
+ /**
+ * @var array>
+ */
+ private static $cache = [];
+
+ /**
+ * Calculates the specificity of the given CSS selector.
+ *
+ * @return int<0, max>
+ *
+ * @internal
+ */
+ public static function calculate(string $selector): int
+ {
+ if (!isset(self::$cache[$selector])) {
+ $a = 0;
+ /// @todo should exclude \# as well as "#"
+ $matches = null;
+ $b = \substr_count($selector, '#');
+ $c = \preg_match_all(self::NON_ID_ATTRIBUTES_AND_PSEUDO_CLASSES_RX, $selector, $matches);
+ $d = \preg_match_all(self::ELEMENTS_AND_PSEUDO_ELEMENTS_RX, $selector, $matches);
+ self::$cache[$selector] = ($a * 1000) + ($b * 100) + ($c * 10) + $d;
+ }
+
+ return self::$cache[$selector];
+ }
+
+ /**
+ * Clears the cache in order to lower memory usage.
+ */
+ public static function clearCache(): void
+ {
+ self::$cache = [];
+ }
+}
diff --git a/src/Renderable.php b/src/Renderable.php
new file mode 100644
index 000000000..9ebf9a9b9
--- /dev/null
+++ b/src/Renderable.php
@@ -0,0 +1,10 @@
+ $lineNumber
+ * @param int<0, max> $columnNumber
+ */
+ public function __construct(string $rule, int $lineNumber = 0, int $columnNumber = 0)
+ {
+ $this->rule = $rule;
+ $this->setPosition($lineNumber, $columnNumber);
+ }
+
+ /**
+ * @param list $commentsBeforeRule
+ *
+ * @throws UnexpectedEOFException
+ * @throws UnexpectedTokenException
+ *
+ * @internal since V8.8.0
+ */
+ public static function parse(ParserState $parserState, array $commentsBeforeRule = []): Rule
+ {
+ $comments = \array_merge($commentsBeforeRule, $parserState->consumeWhiteSpace());
+ $rule = new Rule(
+ $parserState->parseIdentifier(!$parserState->comes('--')),
+ $parserState->currentLine(),
+ $parserState->currentColumn()
+ );
+ $rule->setComments($comments);
+ $rule->addComments($parserState->consumeWhiteSpace());
+ $parserState->consume(':');
+ $value = Value::parseValue($parserState, self::listDelimiterForRule($rule->getRule()));
+ $rule->setValue($value);
+ $parserState->consumeWhiteSpace();
+ if ($parserState->comes('!')) {
+ $parserState->consume('!');
+ $parserState->consumeWhiteSpace();
+ $parserState->consume('important');
+ $rule->setIsImportant(true);
+ }
+ $parserState->consumeWhiteSpace();
+ while ($parserState->comes(';')) {
+ $parserState->consume(';');
+ }
+
+ return $rule;
+ }
+
+ /**
+ * Returns a list of delimiters (or separators).
+ * The first item is the innermost separator (or, put another way, the highest-precedence operator).
+ * The sequence continues to the outermost separator (or lowest-precedence operator).
+ *
+ * @param non-empty-string $rule
+ *
+ * @return list
+ */
+ private static function listDelimiterForRule(string $rule): array
+ {
+ if (\preg_match('/^font($|-)/', $rule)) {
+ return [',', '/', ' '];
+ }
+
+ switch ($rule) {
+ case 'src':
+ return [' ', ','];
+ default:
+ return [',', ' ', '/'];
+ }
+ }
+
+ /**
+ * @param non-empty-string $rule
+ */
+ public function setRule(string $rule): void
+ {
+ $this->rule = $rule;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function getRule(): string
+ {
+ return $this->rule;
+ }
+
+ /**
+ * @return RuleValueList|string|null
+ */
+ public function getValue()
+ {
+ return $this->value;
+ }
+
+ /**
+ * @param RuleValueList|string|null $value
+ */
+ public function setValue($value): void
+ {
+ $this->value = $value;
+ }
+
+ /**
+ * Adds a value to the existing value. Value will be appended if a `RuleValueList` exists of the given type.
+ * Otherwise, the existing value will be wrapped by one.
+ *
+ * @param RuleValueList|array $value
+ */
+ public function addValue($value, string $type = ' '): void
+ {
+ if (!\is_array($value)) {
+ $value = [$value];
+ }
+ if (!($this->value instanceof RuleValueList) || $this->value->getListSeparator() !== $type) {
+ $currentValue = $this->value;
+ $this->value = new RuleValueList($type, $this->getLineNumber());
+ if ($currentValue) {
+ $this->value->addListComponent($currentValue);
+ }
+ }
+ foreach ($value as $valueItem) {
+ $this->value->addListComponent($valueItem);
+ }
+ }
+
+ public function setIsImportant(bool $isImportant): void
+ {
+ $this->isImportant = $isImportant;
+ }
+
+ public function getIsImportant(): bool
+ {
+ return $this->isImportant;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function render(OutputFormat $outputFormat): string
+ {
+ $formatter = $outputFormat->getFormatter();
+ $result = "{$formatter->comments($this)}{$this->rule}:{$formatter->spaceAfterRuleName()}";
+ if ($this->value instanceof Value) { // Can also be a ValueList
+ $result .= $this->value->render($outputFormat);
+ } else {
+ $result .= $this->value;
+ }
+ if ($this->isImportant) {
+ $result .= ' !important';
+ }
+ $result .= ';';
+ return $result;
+ }
+}
diff --git a/src/RuleSet/AtRuleSet.php b/src/RuleSet/AtRuleSet.php
new file mode 100644
index 000000000..0fda96388
--- /dev/null
+++ b/src/RuleSet/AtRuleSet.php
@@ -0,0 +1,68 @@
+ $lineNumber
+ */
+ public function __construct(string $type, string $arguments = '', int $lineNumber = 0)
+ {
+ parent::__construct($lineNumber);
+ $this->type = $type;
+ $this->arguments = $arguments;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function atRuleName(): string
+ {
+ return $this->type;
+ }
+
+ public function atRuleArgs(): string
+ {
+ return $this->arguments;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function render(OutputFormat $outputFormat): string
+ {
+ $formatter = $outputFormat->getFormatter();
+ $result = $formatter->comments($this);
+ $arguments = $this->arguments;
+ if ($arguments !== '') {
+ $arguments = ' ' . $arguments;
+ }
+ $result .= "@{$this->type}$arguments{$formatter->spaceBeforeOpeningBrace()}{";
+ $result .= $this->renderRules($outputFormat);
+ $result .= '}';
+ return $result;
+ }
+}
diff --git a/src/RuleSet/DeclarationBlock.php b/src/RuleSet/DeclarationBlock.php
new file mode 100644
index 000000000..e41257970
--- /dev/null
+++ b/src/RuleSet/DeclarationBlock.php
@@ -0,0 +1,167 @@
+
+ */
+ private $selectors = [];
+
+ /**
+ * @throws UnexpectedTokenException
+ * @throws UnexpectedEOFException
+ *
+ * @internal since V8.8.0
+ */
+ public static function parse(ParserState $parserState, ?CSSList $list = null): ?DeclarationBlock
+ {
+ $comments = [];
+ $result = new DeclarationBlock($parserState->currentLine());
+ try {
+ $selectorParts = [];
+ do {
+ $selectorParts[] = $parserState->consume(1)
+ . $parserState->consumeUntil(['{', '}', '\'', '"'], false, false, $comments);
+ if (\in_array($parserState->peek(), ['\'', '"'], true) && \substr(\end($selectorParts), -1) != '\\') {
+ if (!isset($stringWrapperCharacter)) {
+ $stringWrapperCharacter = $parserState->peek();
+ } elseif ($stringWrapperCharacter === $parserState->peek()) {
+ unset($stringWrapperCharacter);
+ }
+ }
+ } while (!\in_array($parserState->peek(), ['{', '}'], true) || isset($stringWrapperCharacter));
+ $result->setSelectors(\implode('', $selectorParts), $list);
+ if ($parserState->comes('{')) {
+ $parserState->consume(1);
+ }
+ } catch (UnexpectedTokenException $e) {
+ if ($parserState->getSettings()->usesLenientParsing()) {
+ if (!$parserState->comes('}')) {
+ $parserState->consumeUntil('}', false, true);
+ }
+ return null;
+ } else {
+ throw $e;
+ }
+ }
+ $result->setComments($comments);
+ RuleSet::parseRuleSet($parserState, $result);
+ return $result;
+ }
+
+ /**
+ * @param array|string $selectors
+ *
+ * @throws UnexpectedTokenException
+ */
+ public function setSelectors($selectors, ?CSSList $list = null): void
+ {
+ if (\is_array($selectors)) {
+ $this->selectors = $selectors;
+ } else {
+ $this->selectors = \explode(',', $selectors);
+ }
+ foreach ($this->selectors as $key => $selector) {
+ if (!($selector instanceof Selector)) {
+ if ($list === null || !($list instanceof KeyFrame)) {
+ if (!Selector::isValid($selector)) {
+ throw new UnexpectedTokenException(
+ "Selector did not match '" . Selector::SELECTOR_VALIDATION_RX . "'.",
+ $selectors,
+ 'custom'
+ );
+ }
+ $this->selectors[$key] = new Selector($selector);
+ } else {
+ if (!KeyframeSelector::isValid($selector)) {
+ throw new UnexpectedTokenException(
+ "Selector did not match '" . KeyframeSelector::SELECTOR_VALIDATION_RX . "'.",
+ $selector,
+ 'custom'
+ );
+ }
+ $this->selectors[$key] = new KeyframeSelector($selector);
+ }
+ }
+ }
+ }
+
+ /**
+ * Remove one of the selectors of the block.
+ *
+ * @param Selector|string $selectorToRemove
+ */
+ public function removeSelector($selectorToRemove): bool
+ {
+ if ($selectorToRemove instanceof Selector) {
+ $selectorToRemove = $selectorToRemove->getSelector();
+ }
+ foreach ($this->selectors as $key => $selector) {
+ if ($selector->getSelector() === $selectorToRemove) {
+ unset($this->selectors[$key]);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * @return array
+ */
+ public function getSelectors(): array
+ {
+ return $this->selectors;
+ }
+
+ /**
+ * @return non-empty-string
+ *
+ * @throws OutputException
+ */
+ public function render(OutputFormat $outputFormat): string
+ {
+ $formatter = $outputFormat->getFormatter();
+ $result = $formatter->comments($this);
+ if (\count($this->selectors) === 0) {
+ // If all the selectors have been removed, this declaration block becomes invalid
+ throw new OutputException(
+ 'Attempt to print declaration block with missing selector',
+ $this->getLineNumber()
+ );
+ }
+ $result .= $outputFormat->getContentBeforeDeclarationBlock();
+ $result .= $formatter->implode(
+ $formatter->spaceBeforeSelectorSeparator() . ',' . $formatter->spaceAfterSelectorSeparator(),
+ $this->selectors
+ );
+ $result .= $outputFormat->getContentAfterDeclarationBlockSelectors();
+ $result .= $formatter->spaceBeforeOpeningBrace() . '{';
+ $result .= $this->renderRules($outputFormat);
+ $result .= '}';
+ $result .= $outputFormat->getContentAfterDeclarationBlock();
+
+ return $result;
+ }
+}
diff --git a/src/RuleSet/RuleContainer.php b/src/RuleSet/RuleContainer.php
new file mode 100644
index 000000000..0c6c5936c
--- /dev/null
+++ b/src/RuleSet/RuleContainer.php
@@ -0,0 +1,36 @@
+ $rules
+ */
+ public function setRules(array $rules): void;
+
+ /**
+ * @return array, Rule>
+ */
+ public function getRules(?string $searchPattern = null): array;
+
+ /**
+ * @return array
+ */
+ public function getRulesAssoc(?string $searchPattern = null): array;
+}
diff --git a/src/RuleSet/RuleSet.php b/src/RuleSet/RuleSet.php
new file mode 100644
index 000000000..8e0e8ae5f
--- /dev/null
+++ b/src/RuleSet/RuleSet.php
@@ -0,0 +1,336 @@
+, Rule>>
+ */
+ private $rules = [];
+
+ /**
+ * @param int<0, max> $lineNumber
+ */
+ public function __construct(int $lineNumber = 0)
+ {
+ $this->setPosition($lineNumber);
+ }
+
+ /**
+ * @throws UnexpectedTokenException
+ * @throws UnexpectedEOFException
+ *
+ * @internal since V8.8.0
+ */
+ public static function parseRuleSet(ParserState $parserState, RuleSet $ruleSet): void
+ {
+ while ($parserState->comes(';')) {
+ $parserState->consume(';');
+ }
+ while (true) {
+ $commentsBeforeRule = $parserState->consumeWhiteSpace();
+ if ($parserState->comes('}')) {
+ break;
+ }
+ $rule = null;
+ if ($parserState->getSettings()->usesLenientParsing()) {
+ try {
+ $rule = Rule::parse($parserState, $commentsBeforeRule);
+ } catch (UnexpectedTokenException $e) {
+ try {
+ $consumedText = $parserState->consumeUntil(["\n", ';', '}'], true);
+ // We need to “unfind” the matches to the end of the ruleSet as this will be matched later
+ if ($parserState->streql(\substr($consumedText, -1), '}')) {
+ $parserState->backtrack(1);
+ } else {
+ while ($parserState->comes(';')) {
+ $parserState->consume(';');
+ }
+ }
+ } catch (UnexpectedTokenException $e) {
+ // We’ve reached the end of the document. Just close the RuleSet.
+ return;
+ }
+ }
+ } else {
+ $rule = Rule::parse($parserState, $commentsBeforeRule);
+ }
+ if ($rule instanceof Rule) {
+ $ruleSet->addRule($rule);
+ }
+ }
+ $parserState->consume('}');
+ }
+
+ public function addRule(Rule $ruleToAdd, ?Rule $sibling = null): void
+ {
+ $propertyName = $ruleToAdd->getRule();
+ if (!isset($this->rules[$propertyName])) {
+ $this->rules[$propertyName] = [];
+ }
+
+ $position = \count($this->rules[$propertyName]);
+
+ if ($sibling !== null) {
+ $siblingIsInSet = false;
+ $siblingPosition = \array_search($sibling, $this->rules[$propertyName], true);
+ if ($siblingPosition !== false) {
+ $siblingIsInSet = true;
+ $position = $siblingPosition;
+ } else {
+ $siblingIsInSet = $this->hasRule($sibling);
+ if ($siblingIsInSet) {
+ // Maintain ordering within `$this->rules[$propertyName]`
+ // by inserting before first `Rule` with a same-or-later position than the sibling.
+ foreach ($this->rules[$propertyName] as $index => $rule) {
+ if (self::comparePositionable($rule, $sibling) >= 0) {
+ $position = $index;
+ break;
+ }
+ }
+ }
+ }
+ if ($siblingIsInSet) {
+ // Increment column number of all existing rules on same line, starting at sibling
+ $siblingLineNumber = $sibling->getLineNumber();
+ $siblingColumnNumber = $sibling->getColumnNumber();
+ foreach ($this->rules as $rulesForAProperty) {
+ foreach ($rulesForAProperty as $rule) {
+ if (
+ $rule->getLineNumber() === $siblingLineNumber &&
+ $rule->getColumnNumber() >= $siblingColumnNumber
+ ) {
+ $rule->setPosition($siblingLineNumber, $rule->getColumnNumber() + 1);
+ }
+ }
+ }
+ $ruleToAdd->setPosition($siblingLineNumber, $siblingColumnNumber);
+ }
+ }
+
+ if ($ruleToAdd->getLineNumber() === null) {
+ //this node is added manually, give it the next best line
+ $columnNumber = $ruleToAdd->getColumnNumber() ?? 0;
+ $rules = $this->getRules();
+ $rulesCount = \count($rules);
+ if ($rulesCount > 0) {
+ $last = $rules[$rulesCount - 1];
+ $ruleToAdd->setPosition($last->getLineNo() + 1, $columnNumber);
+ } else {
+ $ruleToAdd->setPosition(1, $columnNumber);
+ }
+ } elseif ($ruleToAdd->getColumnNumber() === null) {
+ $ruleToAdd->setPosition($ruleToAdd->getLineNumber(), 0);
+ }
+
+ \array_splice($this->rules[$propertyName], $position, 0, [$ruleToAdd]);
+ }
+
+ /**
+ * Returns all rules matching the given rule name
+ *
+ * @example $ruleSet->getRules('font') // returns array(0 => $rule, …) or array().
+ *
+ * @example $ruleSet->getRules('font-')
+ * //returns an array of all rules either beginning with font- or matching font.
+ *
+ * @param string|null $searchPattern
+ * Pattern to search for. If null, returns all rules.
+ * If the pattern ends with a dash, all rules starting with the pattern are returned
+ * as well as one matching the pattern with the dash excluded.
+ *
+ * @return array, Rule>
+ */
+ public function getRules(?string $searchPattern = null): array
+ {
+ $result = [];
+ foreach ($this->rules as $propertyName => $rules) {
+ // Either no search rule is given or the search rule matches the found rule exactly
+ // or the search rule ends in “-” and the found rule starts with the search rule.
+ if (
+ $searchPattern === null || $propertyName === $searchPattern
+ || (
+ \strrpos($searchPattern, '-') === \strlen($searchPattern) - \strlen('-')
+ && (\strpos($propertyName, $searchPattern) === 0
+ || $propertyName === \substr($searchPattern, 0, -1))
+ )
+ ) {
+ $result = \array_merge($result, $rules);
+ }
+ }
+ \usort($result, [self::class, 'comparePositionable']);
+
+ return $result;
+ }
+
+ /**
+ * Overrides all the rules of this set.
+ *
+ * @param array $rules The rules to override with.
+ */
+ public function setRules(array $rules): void
+ {
+ $this->rules = [];
+ foreach ($rules as $rule) {
+ $this->addRule($rule);
+ }
+ }
+
+ /**
+ * Returns all rules matching the given pattern and returns them in an associative array with the rule’s name
+ * as keys. This method exists mainly for backwards-compatibility and is really only partially useful.
+ *
+ * Note: This method loses some information: Calling this (with an argument of `background-`) on a declaration block
+ * like `{ background-color: green; background-color; rgba(0, 127, 0, 0.7); }` will only yield an associative array
+ * containing the rgba-valued rule while `getRules()` would yield an indexed array containing both.
+ *
+ * @param string|null $searchPattern
+ * Pattern to search for. If null, returns all rules. If the pattern ends with a dash,
+ * all rules starting with the pattern are returned as well as one matching the pattern with the dash
+ * excluded.
+ *
+ * @return array
+ */
+ public function getRulesAssoc(?string $searchPattern = null): array
+ {
+ /** @var array $result */
+ $result = [];
+ foreach ($this->getRules($searchPattern) as $rule) {
+ $result[$rule->getRule()] = $rule;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Removes a `Rule` from this `RuleSet` by identity.
+ */
+ public function removeRule(Rule $ruleToRemove): void
+ {
+ $nameOfPropertyToRemove = $ruleToRemove->getRule();
+ if (!isset($this->rules[$nameOfPropertyToRemove])) {
+ return;
+ }
+ foreach ($this->rules[$nameOfPropertyToRemove] as $key => $rule) {
+ if ($rule === $ruleToRemove) {
+ unset($this->rules[$nameOfPropertyToRemove][$key]);
+ }
+ }
+ }
+
+ /**
+ * Removes rules by property name or search pattern.
+ *
+ * @param string $searchPattern
+ * pattern to remove.
+ * If the pattern ends in a dash,
+ * all rules starting with the pattern are removed as well as one matching the pattern with the dash
+ * excluded.
+ */
+ public function removeMatchingRules(string $searchPattern): void
+ {
+ foreach ($this->rules as $propertyName => $rules) {
+ // Either the search rule matches the found rule exactly
+ // or the search rule ends in “-” and the found rule starts with the search rule or equals it
+ // (without the trailing dash).
+ if (
+ $propertyName === $searchPattern
+ || (\strrpos($searchPattern, '-') === \strlen($searchPattern) - \strlen('-')
+ && (\strpos($propertyName, $searchPattern) === 0
+ || $propertyName === \substr($searchPattern, 0, -1)))
+ ) {
+ unset($this->rules[$propertyName]);
+ }
+ }
+ }
+
+ public function removeAllRules(): void
+ {
+ $this->rules = [];
+ }
+
+ protected function renderRules(OutputFormat $outputFormat): string
+ {
+ $result = '';
+ $isFirst = true;
+ $nextLevelFormat = $outputFormat->nextLevel();
+ foreach ($this->getRules() as $rule) {
+ $nextLevelFormatter = $nextLevelFormat->getFormatter();
+ $renderedRule = $nextLevelFormatter->safely(static function () use ($rule, $nextLevelFormat): string {
+ return $rule->render($nextLevelFormat);
+ });
+ if ($renderedRule === null) {
+ continue;
+ }
+ if ($isFirst) {
+ $isFirst = false;
+ $result .= $nextLevelFormatter->spaceBeforeRules();
+ } else {
+ $result .= $nextLevelFormatter->spaceBetweenRules();
+ }
+ $result .= $renderedRule;
+ }
+
+ $formatter = $outputFormat->getFormatter();
+ if (!$isFirst) {
+ // Had some output
+ $result .= $formatter->spaceAfterRules();
+ }
+
+ return $formatter->removeLastSemicolon($result);
+ }
+
+ /**
+ * @return int negative if `$first` is before `$second`; zero if they have the same position; positive otherwise
+ */
+ private static function comparePositionable(Positionable $first, Positionable $second): int
+ {
+ if ($first->getLineNo() === $second->getLineNo()) {
+ return $first->getColNo() - $second->getColNo();
+ }
+ return $first->getLineNo() - $second->getLineNo();
+ }
+
+ private function hasRule(Rule $rule): bool
+ {
+ foreach ($this->rules as $rulesForAProperty) {
+ if (\in_array($rule, $rulesForAProperty, true)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/Settings.php b/src/Settings.php
new file mode 100644
index 000000000..a26d10e9e
--- /dev/null
+++ b/src/Settings.php
@@ -0,0 +1,124 @@
+multibyteSupport = \extension_loaded('mbstring');
+ }
+
+ public static function create(): self
+ {
+ return new Settings();
+ }
+
+ /**
+ * Enables/disables multi-byte string support.
+ *
+ * If `true` (`mbstring` extension must be enabled), will use (slower) `mb_strlen`, `mb_convert_case`, `mb_substr`
+ * and `mb_strpos` functions. Otherwise, the normal (ASCII-Only) functions will be used.
+ *
+ * @return $this fluent interface
+ */
+ public function withMultibyteSupport(bool $multibyteSupport = true): self
+ {
+ $this->multibyteSupport = $multibyteSupport;
+
+ return $this;
+ }
+
+ /**
+ * Sets the charset to be used if the CSS does not contain an `@charset` declaration.
+ *
+ * @param non-empty-string $defaultCharset
+ *
+ * @return $this fluent interface
+ */
+ public function withDefaultCharset(string $defaultCharset): self
+ {
+ $this->defaultCharset = $defaultCharset;
+
+ return $this;
+ }
+
+ /**
+ * Configures whether the parser should silently ignore invalid rules.
+ *
+ * @return $this fluent interface
+ */
+ public function withLenientParsing(bool $usesLenientParsing = true): self
+ {
+ $this->lenientParsing = $usesLenientParsing;
+
+ return $this;
+ }
+
+ /**
+ * Configures the parser to choke on invalid rules.
+ *
+ * @return $this fluent interface
+ */
+ public function beStrict(): self
+ {
+ return $this->withLenientParsing(false);
+ }
+
+ /**
+ * @internal
+ */
+ public function hasMultibyteSupport(): bool
+ {
+ return $this->multibyteSupport;
+ }
+
+ /**
+ * @return non-empty-string
+ *
+ * @internal
+ */
+ public function getDefaultCharset(): string
+ {
+ return $this->defaultCharset;
+ }
+
+ /**
+ * @internal
+ */
+ public function usesLenientParsing(): bool
+ {
+ return $this->lenientParsing;
+ }
+}
diff --git a/src/Value/CSSFunction.php b/src/Value/CSSFunction.php
new file mode 100644
index 000000000..f78f7cb62
--- /dev/null
+++ b/src/Value/CSSFunction.php
@@ -0,0 +1,116 @@
+ $arguments
+ * @param non-empty-string $separator
+ * @param int<0, max> $lineNumber
+ */
+ public function __construct(string $name, $arguments, string $separator = ',', int $lineNumber = 0)
+ {
+ if ($arguments instanceof RuleValueList) {
+ $separator = $arguments->getListSeparator();
+ $arguments = $arguments->getListComponents();
+ }
+ $this->name = $name;
+ $this->setPosition($lineNumber); // TODO: redundant?
+ parent::__construct($arguments, $separator, $lineNumber);
+ }
+
+ /**
+ * @throws SourceException
+ * @throws UnexpectedEOFException
+ * @throws UnexpectedTokenException
+ *
+ * @internal since V8.8.0
+ */
+ public static function parse(ParserState $parserState, bool $ignoreCase = false): CSSFunction
+ {
+ $name = self::parseName($parserState, $ignoreCase);
+ $parserState->consume('(');
+ $arguments = self::parseArguments($parserState);
+
+ $result = new CSSFunction($name, $arguments, ',', $parserState->currentLine());
+ $parserState->consume(')');
+
+ return $result;
+ }
+
+ /**
+ * @throws SourceException
+ * @throws UnexpectedEOFException
+ * @throws UnexpectedTokenException
+ */
+ private static function parseName(ParserState $parserState, bool $ignoreCase = false): string
+ {
+ return $parserState->parseIdentifier($ignoreCase);
+ }
+
+ /**
+ * @return Value|string
+ *
+ * @throws SourceException
+ * @throws UnexpectedEOFException
+ * @throws UnexpectedTokenException
+ */
+ private static function parseArguments(ParserState $parserState)
+ {
+ return Value::parseValue($parserState, ['=', ' ', ',']);
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ /**
+ * @param non-empty-string $name
+ */
+ public function setName(string $name): void
+ {
+ $this->name = $name;
+ }
+
+ /**
+ * @return array
+ */
+ public function getArguments(): array
+ {
+ return $this->components;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function render(OutputFormat $outputFormat): string
+ {
+ $arguments = parent::render($outputFormat);
+ return "{$this->name}({$arguments})";
+ }
+}
diff --git a/src/Value/CSSString.php b/src/Value/CSSString.php
new file mode 100644
index 000000000..52b521e6b
--- /dev/null
+++ b/src/Value/CSSString.php
@@ -0,0 +1,95 @@
+ $lineNumber
+ */
+ public function __construct(string $string, int $lineNumber = 0)
+ {
+ $this->string = $string;
+ parent::__construct($lineNumber);
+ }
+
+ /**
+ * @throws SourceException
+ * @throws UnexpectedEOFException
+ * @throws UnexpectedTokenException
+ *
+ * @internal since V8.8.0
+ */
+ public static function parse(ParserState $parserState): CSSString
+ {
+ $begin = $parserState->peek();
+ $quote = null;
+ if ($begin === "'") {
+ $quote = "'";
+ } elseif ($begin === '"') {
+ $quote = '"';
+ }
+ if ($quote !== null) {
+ $parserState->consume($quote);
+ }
+ $result = '';
+ $content = null;
+ if ($quote === null) {
+ // Unquoted strings end in whitespace or with braces, brackets, parentheses
+ while (\preg_match('/[\\s{}()<>\\[\\]]/isu', $parserState->peek()) !== 1) {
+ $result .= $parserState->parseCharacter(false);
+ }
+ } else {
+ while (!$parserState->comes($quote)) {
+ $content = $parserState->parseCharacter(false);
+ if ($content === null) {
+ throw new SourceException(
+ "Non-well-formed quoted string {$parserState->peek(3)}",
+ $parserState->currentLine()
+ );
+ }
+ $result .= $content;
+ }
+ $parserState->consume($quote);
+ }
+ return new CSSString($result, $parserState->currentLine());
+ }
+
+ public function setString(string $string): void
+ {
+ $this->string = $string;
+ }
+
+ public function getString(): string
+ {
+ return $this->string;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function render(OutputFormat $outputFormat): string
+ {
+ $string = \addslashes($this->string);
+ $string = \str_replace("\n", '\\A', $string);
+ return $outputFormat->getStringQuotingType() . $string . $outputFormat->getStringQuotingType();
+ }
+}
diff --git a/src/Value/CalcFunction.php b/src/Value/CalcFunction.php
new file mode 100644
index 000000000..12e2638cd
--- /dev/null
+++ b/src/Value/CalcFunction.php
@@ -0,0 +1,105 @@
+parseIdentifier();
+ if ($parserState->peek() != '(') {
+ // Found ; or end of line before an opening bracket
+ throw new UnexpectedTokenException('(', $parserState->peek(), 'literal', $parserState->currentLine());
+ } elseif ($function !== 'calc') {
+ // Found invalid calc definition. Example calc (...
+ throw new UnexpectedTokenException('calc', $function, 'literal', $parserState->currentLine());
+ }
+ $parserState->consume('(');
+ $calcRuleValueList = new CalcRuleValueList($parserState->currentLine());
+ $list = new RuleValueList(',', $parserState->currentLine());
+ $nestingLevel = 0;
+ $lastComponentType = null;
+ while (!$parserState->comes(')') || $nestingLevel > 0) {
+ if ($parserState->isEnd() && $nestingLevel === 0) {
+ break;
+ }
+
+ $parserState->consumeWhiteSpace();
+ if ($parserState->comes('(')) {
+ $nestingLevel++;
+ $calcRuleValueList->addListComponent($parserState->consume(1));
+ $parserState->consumeWhiteSpace();
+ continue;
+ } elseif ($parserState->comes(')')) {
+ $nestingLevel--;
+ $calcRuleValueList->addListComponent($parserState->consume(1));
+ $parserState->consumeWhiteSpace();
+ continue;
+ }
+ if ($lastComponentType != CalcFunction::T_OPERAND) {
+ $value = Value::parsePrimitiveValue($parserState);
+ $calcRuleValueList->addListComponent($value);
+ $lastComponentType = CalcFunction::T_OPERAND;
+ } else {
+ if (\in_array($parserState->peek(), $operators, true)) {
+ if (($parserState->comes('-') || $parserState->comes('+'))) {
+ if (
+ $parserState->peek(1, -1) != ' '
+ || !($parserState->comes('- ')
+ || $parserState->comes('+ '))
+ ) {
+ throw new UnexpectedTokenException(
+ " {$parserState->peek()} ",
+ $parserState->peek(1, -1) . $parserState->peek(2),
+ 'literal',
+ $parserState->currentLine()
+ );
+ }
+ }
+ $calcRuleValueList->addListComponent($parserState->consume(1));
+ $lastComponentType = CalcFunction::T_OPERATOR;
+ } else {
+ throw new UnexpectedTokenException(
+ \sprintf(
+ 'Next token was expected to be an operand of type %s. Instead "%s" was found.',
+ \implode(', ', $operators),
+ $parserState->peek()
+ ),
+ '',
+ 'custom',
+ $parserState->currentLine()
+ );
+ }
+ }
+ $parserState->consumeWhiteSpace();
+ }
+ $list->addListComponent($calcRuleValueList);
+ if (!$parserState->isEnd()) {
+ $parserState->consume(')');
+ }
+ return new CalcFunction($function, $list, ',', $parserState->currentLine());
+ }
+}
diff --git a/src/Value/CalcRuleValueList.php b/src/Value/CalcRuleValueList.php
new file mode 100644
index 000000000..3c0f24ce0
--- /dev/null
+++ b/src/Value/CalcRuleValueList.php
@@ -0,0 +1,23 @@
+ $lineNumber
+ */
+ public function __construct(int $lineNumber = 0)
+ {
+ parent::__construct(',', $lineNumber);
+ }
+
+ public function render(OutputFormat $outputFormat): string
+ {
+ return $outputFormat->getFormatter()->implode(' ', $this->components);
+ }
+}
diff --git a/src/Value/Color.php b/src/Value/Color.php
new file mode 100644
index 000000000..028ce8561
--- /dev/null
+++ b/src/Value/Color.php
@@ -0,0 +1,390 @@
+ val1, 'c' => val2, 'h' => val3, …) and output in the second form.
+ */
+class Color extends CSSFunction
+{
+ /**
+ * @param array $colorValues
+ * @param int<0, max> $lineNumber
+ */
+ public function __construct(array $colorValues, int $lineNumber = 0)
+ {
+ parent::__construct(\implode('', \array_keys($colorValues)), $colorValues, ',', $lineNumber);
+ }
+
+ /**
+ * @throws UnexpectedEOFException
+ * @throws UnexpectedTokenException
+ *
+ * @internal since V8.8.0
+ */
+ public static function parse(ParserState $parserState, bool $ignoreCase = false): CSSFunction
+ {
+ return $parserState->comes('#')
+ ? self::parseHexColor($parserState)
+ : self::parseColorFunction($parserState);
+ }
+
+ /**
+ * @throws UnexpectedEOFException
+ * @throws UnexpectedTokenException
+ */
+ private static function parseHexColor(ParserState $parserState): Color
+ {
+ $parserState->consume('#');
+ $hexValue = $parserState->parseIdentifier(false);
+ if ($parserState->strlen($hexValue) === 3) {
+ $hexValue = $hexValue[0] . $hexValue[0] . $hexValue[1] . $hexValue[1] . $hexValue[2] . $hexValue[2];
+ } elseif ($parserState->strlen($hexValue) === 4) {
+ $hexValue = $hexValue[0] . $hexValue[0] . $hexValue[1] . $hexValue[1] . $hexValue[2] . $hexValue[2]
+ . $hexValue[3] . $hexValue[3];
+ }
+
+ if ($parserState->strlen($hexValue) === 8) {
+ $colorValues = [
+ 'r' => new Size(\intval($hexValue[0] . $hexValue[1], 16), null, true, $parserState->currentLine()),
+ 'g' => new Size(\intval($hexValue[2] . $hexValue[3], 16), null, true, $parserState->currentLine()),
+ 'b' => new Size(\intval($hexValue[4] . $hexValue[5], 16), null, true, $parserState->currentLine()),
+ 'a' => new Size(
+ \round(self::mapRange(\intval($hexValue[6] . $hexValue[7], 16), 0, 255, 0, 1), 2),
+ null,
+ true,
+ $parserState->currentLine()
+ ),
+ ];
+ } elseif ($parserState->strlen($hexValue) === 6) {
+ $colorValues = [
+ 'r' => new Size(\intval($hexValue[0] . $hexValue[1], 16), null, true, $parserState->currentLine()),
+ 'g' => new Size(\intval($hexValue[2] . $hexValue[3], 16), null, true, $parserState->currentLine()),
+ 'b' => new Size(\intval($hexValue[4] . $hexValue[5], 16), null, true, $parserState->currentLine()),
+ ];
+ } else {
+ throw new UnexpectedTokenException(
+ 'Invalid hex color value',
+ $hexValue,
+ 'custom',
+ $parserState->currentLine()
+ );
+ }
+
+ return new Color($colorValues, $parserState->currentLine());
+ }
+
+ /**
+ * @throws UnexpectedEOFException
+ * @throws UnexpectedTokenException
+ */
+ private static function parseColorFunction(ParserState $parserState): CSSFunction
+ {
+ $colorValues = [];
+
+ $colorMode = $parserState->parseIdentifier(true);
+ $parserState->consumeWhiteSpace();
+ $parserState->consume('(');
+
+ // CSS Color Module Level 4 says that `rgb` and `rgba` are now aliases; likewise `hsl` and `hsla`.
+ // So, attempt to parse with the `a`, and allow for it not being there.
+ switch ($colorMode) {
+ case 'rgb':
+ $colorModeForParsing = 'rgba';
+ $mayHaveOptionalAlpha = true;
+ break;
+ case 'hsl':
+ $colorModeForParsing = 'hsla';
+ $mayHaveOptionalAlpha = true;
+ break;
+ case 'rgba':
+ // This is handled identically to the following case.
+ case 'hsla':
+ $colorModeForParsing = $colorMode;
+ $mayHaveOptionalAlpha = true;
+ break;
+ default:
+ $colorModeForParsing = $colorMode;
+ $mayHaveOptionalAlpha = false;
+ }
+
+ $containsVar = false;
+ $containsNone = false;
+ $isLegacySyntax = false;
+ $expectedArgumentCount = $parserState->strlen($colorModeForParsing);
+ for ($argumentIndex = 0; $argumentIndex < $expectedArgumentCount; ++$argumentIndex) {
+ $parserState->consumeWhiteSpace();
+ $valueKey = $colorModeForParsing[$argumentIndex];
+ if ($parserState->comes('var')) {
+ $colorValues[$valueKey] = CSSFunction::parseIdentifierOrFunction($parserState);
+ $containsVar = true;
+ } elseif (!$isLegacySyntax && $parserState->comes('none')) {
+ $colorValues[$valueKey] = $parserState->parseIdentifier();
+ $containsNone = true;
+ } else {
+ $colorValues[$valueKey] = Size::parse($parserState, true);
+ }
+
+ // This must be done first, to consume comments as well, so that the `comes` test will work.
+ $parserState->consumeWhiteSpace();
+
+ // With a `var` argument, the function can have fewer arguments.
+ // And as of CSS Color Module Level 4, the alpha argument is optional.
+ $canCloseNow =
+ $containsVar
+ || ($mayHaveOptionalAlpha && $argumentIndex >= $expectedArgumentCount - 2);
+ if ($canCloseNow && $parserState->comes(')')) {
+ break;
+ }
+
+ // "Legacy" syntax is comma-delimited, and does not allow the `none` keyword.
+ // "Modern" syntax is space-delimited, with `/` as alpha delimiter.
+ // They cannot be mixed.
+ if ($argumentIndex === 0 && !$containsNone) {
+ // An immediate closing parenthesis is not valid.
+ if ($parserState->comes(')')) {
+ throw new UnexpectedTokenException(
+ 'Color function with no arguments',
+ '',
+ 'custom',
+ $parserState->currentLine()
+ );
+ }
+ $isLegacySyntax = $parserState->comes(',');
+ }
+
+ if ($isLegacySyntax && $argumentIndex < ($expectedArgumentCount - 1)) {
+ $parserState->consume(',');
+ }
+
+ // In the "modern" syntax, the alpha value must be delimited with `/`.
+ if (!$isLegacySyntax) {
+ if ($containsVar) {
+ // If the `var` substitution encompasses more than one argument,
+ // the alpha deliminator may come at any time.
+ if ($parserState->comes('/')) {
+ $parserState->consume('/');
+ }
+ } elseif (($colorModeForParsing[$argumentIndex + 1] ?? '') === 'a') {
+ // Alpha value is the next expected argument.
+ // Since a closing parenthesis was not found, a `/` separator is now required.
+ $parserState->consume('/');
+ }
+ }
+ }
+ $parserState->consume(')');
+
+ return $containsVar
+ ? new CSSFunction($colorMode, \array_values($colorValues), ',', $parserState->currentLine())
+ : new Color($colorValues, $parserState->currentLine());
+ }
+
+ private static function mapRange(float $value, float $fromMin, float $fromMax, float $toMin, float $toMax): float
+ {
+ $fromRange = $fromMax - $fromMin;
+ $toRange = $toMax - $toMin;
+ $multiplier = $toRange / $fromRange;
+ $newValue = $value - $fromMin;
+ $newValue *= $multiplier;
+
+ return $newValue + $toMin;
+ }
+
+ /**
+ * @return array
+ */
+ public function getColor(): array
+ {
+ return $this->components;
+ }
+
+ /**
+ * @param array $colorValues
+ */
+ public function setColor(array $colorValues): void
+ {
+ $this->setName(\implode('', \array_keys($colorValues)));
+ $this->components = $colorValues;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function getColorDescription(): string
+ {
+ return $this->getName();
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function render(OutputFormat $outputFormat): string
+ {
+ if ($this->shouldRenderAsHex($outputFormat)) {
+ return $this->renderAsHex();
+ }
+
+ if ($this->shouldRenderInModernSyntax()) {
+ return $this->renderInModernSyntax($outputFormat);
+ }
+
+ return parent::render($outputFormat);
+ }
+
+ private function shouldRenderAsHex(OutputFormat $outputFormat): bool
+ {
+ return
+ $outputFormat->usesRgbHashNotation()
+ && $this->getRealName() === 'rgb'
+ && $this->allComponentsAreNumbers();
+ }
+
+ /**
+ * The function name is a concatenation of the array keys of the components, which is passed to the constructor.
+ * However, this can be changed by calling {@see CSSFunction::setName},
+ * so is not reliable in situations where it's necessary to determine the function name based on the components.
+ */
+ private function getRealName(): string
+ {
+ return \implode('', \array_keys($this->components));
+ }
+
+ /**
+ * Test whether all color components are absolute numbers (CSS type `number`), not percentages or anything else.
+ * If any component is not an instance of `Size`, the method will also return `false`.
+ */
+ private function allComponentsAreNumbers(): bool
+ {
+ foreach ($this->components as $component) {
+ if (!($component instanceof Size) || $component->getUnit() !== null) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Note that this method assumes the following:
+ * - The `components` array has keys for `r`, `g` and `b`;
+ * - The values in the array are all instances of `Size`.
+ *
+ * Errors will be triggered or thrown if this is not the case.
+ *
+ * @return non-empty-string
+ */
+ private function renderAsHex(): string
+ {
+ $result = \sprintf(
+ '%02x%02x%02x',
+ $this->components['r']->getSize(),
+ $this->components['g']->getSize(),
+ $this->components['b']->getSize()
+ );
+ $canUseShortVariant = ($result[0] == $result[1]) && ($result[2] == $result[3]) && ($result[4] == $result[5]);
+
+ return '#' . ($canUseShortVariant ? $result[0] . $result[2] . $result[4] : $result);
+ }
+
+ /**
+ * The "legacy" syntax does not allow RGB colors to have a mixture of `percentage`s and `number`s,
+ * and does not allow `none` as any component value.
+ *
+ * The "legacy" and "modern" monikers are part of the formal W3C syntax.
+ * See the following for more information:
+ * - {@link
+ * https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/rgb#formal_syntax
+ * Description of the formal syntax for `rgb()` on MDN
+ * };
+ * - {@link
+ * https://www.w3.org/TR/css-color-4/#rgb-functions
+ * The same in the CSS Color Module Level 4 W3C Candidate Recommendation Draft
+ * } (as of 13 February 2024, at time of writing).
+ */
+ private function shouldRenderInModernSyntax(): bool
+ {
+ if ($this->hasNoneAsComponentValue()) {
+ return true;
+ }
+
+ if (!$this->colorFunctionMayHaveMixedValueTypes($this->getRealName())) {
+ return false;
+ }
+
+ $hasPercentage = false;
+ $hasNumber = false;
+ foreach ($this->components as $key => $value) {
+ if ($key === 'a') {
+ // Alpha can have units that don't match those of the RGB components in the "legacy" syntax.
+ // So it is not necessary to check it. It's also always last, hence `break` rather than `continue`.
+ break;
+ }
+ if (!($value instanceof Size)) {
+ // Unexpected, unknown, or modified via the API
+ return false;
+ }
+ $unit = $value->getUnit();
+ // `switch` only does loose comparison
+ if ($unit === null) {
+ $hasNumber = true;
+ } elseif ($unit === '%') {
+ $hasPercentage = true;
+ } else {
+ // Invalid unit
+ return false;
+ }
+ }
+
+ return $hasPercentage && $hasNumber;
+ }
+
+ private function hasNoneAsComponentValue(): bool
+ {
+ return \in_array('none', $this->components, true);
+ }
+
+ /**
+ * Some color functions, such as `rgb`,
+ * may have a mixture of `percentage`, `number`, or possibly other types in their arguments.
+ *
+ * Note that this excludes the alpha component, which is treated separately.
+ */
+ private function colorFunctionMayHaveMixedValueTypes(string $function): bool
+ {
+ $functionsThatMayHaveMixedValueTypes = ['rgb', 'rgba'];
+
+ return \in_array($function, $functionsThatMayHaveMixedValueTypes, true);
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ private function renderInModernSyntax(OutputFormat $outputFormat): string
+ {
+ // Maybe not yet without alpha, but will be...
+ $componentsWithoutAlpha = $this->components;
+ \end($componentsWithoutAlpha);
+ if (\key($componentsWithoutAlpha) === 'a') {
+ $alpha = $this->components['a'];
+ unset($componentsWithoutAlpha['a']);
+ }
+
+ $formatter = $outputFormat->getFormatter();
+ $arguments = $formatter->implode(' ', $componentsWithoutAlpha);
+ if (isset($alpha)) {
+ $separator = $formatter->spaceBeforeListArgumentSeparator('/')
+ . '/' . $formatter->spaceAfterListArgumentSeparator('/');
+ $arguments = $formatter->implode($separator, [$arguments, $alpha]);
+ }
+
+ return $this->getName() . '(' . $arguments . ')';
+ }
+}
diff --git a/src/Value/LineName.php b/src/Value/LineName.php
new file mode 100644
index 000000000..791f0cc3c
--- /dev/null
+++ b/src/Value/LineName.php
@@ -0,0 +1,59 @@
+ $components
+ * @param int<0, max> $lineNumber
+ */
+ public function __construct(array $components = [], int $lineNumber = 0)
+ {
+ parent::__construct($components, ' ', $lineNumber);
+ }
+
+ /**
+ * @throws UnexpectedTokenException
+ * @throws UnexpectedEOFException
+ *
+ * @internal since V8.8.0
+ */
+ public static function parse(ParserState $parserState): LineName
+ {
+ $parserState->consume('[');
+ $parserState->consumeWhiteSpace();
+ $names = [];
+ do {
+ if ($parserState->getSettings()->usesLenientParsing()) {
+ try {
+ $names[] = $parserState->parseIdentifier();
+ } catch (UnexpectedTokenException $e) {
+ if (!$parserState->comes(']')) {
+ throw $e;
+ }
+ }
+ } else {
+ $names[] = $parserState->parseIdentifier();
+ }
+ $parserState->consumeWhiteSpace();
+ } while (!$parserState->comes(']'));
+ $parserState->consume(']');
+ return new LineName($names, $parserState->currentLine());
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function render(OutputFormat $outputFormat): string
+ {
+ return '[' . parent::render(OutputFormat::createCompact()) . ']';
+ }
+}
diff --git a/src/Value/PrimitiveValue.php b/src/Value/PrimitiveValue.php
new file mode 100644
index 000000000..f7f940928
--- /dev/null
+++ b/src/Value/PrimitiveValue.php
@@ -0,0 +1,7 @@
+ $lineNumber
+ */
+ public function __construct(string $separator = ',', int $lineNumber = 0)
+ {
+ parent::__construct([], $separator, $lineNumber);
+ }
+}
diff --git a/src/Value/Size.php b/src/Value/Size.php
new file mode 100644
index 000000000..a5e15497a
--- /dev/null
+++ b/src/Value/Size.php
@@ -0,0 +1,210 @@
+
+ */
+ private const ABSOLUTE_SIZE_UNITS = [
+ 'px',
+ 'pt',
+ 'pc',
+ 'cm',
+ 'mm',
+ 'mozmm',
+ 'in',
+ 'vh',
+ 'dvh',
+ 'svh',
+ 'lvh',
+ 'vw',
+ 'vmin',
+ 'vmax',
+ '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'];
+
+ /**
+ * @var array, array>|null
+ */
+ private static $SIZE_UNITS = null;
+
+ /**
+ * @var float
+ */
+ private $size;
+
+ /**
+ * @var string|null
+ */
+ private $unit;
+
+ /**
+ * @var bool
+ */
+ private $isColorComponent;
+
+ /**
+ * @param float|int|string $size
+ * @param int<0, max> $lineNumber
+ */
+ public function __construct($size, ?string $unit = null, bool $isColorComponent = false, int $lineNumber = 0)
+ {
+ parent::__construct($lineNumber);
+ $this->size = (float) $size;
+ $this->unit = $unit;
+ $this->isColorComponent = $isColorComponent;
+ }
+
+ /**
+ * @throws UnexpectedEOFException
+ * @throws UnexpectedTokenException
+ *
+ * @internal since V8.8.0
+ */
+ public static function parse(ParserState $parserState, bool $isColorComponent = false): Size
+ {
+ $size = '';
+ if ($parserState->comes('-')) {
+ $size .= $parserState->consume('-');
+ }
+ while (\is_numeric($parserState->peek()) || $parserState->comes('.') || $parserState->comes('e', true)) {
+ if ($parserState->comes('.')) {
+ $size .= $parserState->consume('.');
+ } elseif ($parserState->comes('e', true)) {
+ $lookahead = $parserState->peek(1, 1);
+ if (\is_numeric($lookahead) || $lookahead === '+' || $lookahead === '-') {
+ $size .= $parserState->consume(2);
+ } else {
+ break; // Reached the unit part of the number like "em" or "ex"
+ }
+ } else {
+ $size .= $parserState->consume(1);
+ }
+ }
+
+ $unit = null;
+ $sizeUnits = self::getSizeUnits();
+ foreach ($sizeUnits as $length => &$values) {
+ $key = \strtolower($parserState->peek($length));
+ if (\array_key_exists($key, $values)) {
+ if (($unit = $values[$key]) !== null) {
+ $parserState->consume($length);
+ break;
+ }
+ }
+ }
+ return new Size((float) $size, $unit, $isColorComponent, $parserState->currentLine());
+ }
+
+ /**
+ * @return array, array>
+ */
+ private static function getSizeUnits(): array
+ {
+ if (!\is_array(self::$SIZE_UNITS)) {
+ self::$SIZE_UNITS = [];
+ $sizeUnits = \array_merge(self::ABSOLUTE_SIZE_UNITS, self::RELATIVE_SIZE_UNITS, self::NON_SIZE_UNITS);
+ foreach ($sizeUnits as $sizeUnit) {
+ $tokenLength = \strlen($sizeUnit);
+ if (!isset(self::$SIZE_UNITS[$tokenLength])) {
+ self::$SIZE_UNITS[$tokenLength] = [];
+ }
+ self::$SIZE_UNITS[$tokenLength][\strtolower($sizeUnit)] = $sizeUnit;
+ }
+
+ \krsort(self::$SIZE_UNITS, SORT_NUMERIC);
+ }
+
+ return self::$SIZE_UNITS;
+ }
+
+ public function setUnit(string $unit): void
+ {
+ $this->unit = $unit;
+ }
+
+ public function getUnit(): ?string
+ {
+ return $this->unit;
+ }
+
+ /**
+ * @param float|int|string $size
+ */
+ public function setSize($size): void
+ {
+ $this->size = (float) $size;
+ }
+
+ public function getSize(): float
+ {
+ return $this->size;
+ }
+
+ public function isColorComponent(): bool
+ {
+ return $this->isColorComponent;
+ }
+
+ /**
+ * Returns whether the number stored in this Size really represents a size (as in a length of something on screen).
+ *
+ * Returns `false` if the unit is an angle, a duration, a frequency, or the number is a component in a `Color`
+ * object.
+ */
+ public function isSize(): bool
+ {
+ if (\in_array($this->unit, self::NON_SIZE_UNITS, true)) {
+ return false;
+ }
+ return !$this->isColorComponent();
+ }
+
+ public function isRelative(): bool
+ {
+ if (\in_array($this->unit, self::RELATIVE_SIZE_UNITS, true)) {
+ return true;
+ }
+ if ($this->unit === null && $this->size != 0) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function render(OutputFormat $outputFormat): string
+ {
+ $locale = \localeconv();
+ $decimalPoint = \preg_quote($locale['decimal_point'], '/');
+ $size = \preg_match('/[\\d\\.]+e[+-]?\\d+/i', (string) $this->size)
+ ? \preg_replace("/$decimalPoint?0+$/", '', \sprintf('%f', $this->size)) : (string) $this->size;
+
+ return \preg_replace(["/$decimalPoint/", '/^(-?)0\\./'], ['.', '$1.'], $size) . ($this->unit ?? '');
+ }
+}
diff --git a/src/Value/URL.php b/src/Value/URL.php
new file mode 100644
index 000000000..4b4fb4c89
--- /dev/null
+++ b/src/Value/URL.php
@@ -0,0 +1,83 @@
+ $lineNumber
+ */
+ public function __construct(CSSString $url, int $lineNumber = 0)
+ {
+ parent::__construct($lineNumber);
+ $this->url = $url;
+ }
+
+ /**
+ * @throws SourceException
+ * @throws UnexpectedEOFException
+ * @throws UnexpectedTokenException
+ *
+ * @internal since V8.8.0
+ */
+ public static function parse(ParserState $parserState): URL
+ {
+ $anchor = $parserState->anchor();
+ $identifier = '';
+ for ($i = 0; $i < 3; $i++) {
+ $character = $parserState->parseCharacter(true);
+ if ($character === null) {
+ break;
+ }
+ $identifier .= $character;
+ }
+ $useUrl = $parserState->streql($identifier, 'url');
+ if ($useUrl) {
+ $parserState->consumeWhiteSpace();
+ $parserState->consume('(');
+ } else {
+ $anchor->backtrack();
+ }
+ $parserState->consumeWhiteSpace();
+ $result = new URL(CSSString::parse($parserState), $parserState->currentLine());
+ if ($useUrl) {
+ $parserState->consumeWhiteSpace();
+ $parserState->consume(')');
+ }
+ return $result;
+ }
+
+ public function setURL(CSSString $url): void
+ {
+ $this->url = $url;
+ }
+
+ public function getURL(): CSSString
+ {
+ return $this->url;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function render(OutputFormat $outputFormat): string
+ {
+ return "url({$this->url->render($outputFormat)})";
+ }
+}
diff --git a/src/Value/Value.php b/src/Value/Value.php
new file mode 100644
index 000000000..e33a2949f
--- /dev/null
+++ b/src/Value/Value.php
@@ -0,0 +1,211 @@
+ $lineNumber
+ */
+ public function __construct(int $lineNumber = 0)
+ {
+ $this->setPosition($lineNumber);
+ }
+
+ /**
+ * @param array $listDelimiters
+ *
+ * @return Value|string
+ *
+ * @throws UnexpectedTokenException
+ * @throws UnexpectedEOFException
+ *
+ * @internal since V8.8.0
+ */
+ public static function parseValue(ParserState $parserState, array $listDelimiters = [])
+ {
+ /** @var list $stack */
+ $stack = [];
+ $parserState->consumeWhiteSpace();
+ //Build a list of delimiters and parsed values
+ while (
+ !($parserState->comes('}') || $parserState->comes(';') || $parserState->comes('!')
+ || $parserState->comes(')')
+ || $parserState->isEnd())
+ ) {
+ if (\count($stack) > 0) {
+ $foundDelimiter = false;
+ foreach ($listDelimiters as $delimiter) {
+ if ($parserState->comes($delimiter)) {
+ \array_push($stack, $parserState->consume($delimiter));
+ $parserState->consumeWhiteSpace();
+ $foundDelimiter = true;
+ break;
+ }
+ }
+ if (!$foundDelimiter) {
+ //Whitespace was the list delimiter
+ \array_push($stack, ' ');
+ }
+ }
+ \array_push($stack, self::parsePrimitiveValue($parserState));
+ $parserState->consumeWhiteSpace();
+ }
+ // Convert the list to list objects
+ foreach ($listDelimiters as $delimiter) {
+ $stackSize = \count($stack);
+ if ($stackSize === 1) {
+ return $stack[0];
+ }
+ $newStack = [];
+ for ($offset = 0; $offset < $stackSize; ++$offset) {
+ if ($offset === ($stackSize - 1) || $delimiter !== $stack[$offset + 1]) {
+ $newStack[] = $stack[$offset];
+ continue;
+ }
+ $length = 2; //Number of elements to be joined
+ for ($i = $offset + 3; $i < $stackSize; $i += 2, ++$length) {
+ if ($delimiter !== $stack[$i]) {
+ break;
+ }
+ }
+ $list = new RuleValueList($delimiter, $parserState->currentLine());
+ for ($i = $offset; $i - $offset < $length * 2; $i += 2) {
+ $list->addListComponent($stack[$i]);
+ }
+ $newStack[] = $list;
+ $offset += $length * 2 - 2;
+ }
+ $stack = $newStack;
+ }
+ if (!isset($stack[0])) {
+ throw new UnexpectedTokenException(
+ " {$parserState->peek()} ",
+ $parserState->peek(1, -1) . $parserState->peek(2),
+ 'literal',
+ $parserState->currentLine()
+ );
+ }
+ return $stack[0];
+ }
+
+ /**
+ * @return CSSFunction|string
+ *
+ * @throws UnexpectedEOFException
+ * @throws UnexpectedTokenException
+ *
+ * @internal since V8.8.0
+ */
+ public static function parseIdentifierOrFunction(ParserState $parserState, bool $ignoreCase = false)
+ {
+ $anchor = $parserState->anchor();
+ $result = $parserState->parseIdentifier($ignoreCase);
+
+ if ($parserState->comes('(')) {
+ $anchor->backtrack();
+ if ($parserState->streql('url', $result)) {
+ $result = URL::parse($parserState);
+ } elseif ($parserState->streql('calc', $result)) {
+ $result = CalcFunction::parse($parserState);
+ } else {
+ $result = CSSFunction::parse($parserState, $ignoreCase);
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * @return CSSFunction|CSSString|LineName|Size|URL|string
+ *
+ * @throws UnexpectedEOFException
+ * @throws UnexpectedTokenException
+ * @throws SourceException
+ *
+ * @internal since V8.8.0
+ */
+ public static function parsePrimitiveValue(ParserState $parserState)
+ {
+ $value = null;
+ $parserState->consumeWhiteSpace();
+ if (
+ \is_numeric($parserState->peek())
+ || ($parserState->comes('-.')
+ && \is_numeric($parserState->peek(1, 2)))
+ || (($parserState->comes('-') || $parserState->comes('.')) && \is_numeric($parserState->peek(1, 1)))
+ ) {
+ $value = Size::parse($parserState);
+ } elseif ($parserState->comes('#') || $parserState->comes('rgb', true) || $parserState->comes('hsl', true)) {
+ $value = Color::parse($parserState);
+ } elseif ($parserState->comes("'") || $parserState->comes('"')) {
+ $value = CSSString::parse($parserState);
+ } elseif ($parserState->comes('progid:') && $parserState->getSettings()->usesLenientParsing()) {
+ $value = self::parseMicrosoftFilter($parserState);
+ } elseif ($parserState->comes('[')) {
+ $value = LineName::parse($parserState);
+ } elseif ($parserState->comes('U+')) {
+ $value = self::parseUnicodeRangeValue($parserState);
+ } else {
+ $nextCharacter = $parserState->peek(1);
+ try {
+ $value = self::parseIdentifierOrFunction($parserState);
+ } catch (UnexpectedTokenException $e) {
+ if (\in_array($nextCharacter, ['+', '-', '*', '/'], true)) {
+ $value = $parserState->consume(1);
+ } else {
+ throw $e;
+ }
+ }
+ }
+ $parserState->consumeWhiteSpace();
+
+ return $value;
+ }
+
+ /**
+ * @throws UnexpectedEOFException
+ * @throws UnexpectedTokenException
+ */
+ private static function parseMicrosoftFilter(ParserState $parserState): CSSFunction
+ {
+ $function = $parserState->consumeUntil('(', false, true);
+ $arguments = Value::parseValue($parserState, [',', '=']);
+ return new CSSFunction($function, $arguments, ',', $parserState->currentLine());
+ }
+
+ /**
+ * @throws UnexpectedEOFException
+ * @throws UnexpectedTokenException
+ */
+ private static function parseUnicodeRangeValue(ParserState $parserState): string
+ {
+ $codepointMaxLength = 6; // Code points outside BMP can use up to six digits
+ $range = '';
+ $parserState->consume('U+');
+ do {
+ if ($parserState->comes('-')) {
+ $codepointMaxLength = 13; // Max length is 2 six-digit code points + the dash(-) between them
+ }
+ $range .= $parserState->consume(1);
+ } while (\strlen($range) < $codepointMaxLength && \preg_match('/[A-Fa-f0-9\\?-]/', $parserState->peek()));
+
+ return "U+{$range}";
+ }
+}
diff --git a/src/Value/ValueList.php b/src/Value/ValueList.php
new file mode 100644
index 000000000..1d26b74d3
--- /dev/null
+++ b/src/Value/ValueList.php
@@ -0,0 +1,96 @@
+
+ *
+ * @internal since 8.8.0
+ */
+ protected $components;
+
+ /**
+ * @var non-empty-string
+ *
+ * @internal since 8.8.0
+ */
+ protected $separator;
+
+ /**
+ * @param array|Value|string $components
+ * @param non-empty-string $separator
+ * @param int<0, max> $lineNumber
+ */
+ public function __construct($components = [], $separator = ',', int $lineNumber = 0)
+ {
+ parent::__construct($lineNumber);
+ if (!\is_array($components)) {
+ $components = [$components];
+ }
+ $this->components = $components;
+ $this->separator = $separator;
+ }
+
+ /**
+ * @param Value|string $component
+ */
+ public function addListComponent($component): void
+ {
+ $this->components[] = $component;
+ }
+
+ /**
+ * @return array
+ */
+ public function getListComponents(): array
+ {
+ return $this->components;
+ }
+
+ /**
+ * @param array $components
+ */
+ public function setListComponents(array $components): void
+ {
+ $this->components = $components;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function getListSeparator(): string
+ {
+ return $this->separator;
+ }
+
+ /**
+ * @param non-empty-string $separator
+ */
+ public function setListSeparator(string $separator): void
+ {
+ $this->separator = $separator;
+ }
+
+ public function render(OutputFormat $outputFormat): string
+ {
+ $formatter = $outputFormat->getFormatter();
+
+ return $formatter->implode(
+ $formatter->spaceBeforeListArgumentSeparator($this->separator) . $this->separator
+ . $formatter->spaceAfterListArgumentSeparator($this->separator),
+ $this->components
+ );
+ }
+}
diff --git a/tests/CSSList/AtRuleBlockListTest.php b/tests/CSSList/AtRuleBlockListTest.php
new file mode 100644
index 000000000..9a725b212
--- /dev/null
+++ b/tests/CSSList/AtRuleBlockListTest.php
@@ -0,0 +1,94 @@
+
+ */
+ public static function provideMinWidthMediaRule(): array
+ {
+ return [
+ 'without spaces around arguments' => ['@media(min-width: 768px){.class{color:red}}'],
+ 'with spaces around arguments' => ['@media (min-width: 768px) {.class{color:red}}'],
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public static function provideSyntacticallyCorrectAtRule(): array
+ {
+ return [
+ 'media print' => ['@media print { html { background: white; color: black; } }'],
+ 'keyframes' => ['@keyframes mymove { from { top: 0px; } }'],
+ 'supports' => [
+ '
+ @supports (display: flex) {
+ .flex-container > * {
+ text-shadow: 0 0 2px blue;
+ float: none;
+ }
+ .flex-container {
+ display: flex;
+ }
+ }
+ ',
+ ],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideMinWidthMediaRule
+ */
+ public function parsesRuleNameOfMediaQueries(string $css): void
+ {
+ $contents = (new Parser($css))->parse()->getContents();
+ $atRuleBlockList = $contents[0];
+
+ self::assertInstanceOf(AtRuleBlockList::class, $atRuleBlockList);
+ self::assertSame('media', $atRuleBlockList->atRuleName());
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideMinWidthMediaRule
+ */
+ public function parsesArgumentsOfMediaQueries(string $css): void
+ {
+ $contents = (new Parser($css))->parse()->getContents();
+ $atRuleBlockList = $contents[0];
+
+ self::assertInstanceOf(AtRuleBlockList::class, $atRuleBlockList);
+ self::assertSame('(min-width: 768px)', $atRuleBlockList->atRuleArgs());
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideMinWidthMediaRule
+ * @dataProvider provideSyntacticallyCorrectAtRule
+ */
+ public function parsesSyntacticallyCorrectAtRuleInStrictMode(string $css): void
+ {
+ $contents = (new Parser($css, Settings::create()->beStrict()))->parse()->getContents();
+
+ self::assertNotEmpty($contents, 'Failing CSS: `' . $css . '`');
+ }
+}
diff --git a/tests/Comment/CommentTest.php b/tests/Comment/CommentTest.php
new file mode 100644
index 000000000..15d5983ee
--- /dev/null
+++ b/tests/Comment/CommentTest.php
@@ -0,0 +1,84 @@
+render(OutputFormat::createPretty()));
+ self::assertSame(
+ '/** 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))
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function stripCommentsFromOutput(): void
+ {
+ $css = TestsParserTest::parsedStructureForFile('comments');
+ self::assertSame('
+@import url("some/url.css") screen;
+
+.foo, #bar {
+ background-color: #000;
+}
+
+@media screen {
+ #foo.bar {
+ position: absolute;
+ }
+}
+', $css->render(OutputFormat::createPretty()->setRenderComments(false)));
+ self::assertSame(
+ '@import url("some/url.css") screen;'
+ . '.foo,#bar{background-color:#000;}'
+ . '@media screen{#foo.bar{position:absolute;}}',
+ $css->render(OutputFormat::createCompact())
+ );
+ }
+}
diff --git a/tests/Functional/CSSList/DocumentTest.php b/tests/Functional/CSSList/DocumentTest.php
new file mode 100644
index 000000000..71334f7f4
--- /dev/null
+++ b/tests/Functional/CSSList/DocumentTest.php
@@ -0,0 +1,137 @@
+render());
+ }
+
+ /**
+ * @test
+ */
+ public function renderWithVirginOutputFormatCanRenderEmptyDocument(): void
+ {
+ $subject = new Document();
+
+ self::assertSame('', $subject->render(new OutputFormat()));
+ }
+
+ /**
+ * @test
+ */
+ public function renderWithDefaultOutputFormatCanRenderEmptyDocument(): void
+ {
+ $subject = new Document();
+
+ self::assertSame('', $subject->render(OutputFormat::create()));
+ }
+
+ /**
+ * @test
+ */
+ public function renderWithCompactOutputFormatCanRenderEmptyDocument(): void
+ {
+ $subject = new Document();
+
+ self::assertSame('', $subject->render(OutputFormat::createCompact()));
+ }
+
+ /**
+ * @test
+ */
+ public function renderWithPrettyOutputFormatCanRenderEmptyDocument(): void
+ {
+ $subject = new Document();
+
+ self::assertSame('', $subject->render(OutputFormat::createPretty()));
+ }
+
+ /**
+ * Builds a subject with one `@charset` rule and one `@media` rule.
+ */
+ private function buildSubjectWithAtRules(): Document
+ {
+ $subject = new Document();
+ $charset = new Charset(new CSSString('UTF-8'));
+ $subject->append($charset);
+ $mediaQuery = new AtRuleBlockList('media', 'screen');
+ $subject->append($mediaQuery);
+
+ return $subject;
+ }
+
+ /**
+ * @test
+ */
+ public function renderWithoutOutputFormatCanRenderAtRules(): void
+ {
+ $subject = $this->buildSubjectWithAtRules();
+
+ $expected = '@charset "UTF-8";' . "\n" . '@media screen {}';
+ self::assertSame($expected, $subject->render());
+ }
+
+ /**
+ * @test
+ */
+ public function renderWithVirginOutputFormatCanRenderAtRules(): void
+ {
+ $subject = $this->buildSubjectWithAtRules();
+
+ $expected = '@charset "UTF-8";' . "\n" . '@media screen {}';
+ self::assertSame($expected, $subject->render(new OutputFormat()));
+ }
+
+ /**
+ * @test
+ */
+ public function renderWithDefaultOutputFormatCanRenderAtRules(): void
+ {
+ $subject = $this->buildSubjectWithAtRules();
+
+ $expected = '@charset "UTF-8";' . "\n" . '@media screen {}';
+ self::assertSame($expected, $subject->render(OutputFormat::create()));
+ }
+
+ /**
+ * @test
+ */
+ public function renderWithCompactOutputFormatCanRenderAtRules(): void
+ {
+ $subject = $this->buildSubjectWithAtRules();
+
+ $expected = '@charset "UTF-8";@media screen{}';
+ self::assertSame($expected, $subject->render(OutputFormat::createCompact()));
+ }
+
+ /**
+ * @test
+ */
+ public function renderWithPrettyOutputFormatCanRenderAtRules(): void
+ {
+ $subject = $this->buildSubjectWithAtRules();
+
+ $expected = "\n" . '@charset "UTF-8";' . "\n\n" . '@media screen {}' . "\n";
+ self::assertSame($expected, $subject->render(OutputFormat::createPretty()));
+ }
+}
diff --git a/tests/Functional/Comment/CommentTest.php b/tests/Functional/Comment/CommentTest.php
new file mode 100644
index 000000000..bbfa06a75
--- /dev/null
+++ b/tests/Functional/Comment/CommentTest.php
@@ -0,0 +1,67 @@
+setComment($comment);
+
+ self::assertSame('/*' . $comment . '*/', $subject->render(new OutputFormat()));
+ }
+
+ /**
+ * @test
+ */
+ public function renderWithDefaultOutputFormatRendersCommentEnclosedInCommentDelimiters(): void
+ {
+ $comment = 'There is no spoon.';
+ $subject = new Comment();
+
+ $subject->setComment($comment);
+
+ self::assertSame('/*' . $comment . '*/', $subject->render(OutputFormat::create()));
+ }
+
+ /**
+ * @test
+ */
+ public function renderWithCompactOutputFormatRendersCommentEnclosedInCommentDelimiters(): void
+ {
+ $comment = 'There is no spoon.';
+ $subject = new Comment();
+
+ $subject->setComment($comment);
+
+ self::assertSame('/*' . $comment . '*/', $subject->render(OutputFormat::createCompact()));
+ }
+
+ /**
+ * @test
+ */
+ public function renderWithPrettyOutputFormatRendersCommentEnclosedInCommentDelimiters(): void
+ {
+ $comment = 'There is no spoon.';
+ $subject = new Comment();
+
+ $subject->setComment($comment);
+
+ self::assertSame('/*' . $comment . '*/', $subject->render(OutputFormat::createPretty()));
+ }
+}
diff --git a/tests/Functional/ParserTest.php b/tests/Functional/ParserTest.php
new file mode 100644
index 000000000..982bb3a23
--- /dev/null
+++ b/tests/Functional/ParserTest.php
@@ -0,0 +1,39 @@
+parse();
+
+ self::assertInstanceOf(Document::class, $result);
+ }
+
+ /**
+ * @test
+ */
+ public function parseWithOneRuleSetReturnsDocument(): void
+ {
+ $parser = new Parser('.thing { }');
+
+ $result = $parser->parse();
+
+ self::assertInstanceOf(Document::class, $result);
+ }
+}
diff --git a/tests/Functional/Property/SelectorTest.php b/tests/Functional/Property/SelectorTest.php
new file mode 100644
index 000000000..397dbc722
--- /dev/null
+++ b/tests/Functional/Property/SelectorTest.php
@@ -0,0 +1,59 @@
+render(new OutputFormat()));
+ }
+
+ /**
+ * @test
+ */
+ public function renderWithDefaultOutputFormatRendersSelectorPassedToConstructor(): void
+ {
+ $pattern = 'a';
+ $subject = new Selector($pattern);
+
+ self::assertSame($pattern, $subject->render(OutputFormat::create()));
+ }
+
+ /**
+ * @test
+ */
+ public function renderWithCompactOutputFormatRendersSelectorPassedToConstructor(): void
+ {
+ $pattern = 'a';
+ $subject = new Selector($pattern);
+
+ self::assertSame($pattern, $subject->render(OutputFormat::createCompact()));
+ }
+
+ /**
+ * @test
+ */
+ public function renderWithPrettyOutputFormatRendersSelectorPassedToConstructor(): void
+ {
+ $pattern = 'a';
+ $subject = new Selector($pattern);
+
+ self::assertSame($pattern, $subject->render(OutputFormat::createPretty()));
+ }
+}
diff --git a/tests/Functional/RuleSet/DeclarationBlockTest.php b/tests/Functional/RuleSet/DeclarationBlockTest.php
new file mode 100644
index 000000000..fe6b9e51c
--- /dev/null
+++ b/tests/Functional/RuleSet/DeclarationBlockTest.php
@@ -0,0 +1,69 @@
+
+ */
+ public static function provideInvalidDeclarationBlock(): array
+ {
+ return [
+ 'no selector' => ['{ color: red; }'],
+ 'invalid selector' => ['/ { color: red; }'],
+ 'no opening brace' => ['body color: red; }'],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideInvalidDeclarationBlock
+ */
+ public function parseReturnsNullForInvalidDeclarationBlock(string $invalidDeclarationBlock): void
+ {
+ $parserState = new ParserState($invalidDeclarationBlock, Settings::create());
+
+ $result = DeclarationBlock::parse($parserState);
+
+ self::assertNull($result);
+ }
+
+ /**
+ * @test
+ */
+ public function rendersRulesInOrderProvided(): void
+ {
+ $declarationBlock = new DeclarationBlock();
+ $declarationBlock->setSelectors([new Selector('.test')]);
+
+ $rule1 = new Rule('background-color');
+ $rule1->setValue('transparent');
+ $declarationBlock->addRule($rule1);
+
+ $rule2 = new Rule('background');
+ $rule2->setValue('#222');
+ $declarationBlock->addRule($rule2);
+
+ $rule3 = new Rule('background-color');
+ $rule3->setValue('#fff');
+ $declarationBlock->addRule($rule3);
+
+ $expectedRendering = 'background-color: transparent;background: #222;background-color: #fff';
+ self::assertStringContainsString($expectedRendering, $declarationBlock->render(new OutputFormat()));
+ }
+}
diff --git a/tests/Functional/Value/ValueTest.php b/tests/Functional/Value/ValueTest.php
new file mode 100644
index 000000000..4d65ee2a8
--- /dev/null
+++ b/tests/Functional/Value/ValueTest.php
@@ -0,0 +1,45 @@
+
+ */
+ private const DEFAULT_DELIMITERS = [',', ' ', '/'];
+
+ /**
+ * @test
+ */
+ public function parsesFirstArgumentInMaxFunction(): void
+ {
+ $parsedValue = Value::parseValue(
+ new ParserState('max(300px, 400px);', Settings::create()),
+ self::DEFAULT_DELIMITERS
+ );
+
+ self::assertInstanceOf(CSSFunction::class, $parsedValue);
+ $size = $parsedValue->getArguments()[0];
+ self::assertInstanceOf(Size::class, $size);
+ self::assertSame(300.0, $size->getSize());
+ self::assertSame('px', $size->getUnit());
+ self::assertFalse($size->isColorComponent());
+ }
+}
diff --git a/tests/files/-empty.css b/tests/FunctionalDeprecated/.gitkeep
similarity index 100%
rename from tests/files/-empty.css
rename to tests/FunctionalDeprecated/.gitkeep
diff --git a/tests/OutputFormatTest.php b/tests/OutputFormatTest.php
new file mode 100644
index 000000000..9852ae58a
--- /dev/null
+++ b/tests/OutputFormatTest.php
@@ -0,0 +1,343 @@
+parser = new Parser(self::TEST_CSS);
+ $this->document = $this->parser->parse();
+ }
+
+ /**
+ * @test
+ */
+ public function plain(): void
+ {
+ self::assertSame(
+ '.main, .test {font: italic normal bold 16px/1.2 "Helvetica",Verdana,sans-serif;background: white;}
+@media screen {.main {background-size: 100% 100%;font-size: 1.3em;background-color: #fff;}}',
+ $this->document->render()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function compact(): 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;}}',
+ $this->document->render(OutputFormat::createCompact())
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function pretty(): void
+ {
+ self::assertSame(self::TEST_CSS, $this->document->render(OutputFormat::createPretty()));
+ }
+
+ /**
+ * @test
+ */
+ 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;}}",
+ $this->document->render(OutputFormat::create()->setSpaceAfterListArgumentSeparator(' '))
+ );
+ }
+
+ /**
+ * @test
+ */
+ 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;}}",
+ $this->document->render(
+ OutputFormat::create()
+ ->setSpaceAfterListArgumentSeparator(' ')
+ ->setSpaceAfterListArgumentSeparators([
+ ',' => "\t",
+ '/' => '',
+ ' ' => '',
+ ])
+ )
+ );
+ }
+
+ /**
+ * @test
+ */
+ 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;}}',
+ $this->document->render(OutputFormat::create()->setSpaceAfterSelectorSeparator("\n"))
+ );
+ }
+
+ /**
+ * @test
+ */
+ 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;}}',
+ $this->document->render(OutputFormat::create()->setStringQuotingType("'"))
+ );
+ }
+
+ /**
+ * @test
+ */
+ 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);}}',
+ $this->document->render(OutputFormat::create()->setRGBHashNotation(false))
+ );
+ }
+
+ /**
+ * @test
+ */
+ 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}}',
+ $this->document->render(OutputFormat::create()->setSemicolonAfterLastRule(false))
+ );
+ }
+
+ /**
+ * @test
+ */
+ 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;}}',
+ $this->document->render(OutputFormat::create()->setSpaceAfterRuleName("\t"))
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function spaceRules(): void
+ {
+ $outputFormat = OutputFormat::create()
+ ->setSpaceBeforeRules("\n")
+ ->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));
+ }
+
+ /**
+ * @test
+ */
+ public function spaceBlocks(): void
+ {
+ $outputFormat = OutputFormat::create()
+ ->setSpaceBeforeBlocks("\n")
+ ->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));
+ }
+
+ /**
+ * @test
+ */
+ public function spaceBoth(): void
+ {
+ $outputFormat = OutputFormat::create()
+ ->setSpaceBeforeRules("\n")
+ ->setSpaceBetweenRules("\n")
+ ->setSpaceAfterRules("\n")
+ ->setSpaceBeforeBlocks("\n")
+ ->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));
+ }
+
+ /**
+ * @test
+ */
+ public function spaceBetweenBlocks(): void
+ {
+ $outputFormat = OutputFormat::create()
+ ->setSpaceBetweenBlocks('');
+
+ 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)
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function indentation(): void
+ {
+ $outputFormat = OutputFormat::create()
+ ->setSpaceBeforeRules("\n")
+ ->setSpaceBetweenRules("\n")
+ ->setSpaceAfterRules("\n")
+ ->setSpaceBeforeBlocks("\n")
+ ->setSpaceBetweenBlocks("\n")
+ ->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));
+ }
+
+ /**
+ * @test
+ */
+ public function spaceBeforeBraces(): void
+ {
+ $outputFormat = OutputFormat::create()
+ ->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;}}',
+ $this->document->render($outputFormat)
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function ignoreExceptionsOff(): void
+ {
+ $this->expectException(OutputException::class);
+
+ $outputFormat = OutputFormat::create()->setIgnoreExceptions(false);
+
+ $declarationBlocks = $this->document->getAllDeclarationBlocks();
+ $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;}}',
+ $this->document->render($outputFormat)
+ );
+ $firstDeclarationBlock->removeSelector('.test');
+ $this->document->render($outputFormat);
+ }
+
+ /**
+ * @test
+ */
+ public function ignoreExceptionsOn(): void
+ {
+ $outputFormat = OutputFormat::create()->setIgnoreExceptions(true);
+
+ $declarationBlocks = $this->document->getAllDeclarationBlocks();
+ $firstDeclarationBlock = $declarationBlocks[0];
+ $firstDeclarationBlock->removeSelector('.main');
+ $firstDeclarationBlock->removeSelector('.test');
+ self::assertSame(
+ '@media screen {.main {background-size: 100% 100%;font-size: 1.3em;background-color: #fff;}}',
+ $this->document->render($outputFormat)
+ );
+ }
+}
diff --git a/tests/ParserTest.php b/tests/ParserTest.php
new file mode 100644
index 000000000..656d943bc
--- /dev/null
+++ b/tests/ParserTest.php
@@ -0,0 +1,1243 @@
+parse();
+
+ self::assertInstanceOf(Document::class, $document);
+
+ $cssList = $document->getContents();
+ self::assertCount(1, $cssList);
+ self::assertInstanceOf(RuleSet::class, $cssList[0]);
+ }
+
+ /**
+ * @test
+ */
+ public function files(): void
+ {
+ $directory = __DIR__ . '/fixtures';
+ if ($directoryHandle = \opendir($directory)) {
+ /* This is the correct way to loop over the directory. */
+ while (false !== ($filename = \readdir($directoryHandle))) {
+ if (\strpos($filename, '.') === 0) {
+ continue;
+ }
+ if (\strrpos($filename, '.css') !== \strlen($filename) - \strlen('.css')) {
+ continue;
+ }
+ if (\strpos($filename, '-') === 0) {
+ // Either a file which SHOULD fail (at least in strict mode)
+ // or a future test of an as-of-now missing feature
+ continue;
+ }
+ $parser = new Parser(\file_get_contents($directory . '/' . $filename));
+ try {
+ self::assertNotEquals('', $parser->parse()->render());
+ } catch (\Exception $e) {
+ self::fail($e);
+ }
+ }
+ \closedir($directoryHandle);
+ }
+ }
+
+ /**
+ * @depends files
+ *
+ * @test
+ */
+ public function colorParsing(): void
+ {
+ $document = self::parsedStructureForFile('colortest');
+ foreach ($document->getAllRuleSets() as $ruleSet) {
+ if (!($ruleSet instanceof DeclarationBlock)) {
+ continue;
+ }
+ $selectors = $ruleSet->getSelectors();
+ $selector = $selectors[0]->getSelector();
+ if ($selector === '#mine') {
+ $colorRules = $ruleSet->getRules('color');
+ $colorRuleValue = $colorRules[0]->getValue();
+ self::assertSame('red', $colorRuleValue);
+ $colorRules = $ruleSet->getRules('background-');
+ $colorRuleValue = $colorRules[0]->getValue();
+ self::assertInstanceOf(Color::class, $colorRuleValue);
+ self::assertEquals([
+ 'r' => new Size(35.0, null, true, $colorRuleValue->getLineNo()),
+ 'g' => new Size(35.0, null, true, $colorRuleValue->getLineNo()),
+ 'b' => new Size(35.0, null, true, $colorRuleValue->getLineNo()),
+ ], $colorRuleValue->getColor());
+ $colorRules = $ruleSet->getRules('border-color');
+ $colorRuleValue = $colorRules[0]->getValue();
+ self::assertInstanceOf(Color::class, $colorRuleValue);
+ self::assertEquals([
+ 'r' => new Size(10.0, null, true, $colorRuleValue->getLineNo()),
+ 'g' => new Size(100.0, null, true, $colorRuleValue->getLineNo()),
+ 'b' => new Size(230.0, null, true, $colorRuleValue->getLineNo()),
+ ], $colorRuleValue->getColor());
+ $colorRuleValue = $colorRules[1]->getValue();
+ self::assertInstanceOf(Color::class, $colorRuleValue);
+ self::assertEquals([
+ 'r' => new Size(10.0, null, true, $colorRuleValue->getLineNo()),
+ 'g' => new Size(100.0, null, true, $colorRuleValue->getLineNo()),
+ 'b' => new Size(231.0, null, true, $colorRuleValue->getLineNo()),
+ 'a' => new Size('0000.3', null, true, $colorRuleValue->getLineNo()),
+ ], $colorRuleValue->getColor());
+ $colorRules = $ruleSet->getRules('outline-color');
+ $colorRuleValue = $colorRules[0]->getValue();
+ self::assertInstanceOf(Color::class, $colorRuleValue);
+ self::assertEquals([
+ 'r' => new Size(34.0, null, true, $colorRuleValue->getLineNo()),
+ 'g' => new Size(34.0, null, true, $colorRuleValue->getLineNo()),
+ 'b' => new Size(34.0, null, true, $colorRuleValue->getLineNo()),
+ ], $colorRuleValue->getColor());
+ } elseif ($selector === '#yours') {
+ $colorRules = $ruleSet->getRules('background-color');
+ $colorRuleValue = $colorRules[0]->getValue();
+ self::assertInstanceOf(Color::class, $colorRuleValue);
+ self::assertEquals([
+ 'h' => new Size(220.0, null, true, $colorRuleValue->getLineNo()),
+ 's' => new Size(10.0, '%', true, $colorRuleValue->getLineNo()),
+ 'l' => new Size(220.0, '%', true, $colorRuleValue->getLineNo()),
+ ], $colorRuleValue->getColor());
+ $colorRuleValue = $colorRules[1]->getValue();
+ self::assertInstanceOf(Color::class, $colorRuleValue);
+ self::assertEquals([
+ 'h' => new Size(220.0, null, true, $colorRuleValue->getLineNo()),
+ 's' => new Size(10.0, '%', true, $colorRuleValue->getLineNo()),
+ 'l' => new Size(220.0, '%', true, $colorRuleValue->getLineNo()),
+ 'a' => new Size(0000.3, null, true, $colorRuleValue->getLineNo()),
+ ], $colorRuleValue->getColor());
+ $colorRules = $ruleSet->getRules('outline-color');
+ self::assertEmpty($colorRules);
+ }
+ }
+ foreach ($document->getAllValues(null, 'color') as $colorValue) {
+ self::assertSame('red', $colorValue);
+ }
+ 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"
+ . '#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"
+ . '#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()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function unicodeParsing(): void
+ {
+ $document = self::parsedStructureForFile('unicode');
+ foreach ($document->getAllDeclarationBlocks() as $ruleSet) {
+ $selectors = $ruleSet->getSelectors();
+ $selector = $selectors[0]->getSelector();
+ if (\substr($selector, 0, \strlen('.test-')) !== '.test-') {
+ continue;
+ }
+ $contentRules = $ruleSet->getRules('content');
+ $firstContentRuleAsString = $contentRules[0]->getValue()->render(OutputFormat::create());
+ if ($selector === '.test-1') {
+ self::assertSame('" "', $firstContentRuleAsString);
+ }
+ if ($selector === '.test-2') {
+ self::assertSame('"é"', $firstContentRuleAsString);
+ }
+ if ($selector === '.test-3') {
+ self::assertSame('" "', $firstContentRuleAsString);
+ }
+ if ($selector === '.test-4') {
+ self::assertSame('"𝄞"', $firstContentRuleAsString);
+ }
+ if ($selector === '.test-5') {
+ self::assertSame('"水"', $firstContentRuleAsString);
+ }
+ if ($selector === '.test-6') {
+ self::assertSame('"¥"', $firstContentRuleAsString);
+ }
+ if ($selector === '.test-7') {
+ self::assertSame('"\\A"', $firstContentRuleAsString);
+ }
+ if ($selector === '.test-8') {
+ self::assertSame('"\\"\\""', $firstContentRuleAsString);
+ }
+ if ($selector === '.test-9') {
+ self::assertSame('"\\"\\\'"', $firstContentRuleAsString);
+ }
+ if ($selector === '.test-10') {
+ self::assertSame('"\\\'\\\\"', $firstContentRuleAsString);
+ }
+ if ($selector === '.test-11') {
+ self::assertSame('"test"', $firstContentRuleAsString);
+ }
+ }
+ }
+
+ /**
+ * @test
+ */
+ public function unicodeRangeParsing(): void
+ {
+ $document = self::parsedStructureForFile('unicode-range');
+ $expected = '@font-face {unicode-range: U+0100-024F,U+0259,U+1E??-2EFF,U+202F;}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function specificity(): void
+ {
+ $document = self::parsedStructureForFile('specificity');
+ self::assertEquals([new Selector('#test .help')], $document->getSelectorsBySpecificity('> 100'));
+ self::assertEquals(
+ [new Selector('#test .help'), new Selector('#file')],
+ $document->getSelectorsBySpecificity('>= 100')
+ );
+ self::assertEquals([new Selector('#file')], $document->getSelectorsBySpecificity('=== 100'));
+ self::assertEquals([new Selector('#file')], $document->getSelectorsBySpecificity('== 100'));
+ self::assertEquals([
+ new Selector('#file'),
+ new Selector('.help:hover'),
+ new Selector('li.green'),
+ new Selector('ol li::before'),
+ ], $document->getSelectorsBySpecificity('<= 100'));
+ self::assertEquals([
+ new Selector('.help:hover'),
+ new Selector('li.green'),
+ new Selector('ol li::before'),
+ ], $document->getSelectorsBySpecificity('< 100'));
+ self::assertEquals([new Selector('li.green')], $document->getSelectorsBySpecificity('11'));
+ self::assertEquals([new Selector('ol li::before')], $document->getSelectorsBySpecificity('3'));
+ }
+
+ /**
+ * @test
+ */
+ 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"
+ . '@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"
+ . '@region-style #intro {p {color: blue;}}',
+ $document->render()
+ );
+ foreach ($document->getAllDeclarationBlocks() as $declarationBlock) {
+ foreach ($declarationBlock->getSelectors() as $selector) {
+ //Loop over all selector parts (the comma-separated strings in a selector) and prepend the id
+ $selector->setSelector('#my_id ' . $selector->getSelector());
+ }
+ }
+ 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"
+ . '@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"
+ . '@region-style #intro {#my_id p {color: blue;}}',
+ $document->render(OutputFormat::create()->setRenderComments(false))
+ );
+
+ $document = self::parsedStructureForFile('values');
+ self::assertSame(
+ '#header {margin: 10px 2em 1cm 2%;font-family: Verdana,Helvetica,"Gill Sans",sans-serif;'
+ . 'font-size: 10px;color: red !important;background-color: green;'
+ . 'background-color: rgba(0,128,0,.7);frequency: 30Hz;transform: rotate(1turn);}
+body {color: green;font: 75% "Lucida Grande","Trebuchet MS",Verdana,sans-serif;}',
+ $document->render()
+ );
+ foreach ($document->getAllRuleSets() as $ruleSet) {
+ $ruleSet->removeMatchingRules('font-');
+ }
+ self::assertSame(
+ '#header {margin: 10px 2em 1cm 2%;color: red !important;background-color: green;'
+ . 'background-color: rgba(0,128,0,.7);frequency: 30Hz;transform: rotate(1turn);}
+body {color: green;}',
+ $document->render()
+ );
+ foreach ($document->getAllRuleSets() as $ruleSet) {
+ $ruleSet->removeMatchingRules('background-');
+ }
+ self::assertSame(
+ '#header {margin: 10px 2em 1cm 2%;color: red !important;frequency: 30Hz;transform: rotate(1turn);}
+body {color: green;}',
+ $document->render()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function ruleGetters(): void
+ {
+ $document = self::parsedStructureForFile('values');
+ $declarationBlocks = $document->getAllDeclarationBlocks();
+ $headerBlock = $declarationBlocks[0];
+ $bodyBlock = $declarationBlocks[1];
+ $backgroundHeaderRules = $headerBlock->getRules('background-');
+ self::assertCount(2, $backgroundHeaderRules);
+ self::assertSame('background-color', $backgroundHeaderRules[0]->getRule());
+ self::assertSame('background-color', $backgroundHeaderRules[1]->getRule());
+ $backgroundHeaderRules = $headerBlock->getRulesAssoc('background-');
+ self::assertCount(1, $backgroundHeaderRules);
+ self::assertInstanceOf(Color::class, $backgroundHeaderRules['background-color']->getValue());
+ self::assertSame('rgba', $backgroundHeaderRules['background-color']->getValue()->getColorDescription());
+ $headerBlock->removeRule($backgroundHeaderRules['background-color']);
+ $backgroundHeaderRules = $headerBlock->getRules('background-');
+ self::assertCount(1, $backgroundHeaderRules);
+ self::assertSame('green', $backgroundHeaderRules[0]->getValue());
+ }
+
+ /**
+ * @test
+ */
+ public function slashedValues(): void
+ {
+ $document = self::parsedStructureForFile('slashed');
+ self::assertSame(
+ '.test {font: 12px/1.5 Verdana,Arial,sans-serif;border-radius: 5px 10px 5px 10px/10px 5px 10px 5px;}',
+ $document->render()
+ );
+ foreach ($document->getAllValues(null) as $value) {
+ if ($value instanceof Size && $value->isSize() && !$value->isRelative()) {
+ $value->setSize($value->getSize() * 3);
+ }
+ }
+ foreach ($document->getAllDeclarationBlocks() as $declarationBlock) {
+ $fontRules = $declarationBlock->getRules('font');
+ $fontRule = $fontRules[0];
+ $fontRuleValue = $fontRule->getValue();
+ self::assertSame(' ', $fontRuleValue->getListSeparator());
+ $fontRuleValueComponents = $fontRuleValue->getListComponents();
+ $commaList = $fontRuleValueComponents[1];
+ self::assertInstanceOf(ValueList::class, $commaList);
+ $slashList = $fontRuleValueComponents[0];
+ self::assertInstanceOf(ValueList::class, $slashList);
+ self::assertSame(',', $commaList->getListSeparator());
+ self::assertSame('/', $slashList->getListSeparator());
+ $borderRadiusRules = $declarationBlock->getRules('border-radius');
+ $borderRadiusRule = $borderRadiusRules[0];
+ $slashList = $borderRadiusRule->getValue();
+ self::assertSame('/', $slashList->getListSeparator());
+ $slashListComponents = $slashList->getListComponents();
+ $secondSlashListComponent = $slashListComponents[1];
+ self::assertInstanceOf(ValueList::class, $secondSlashListComponent);
+ $firstSlashListComponent = $slashListComponents[0];
+ self::assertInstanceOf(ValueList::class, $firstSlashListComponent);
+ self::assertSame(' ', $firstSlashListComponent->getListSeparator());
+ self::assertSame(' ', $secondSlashListComponent->getListSeparator());
+ }
+ self::assertSame(
+ '.test {font: 36px/1.5 Verdana,Arial,sans-serif;border-radius: 15px 30px 15px 30px/30px 15px 30px 15px;}',
+ $document->render()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function functionSyntax(): void
+ {
+ $document = self::parsedStructureForFile('functions');
+ $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"
+ . '.collapser.expanded::before, .collapser.expanded::-moz-before,'
+ . ' .collapser.expanded::-webkit-before {-moz-transform: rotate(90deg);}'
+ . "\n"
+ . '.collapser + * {height: 0;overflow: hidden;-moz-transition-property: height;'
+ . '-moz-transition-duration: .3s;}'
+ . "\n"
+ . '.collapser.expanded + * {height: auto;}';
+ self::assertSame($expected, $document->render());
+
+ foreach ($document->getAllValues(null, null, true) as $value) {
+ if ($value instanceof Size && $value->isSize()) {
+ $value->setSize($value->getSize() * 3);
+ }
+ }
+ $expected = \str_replace(['1.2em', '.2em', '60%'], ['3.6em', '.6em', '180%'], $expected);
+ self::assertSame($expected, $document->render());
+
+ foreach ($document->getAllValues(null, null, true) as $value) {
+ if ($value instanceof Size && !$value->isRelative() && !$value->isColorComponent()) {
+ $value->setSize($value->getSize() * 2);
+ }
+ }
+ $expected = \str_replace(['.2s', '.3s', '90deg'], ['.4s', '.6s', '180deg'], $expected);
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function namespaces(): void
+ {
+ $document = self::parsedStructureForFile('namespaces');
+ $expected = '@namespace toto "http://toto.example.org";
+@namespace "http://example.com/foo";
+@namespace foo url("http://www.example.com/");
+@namespace foo url("http://www.example.com/");
+foo|test {gaga: 1;}
+|test {gaga: 2;}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function innerColors(): void
+ {
+ $document = self::parsedStructureForFile('inner-color');
+ $expected = 'test {background: -webkit-gradient(linear,0 0,0 bottom,from(#006cad),to(hsl(202,100%,49%)));}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function prefixedGradient(): void
+ {
+ $document = self::parsedStructureForFile('webkit');
+ $expected = '.test {background: -webkit-linear-gradient(top right,white,black);}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function listValueRemoval(): void
+ {
+ $document = self::parsedStructureForFile('atrules');
+ foreach ($document->getContents() as $contentItem) {
+ if ($contentItem instanceof AtRule) {
+ $document->remove($contentItem);
+ continue;
+ }
+ }
+ self::assertSame('html, body {font-size: -.6em;}', $document->render());
+
+ $document = self::parsedStructureForFile('nested');
+ foreach ($document->getAllDeclarationBlocks() as $declarationBlock) {
+ $document->removeDeclarationBlockBySelector($declarationBlock, false);
+ break;
+ }
+ self::assertSame(
+ 'html {some-other: -test(val1);}
+@media screen {html {some: -test(val2);}}
+#unrelated {other: yes;}',
+ $document->render()
+ );
+
+ $document = self::parsedStructureForFile('nested');
+ foreach ($document->getAllDeclarationBlocks() as $declarationBlock) {
+ $document->removeDeclarationBlockBySelector($declarationBlock, true);
+ break;
+ }
+ self::assertSame(
+ '@media screen {html {some: -test(val2);}}
+#unrelated {other: yes;}',
+ $document->render()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function selectorRemoval(): void
+ {
+ $this->expectException(OutputException::class);
+
+ $document = self::parsedStructureForFile('1readme');
+ $declarationsBlocks = $document->getAllDeclarationBlocks();
+ $declarationBlock = $declarationsBlocks[0];
+ self::assertTrue($declarationBlock->removeSelector('html'));
+ $expected = '@charset "utf-8";
+@font-face {font-family: "CrassRoots";src: url("../media/cr.ttf");}
+body {font-size: 1.6em;}';
+ self::assertSame($expected, $document->render());
+ self::assertFalse($declarationBlock->removeSelector('html'));
+ self::assertTrue($declarationBlock->removeSelector('body'));
+ // This tries to output a declaration block without a selector and throws.
+ $document->render();
+ }
+
+ /**
+ * @test
+ */
+ public function comments(): void
+ {
+ $document = self::parsedStructureForFile('comments');
+ $expected = <<render());
+ }
+
+ /**
+ * @test
+ */
+ public function urlInFile(): void
+ {
+ $document = self::parsedStructureForFile('url', Settings::create()->withMultibyteSupport(true));
+ $expected = 'body {background: #fff url("https://somesite.com/images/someimage.gif") repeat top center;}
+body {background-url: url("https://somesite.com/images/someimage.gif");}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function hexAlphaInFile(): void
+ {
+ $document = self::parsedStructureForFile('hex-alpha', Settings::create()->withMultibyteSupport(true));
+ $expected = 'div {background: rgba(17,34,51,.27);}
+div {background: rgba(17,34,51,.27);}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function calcInFile(): void
+ {
+ $document = self::parsedStructureForFile('calc', Settings::create()->withMultibyteSupport(true));
+ $expected = 'div {width: calc(100% / 4);}
+div {margin-top: calc(-120% - 4px);}
+div {height: calc(9 / 16 * 100%) !important;width: calc(( 50px - 50% ) * 2);}
+div {width: calc(50% - ( ( 4% ) * .5 ));}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function calcNestedInFile(): void
+ {
+ $document = self::parsedStructureForFile('calc-nested', Settings::create()->withMultibyteSupport(true));
+ $expected = '.test {font-size: calc(( 3 * 4px ) + -2px);top: calc(200px - calc(20 * 3px));}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function invalidCalcInFile(): void
+ {
+ $document = self::parsedStructureForFile('calc-invalid', Settings::create()->withMultibyteSupport(true));
+ $expected = 'div {}
+div {}
+div {}
+div {height: -moz-calc;}
+div {height: calc;}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function invalidCalc(): void
+ {
+ $parser = new Parser('div { height: calc(100px');
+ $document = $parser->parse();
+ self::assertSame('div {height: calc(100px);}', $document->render());
+
+ $parser = new Parser('div { height: calc(100px)');
+ $document = $parser->parse();
+ self::assertSame('div {height: calc(100px);}', $document->render());
+
+ $parser = new Parser('div { height: calc(100px);');
+ $document = $parser->parse();
+ self::assertSame('div {height: calc(100px);}', $document->render());
+
+ $parser = new Parser('div { height: calc(100px}');
+ $document = $parser->parse();
+ self::assertSame('div {}', $document->render());
+
+ $parser = new Parser('div { height: calc(100px;');
+ $document = $parser->parse();
+ self::assertSame('div {}', $document->render());
+
+ $parser = new Parser('div { height: calc(100px;}');
+ $document = $parser->parse();
+ self::assertSame('div {}', $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function gridLineNameInFile(): void
+ {
+ $document = self::parsedStructureForFile('grid-linename', Settings::create()->withMultibyteSupport(true));
+ $expected = "div {grid-template-columns: [linename] 100px;}\n"
+ . 'span {grid-template-columns: [linename1 linename2] 100px;}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function emptyGridLineNameLenientInFile(): void
+ {
+ $document = self::parsedStructureForFile('empty-grid-linename');
+ $expected = '.test {grid-template-columns: [] 100px;}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function invalidGridLineNameInFile(): void
+ {
+ $document = self::parsedStructureForFile(
+ 'invalid-grid-linename',
+ Settings::create()->withMultibyteSupport(true)
+ );
+ $expected = 'div {}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function unmatchedBracesInFile(): void
+ {
+ $document = self::parsedStructureForFile('unmatched_braces', Settings::create()->withMultibyteSupport(true));
+ $expected = 'button, input, checkbox, textarea {outline: 0;margin: 0;}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ 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: white;color: black;}';
+ 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());
+ }
+
+ /**
+ * @test
+ */
+ public function selectorEscapesInFile(): void
+ {
+ $document = self::parsedStructureForFile('selector-escapes', Settings::create()->withMultibyteSupport(true));
+ $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());
+ }
+
+ /**
+ * @test
+ */
+ public function identifierEscapesInFile(): void
+ {
+ $document = self::parsedStructureForFile('identifier-escapes', Settings::create()->withMultibyteSupport(true));
+ $expected = 'div {font: 14px Font Awesome\\ 5 Pro;font: 14px Font Awesome\\} 5 Pro;'
+ . 'font: 14px Font Awesome\\; 5 Pro;f\\;ont: 14px Font Awesome\\; 5 Pro;}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ 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"
+ . '@media only screen and (min-width: 200px) {.test {prop: val;}}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function keyframeSelectors(): void
+ {
+ $document = self::parsedStructureForFile(
+ '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);}}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function lineNameFailure(): void
+ {
+ $this->expectException(UnexpectedTokenException::class);
+
+ self::parsedStructureForFile('-empty-grid-linename', Settings::create()->withLenientParsing(false));
+ }
+
+ /**
+ * @test
+ */
+ public function calcFailure(): void
+ {
+ $this->expectException(UnexpectedTokenException::class);
+
+ self::parsedStructureForFile('-calc-no-space-around-minus', Settings::create()->withLenientParsing(false));
+ }
+
+ /**
+ * @test
+ */
+ 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"
+ . 'body {background-url: url("https://somesite.com/images/someimage.gif");}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function emptyFile(): void
+ {
+ $document = self::parsedStructureForFile('-empty', Settings::create()->withMultibyteSupport(true));
+ $expected = '';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function emptyFileMbOff(): void
+ {
+ $document = self::parsedStructureForFile('-empty', Settings::create()->withMultibyteSupport(false));
+ $expected = '';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function charsetLenient1(): void
+ {
+ $document = self::parsedStructureForFile('-charset-after-rule', Settings::create()->withLenientParsing(true));
+ $expected = '#id {prop: var(--val);}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function charsetLenient2(): void
+ {
+ $document = self::parsedStructureForFile('-charset-in-block', Settings::create()->withLenientParsing(true));
+ $expected = '@media print {}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function trailingWhitespace(): void
+ {
+ $document = self::parsedStructureForFile('trailing-whitespace', Settings::create()->withLenientParsing(false));
+ $expected = 'div {width: 200px;}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function charsetFailure1(): void
+ {
+ $this->expectException(UnexpectedTokenException::class);
+
+ self::parsedStructureForFile('-charset-after-rule', Settings::create()->withLenientParsing(false));
+ }
+
+ /**
+ * @test
+ */
+ public function charsetFailure2(): void
+ {
+ $this->expectException(UnexpectedTokenException::class);
+
+ self::parsedStructureForFile('-charset-in-block', Settings::create()->withLenientParsing(false));
+ }
+
+ /**
+ * @test
+ */
+ public function unopenedClosingBracketFailure(): void
+ {
+ $this->expectException(SourceException::class);
+
+ self::parsedStructureForFile('-unopened-close-brackets', Settings::create()->withLenientParsing(false));
+ }
+
+ /**
+ * Ensure that a missing property value raises an exception.
+ *
+ * @test
+ */
+ public function missingPropertyValueStrict(): void
+ {
+ $this->expectException(UnexpectedTokenException::class);
+
+ self::parsedStructureForFile('missing-property-value', Settings::create()->withLenientParsing(false));
+ }
+
+ /**
+ * Ensure that a missing property value is ignored when in lenient parsing mode.
+ *
+ * @test
+ */
+ public function missingPropertyValueLenient(): void
+ {
+ $parsed = self::parsedStructureForFile('missing-property-value', Settings::create()->withLenientParsing(true));
+ $rulesets = $parsed->getAllRuleSets();
+ self::assertCount(1, $rulesets);
+ $block = $rulesets[0];
+ self::assertInstanceOf(DeclarationBlock::class, $block);
+ self::assertEquals([new Selector('div')], $block->getSelectors());
+ $rules = $block->getRules();
+ self::assertCount(1, $rules);
+ $rule = $rules[0];
+ self::assertSame('display', $rule->getRule());
+ self::assertSame('inline-block', $rule->getValue());
+ }
+
+ /**
+ * Parses structure for file.
+ *
+ * @param string $filename
+ * @param Settings|null $settings
+ */
+ public static function parsedStructureForFile($filename, $settings = null): Document
+ {
+ $filename = __DIR__ . "/fixtures/$filename.css";
+ $parser = new Parser(\file_get_contents($filename), $settings);
+ return $parser->parse();
+ }
+
+ /**
+ * @depends files
+ *
+ * @test
+ */
+ public function lineNumbersParsing(): void
+ {
+ $document = self::parsedStructureForFile('line-numbers');
+ // array key is the expected line number
+ $expected = [
+ 1 => [Charset::class],
+ 3 => [CSSNamespace::class],
+ 5 => [AtRuleSet::class],
+ 11 => [DeclarationBlock::class],
+ // Line Numbers of the inner declaration blocks
+ 17 => [KeyFrame::class, 18, 20],
+ 23 => [Import::class],
+ 25 => [DeclarationBlock::class],
+ ];
+
+ $actual = [];
+ foreach ($document->getContents() as $contentItem) {
+ self::assertInstanceOf(Positionable::class, $contentItem);
+ $actual[$contentItem->getLineNumber()] = [\get_class($contentItem)];
+ if ($contentItem instanceof KeyFrame) {
+ foreach ($contentItem->getContents() as $block) {
+ self::assertInstanceOf(Positionable::class, $block);
+ $actual[$contentItem->getLineNumber()][] = $block->getLineNumber();
+ }
+ }
+ }
+
+ $expectedLineNumbers = [7, 26];
+ $actualLineNumbers = [];
+ foreach ($document->getAllValues() as $value) {
+ if ($value instanceof URL) {
+ $actualLineNumbers[] = $value->getLineNo();
+ }
+ }
+
+ // Checking for the multiline color rule lines 27-31
+ $expectedColorLineNumbers = [28, 29, 30];
+ $declarationBlocks = $document->getAllDeclarationBlocks();
+ // Choose the 2nd one
+ $secondDeclarationBlock = $declarationBlocks[1];
+ $rules = $secondDeclarationBlock->getRules();
+ // Choose the 2nd one
+ $valueOfSecondRule = $rules[1]->getValue();
+ self::assertInstanceOf(Color::class, $valueOfSecondRule);
+ self::assertSame(27, $rules[1]->getLineNo());
+
+ $actualColorLineNumbers = [];
+ foreach ($valueOfSecondRule->getColor() as $size) {
+ $actualColorLineNumbers[] = $size->getLineNo();
+ }
+
+ self::assertSame($expectedColorLineNumbers, $actualColorLineNumbers);
+ self::assertSame($expectedLineNumbers, $actualLineNumbers);
+ self::assertSame($expected, $actual);
+ }
+
+ /**
+ * @test
+ */
+ public function unexpectedTokenExceptionLineNo(): void
+ {
+ $this->expectException(UnexpectedTokenException::class);
+
+ $parser = new Parser("\ntest: 1;", Settings::create()->beStrict());
+ try {
+ $parser->parse();
+ } catch (UnexpectedTokenException $e) {
+ self::assertSame(2, $e->getLineNo());
+ throw $e;
+ }
+ }
+
+ /**
+ * @depends files
+ *
+ * @test
+ */
+ public function commentExtracting(): void
+ {
+ $document = self::parsedStructureForFile('comments');
+ $nodes = $document->getContents();
+
+ // Import property.
+ self::assertInstanceOf(Commentable::class, $nodes[0]);
+ $importComments = $nodes[0]->getComments();
+ self::assertCount(2, $importComments);
+ self::assertSame("*\n * Comments\n ", $importComments[0]->getComment());
+ self::assertSame(' Hell ', $importComments[1]->getComment());
+
+ // Declaration block.
+ $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());
+
+ // Declaration rules.
+ self::assertInstanceOf(RuleSet::class, $fooBarBlock);
+ $fooBarRules = $fooBarBlock->getRules();
+ $fooBarRule = $fooBarRules[0];
+ $fooBarRuleComments = $fooBarRule->getComments();
+ self::assertCount(1, $fooBarRuleComments);
+ self::assertSame(' Number 6 ', $fooBarRuleComments[0]->getComment());
+
+ // Media property.
+ self::assertInstanceOf(Commentable::class, $nodes[2]);
+ $mediaComments = $nodes[2]->getComments();
+ self::assertCount(0, $mediaComments);
+
+ // Media children.
+ self::assertInstanceOf(CSSList::class, $nodes[2]);
+ $mediaRules = $nodes[2]->getContents();
+ self::assertInstanceOf(Commentable::class, $mediaRules[0]);
+ $fooBarComments = $mediaRules[0]->getComments();
+ self::assertCount(1, $fooBarComments);
+ self::assertSame('* Number 10 *', $fooBarComments[0]->getComment());
+
+ // Media -> declaration -> rule.
+ self::assertInstanceOf(RuleSet::class, $mediaRules[0]);
+ $fooBarRules = $mediaRules[0]->getRules();
+ $fooBarChildComments = $fooBarRules[0]->getComments();
+ self::assertCount(1, $fooBarChildComments);
+ self::assertSame('* Number 10b *', $fooBarChildComments[0]->getComment());
+ }
+
+ /**
+ * @test
+ */
+ public function flatCommentExtractingOneComment(): void
+ {
+ $parser = new Parser('div {/*Find Me!*/left:10px; text-align:left;}');
+ $document = $parser->parse();
+
+ $contents = $document->getContents();
+ self::assertInstanceOf(RuleSet::class, $contents[0]);
+ $divRules = $contents[0]->getRules();
+ $comments = $divRules[0]->getComments();
+
+ self::assertCount(1, $comments);
+ self::assertSame('Find Me!', $comments[0]->getComment());
+ }
+
+ /**
+ * @test
+ */
+ public function flatCommentExtractingTwoConjoinedCommentsForOneRule(): void
+ {
+ $parser = new Parser('div {/*Find Me!*//*Find Me Too!*/left:10px; text-align:left;}');
+ $document = $parser->parse();
+
+ $contents = $document->getContents();
+ self::assertInstanceOf(RuleSet::class, $contents[0]);
+ $divRules = $contents[0]->getRules();
+ $comments = $divRules[0]->getComments();
+
+ self::assertCount(2, $comments);
+ self::assertSame('Find Me!', $comments[0]->getComment());
+ self::assertSame('Find Me Too!', $comments[1]->getComment());
+ }
+
+ /**
+ * @test
+ */
+ public function flatCommentExtractingTwoSpaceSeparatedCommentsForOneRule(): void
+ {
+ $parser = new Parser('div { /*Find Me!*/ /*Find Me Too!*/ left:10px; text-align:left;}');
+ $document = $parser->parse();
+
+ $contents = $document->getContents();
+ self::assertInstanceOf(RuleSet::class, $contents[0]);
+ $divRules = $contents[0]->getRules();
+ $comments = $divRules[0]->getComments();
+
+ self::assertCount(2, $comments);
+ self::assertSame('Find Me!', $comments[0]->getComment());
+ self::assertSame('Find Me Too!', $comments[1]->getComment());
+ }
+
+ /**
+ * @test
+ */
+ public function flatCommentExtractingCommentsForTwoRules(): void
+ {
+ $parser = new Parser('div {/*Find Me!*/left:10px; /*Find Me Too!*/text-align:left;}');
+ $document = $parser->parse();
+
+ $contents = $document->getContents();
+ self::assertInstanceOf(RuleSet::class, $contents[0]);
+ $divRules = $contents[0]->getRules();
+ $rule1Comments = $divRules[0]->getComments();
+ $rule2Comments = $divRules[1]->getComments();
+
+ self::assertCount(1, $rule1Comments);
+ self::assertCount(1, $rule2Comments);
+ self::assertSame('Find Me!', $rule1Comments[0]->getComment());
+ self::assertSame('Find Me Too!', $rule2Comments[0]->getComment());
+ }
+
+ /**
+ * @test
+ */
+ public function topLevelCommentExtracting(): void
+ {
+ $parser = new Parser('/*Find Me!*/div {left:10px; text-align:left;}');
+ $document = $parser->parse();
+ $contents = $document->getContents();
+ self::assertInstanceOf(Commentable::class, $contents[0]);
+ $comments = $contents[0]->getComments();
+ self::assertCount(1, $comments);
+ self::assertSame('Find Me!', $comments[0]->getComment());
+ }
+
+ /**
+ * @test
+ */
+ public function microsoftFilterStrictParsing(): void
+ {
+ $this->expectException(UnexpectedTokenException::class);
+
+ $document = self::parsedStructureForFile('ms-filter', Settings::create()->beStrict());
+ }
+
+ /**
+ * @test
+ */
+ public function microsoftFilterParsing(): void
+ {
+ $document = self::parsedStructureForFile('ms-filter');
+ $expected = '.test {filter: progid:DXImageTransform.Microsoft.gradient(startColorstr="#80000000",'
+ . 'endColorstr="#00000000",GradientType=1);}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function largeSizeValuesInFile(): void
+ {
+ $document = self::parsedStructureForFile('large-z-index', Settings::create()->withMultibyteSupport(false));
+ $expected = '.overlay {z-index: 10000000000000000000000;}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function scientificNotationSizeValuesInFile(): void
+ {
+ $document = self::parsedStructureForFile(
+ 'scientific-notation-numbers',
+ Settings::create()->withMultibyteSupport(false)
+ );
+ $expected = ''
+ . 'body {background-color: rgba(62,174,151,3041820656523200167936);'
+ . 'z-index: .030418206565232;font-size: 1em;top: 192.3478px;}';
+ self::assertSame($expected, $document->render());
+ }
+
+ /**
+ * @test
+ */
+ public function lonelyImport(): void
+ {
+ $document = self::parsedStructureForFile('lonely-import');
+ $expected = '@import url("example.css") only screen and (max-width: 600px);';
+ self::assertSame($expected, $document->render());
+ }
+
+ public function escapedSpecialCaseTokens(): void
+ {
+ $document = self::parsedStructureForFile('escaped-tokens');
+ $contents = $document->getContents();
+ self::assertInstanceOf(RuleSet::class, $contents[0]);
+ $rules = $contents[0]->getRules();
+ $urlRule = $rules[0];
+ $calcRule = $rules[1];
+ self::assertInstanceOf(URL::class, $urlRule->getValue());
+ self::assertInstanceOf(CalcFunction::class, $calcRule->getValue());
+ }
+}
diff --git a/tests/RuleSet/DeclarationBlockTest.php b/tests/RuleSet/DeclarationBlockTest.php
new file mode 100644
index 000000000..5aaf0662c
--- /dev/null
+++ b/tests/RuleSet/DeclarationBlockTest.php
@@ -0,0 +1,136 @@
+parse();
+ $rule = new Rule('right');
+ $rule->setValue('-10px');
+ $contents = $document->getContents();
+ $wrapper = $contents[0];
+
+ self::assertInstanceOf(RuleSet::class, $wrapper);
+ self::assertCount(2, $wrapper->getRules());
+ $wrapper->setRules([$rule]);
+
+ $rules = $wrapper->getRules();
+ self::assertCount(1, $rules);
+ self::assertSame('right', $rules[0]->getRule());
+ self::assertSame('-10px', $rules[0]->getValue());
+ }
+
+ /**
+ * @test
+ */
+ public function ruleInsertion(): void
+ {
+ $css = '.wrapper { left: 10px; text-align: left; }';
+ $parser = new Parser($css);
+ $document = $parser->parse();
+ $contents = $document->getContents();
+ $wrapper = $contents[0];
+
+ self::assertInstanceOf(RuleSet::class, $wrapper);
+
+ $leftRules = $wrapper->getRules('left');
+ self::assertCount(1, $leftRules);
+ $firstLeftRule = $leftRules[0];
+
+ $textRules = $wrapper->getRules('text-');
+ self::assertCount(1, $textRules);
+ $firstTextRule = $textRules[0];
+
+ $leftPrefixRule = new Rule('left');
+ $leftPrefixRule->setValue(new Size(16, 'em'));
+
+ $textAlignRule = new Rule('text-align');
+ $textAlignRule->setValue(new Size(1));
+
+ $borderBottomRule = new Rule('border-bottom-width');
+ $borderBottomRule->setValue(new Size(1, 'px'));
+
+ $wrapper->addRule($borderBottomRule);
+ $wrapper->addRule($leftPrefixRule, $firstLeftRule);
+ $wrapper->addRule($textAlignRule, $firstTextRule);
+
+ $rules = $wrapper->getRules();
+
+ self::assertSame($leftPrefixRule, $rules[0]);
+ self::assertSame($firstLeftRule, $rules[1]);
+ self::assertSame($textAlignRule, $rules[2]);
+ self::assertSame($firstTextRule, $rules[3]);
+ self::assertSame($borderBottomRule, $rules[4]);
+
+ self::assertSame(
+ '.wrapper {left: 16em;left: 10px;text-align: 1;text-align: left;border-bottom-width: 1px;}',
+ $document->render()
+ );
+ }
+
+ /**
+ * @return array
+ */
+ public static function declarationBlocksWithCommentsProvider(): array
+ {
+ return [
+ 'CSS comments with one asterisk' => ['p {color: #000;/* black */}', 'p {color: #000;}'],
+ 'CSS comments with two asterisks' => ['p {color: #000;/** black */}', 'p {color: #000;}'],
+ ];
+ }
+
+ /**
+ * @test
+ * @dataProvider declarationBlocksWithCommentsProvider
+ */
+ public function canRemoveCommentsFromRulesUsingLenientParsing(
+ string $cssWithComments,
+ string $cssWithoutComments
+ ): void {
+ $parserSettings = ParserSettings::create()->withLenientParsing(true);
+ $document = (new Parser($cssWithComments, $parserSettings))->parse();
+
+ $outputFormat = (new OutputFormat())->setRenderComments(false);
+ $renderedDocument = $document->render($outputFormat);
+
+ self::assertSame($cssWithoutComments, $renderedDocument);
+ }
+
+ /**
+ * @test
+ * @dataProvider declarationBlocksWithCommentsProvider
+ */
+ public function canRemoveCommentsFromRulesUsingStrictParsing(
+ string $cssWithComments,
+ string $cssWithoutComments
+ ): void {
+ $parserSettings = ParserSettings::create()->withLenientParsing(false);
+ $document = (new Parser($cssWithComments, $parserSettings))->parse();
+
+ $outputFormat = (new OutputFormat())->setRenderComments(false);
+ $renderedDocument = $document->render($outputFormat);
+
+ self::assertSame($cssWithoutComments, $renderedDocument);
+ }
+}
diff --git a/tests/RuleSet/LenientParsingTest.php b/tests/RuleSet/LenientParsingTest.php
new file mode 100644
index 000000000..c014f021b
--- /dev/null
+++ b/tests/RuleSet/LenientParsingTest.php
@@ -0,0 +1,152 @@
+expectException(UnexpectedTokenException::class);
+
+ $pathToFile = __DIR__ . '/../fixtures/-fault-tolerance.css';
+ $parser = new Parser(\file_get_contents($pathToFile), Settings::create()->beStrict());
+ $parser->parse();
+ }
+
+ /**
+ * @test
+ */
+ public function faultToleranceOn(): void
+ {
+ $pathToFile = __DIR__ . '/../fixtures/-fault-tolerance.css';
+ $parser = new Parser(\file_get_contents($pathToFile), Settings::create()->withLenientParsing(true));
+ $result = $parser->parse();
+ self::assertSame(
+ '.test1 {}' . "\n" . '.test2 {hello: 2.2;hello: 2000000000000.2;}' . "\n" . '#test {}' . "\n"
+ . '#test2 {help: none;}',
+ $result->render()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function endToken(): void
+ {
+ $this->expectException(UnexpectedTokenException::class);
+
+ $pathToFile = __DIR__ . '/../fixtures/-end-token.css';
+ $parser = new Parser(\file_get_contents($pathToFile), Settings::create()->beStrict());
+ $parser->parse();
+ }
+
+ /**
+ * @test
+ */
+ public function endToken2(): void
+ {
+ $this->expectException(UnexpectedTokenException::class);
+
+ $pathToFile = __DIR__ . '/../fixtures/-end-token-2.css';
+ $parser = new Parser(\file_get_contents($pathToFile), Settings::create()->beStrict());
+ $parser->parse();
+ }
+
+ /**
+ * @test
+ */
+ public function endTokenPositive(): void
+ {
+ $pathToFile = __DIR__ . '/../fixtures/-end-token.css';
+ $parser = new Parser(\file_get_contents($pathToFile), Settings::create()->withLenientParsing(true));
+ $result = $parser->parse();
+ self::assertSame('', $result->render());
+ }
+
+ /**
+ * @test
+ */
+ public function endToken2Positive(): void
+ {
+ $pathToFile = __DIR__ . '/../fixtures/-end-token-2.css';
+ $parser = new Parser(\file_get_contents($pathToFile), Settings::create()->withLenientParsing(true));
+ $result = $parser->parse();
+ self::assertSame(
+ '#home .bg-layout {background-image: url("/bundles/main/img/bg1.png?5");}',
+ $result->render()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function localeTrap(): void
+ {
+ \setlocale(LC_ALL, 'pt_PT', 'no');
+ $pathToFile = __DIR__ . '/../fixtures/-fault-tolerance.css';
+ $parser = new Parser(\file_get_contents($pathToFile), Settings::create()->withLenientParsing(true));
+ $result = $parser->parse();
+ self::assertSame(
+ '.test1 {}' . "\n" . '.test2 {hello: 2.2;hello: 2000000000000.2;}' . "\n" . '#test {}' . "\n"
+ . '#test2 {help: none;}',
+ $result->render()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function caseInsensitivity(): void
+ {
+ $pathToFile = __DIR__ . '/../fixtures/case-insensitivity.css';
+ $parser = new Parser(\file_get_contents($pathToFile));
+ $result = $parser->parse();
+
+ self::assertSame(
+ '@charset "utf-8";' . "\n"
+ . '@import url("test.css");'
+ . "\n@media screen {}"
+ . "\n#myid {case: insensitive !important;frequency: 30Hz;font-size: 1em;color: #ff0;"
+ . 'color: hsl(40,40%,30%);font-family: Arial;}',
+ $result->render()
+ );
+ }
+
+ /**
+ * @test
+ */
+ public function cssWithInvalidColorStillGetsParsedAsDocument(): void
+ {
+ $pathToFile = __DIR__ . '/../fixtures/invalid-color.css';
+ $parser = new Parser(\file_get_contents($pathToFile), Settings::create()->withLenientParsing(true));
+ $result = $parser->parse();
+
+ self::assertInstanceOf(Document::class, $result);
+ }
+
+ /**
+ * @test
+ */
+ public function invalidColorStrict(): void
+ {
+ $this->expectException(UnexpectedTokenException::class);
+
+ $pathToFile = __DIR__ . '/../fixtures/invalid-color.css';
+ $parser = new Parser(\file_get_contents($pathToFile), Settings::create()->beStrict());
+ $parser->parse();
+ }
+}
diff --git a/tests/Sabberworm/CSS/CSSList/AtRuleBlockListTest.php b/tests/Sabberworm/CSS/CSSList/AtRuleBlockListTest.php
deleted file mode 100644
index 3d0268d39..000000000
--- a/tests/Sabberworm/CSS/CSSList/AtRuleBlockListTest.php
+++ /dev/null
@@ -1,28 +0,0 @@
-parse();
- $aContents = $oDoc->getContents();
- $oMediaQuery = $aContents[0];
- $this->assertSame('media', $oMediaQuery->atRuleName(), 'Does not interpret the type as a function');
- $this->assertSame('(min-width: 768px)', $oMediaQuery->atRuleArgs(), 'The media query is the value');
-
- $sCss = '@media (min-width: 768px) {.class{color:red}}';
- $oParser = new Parser($sCss);
- $oDoc = $oParser->parse();
- $aContents = $oDoc->getContents();
- $oMediaQuery = $aContents[0];
- $this->assertSame('media', $oMediaQuery->atRuleName(), 'Does not interpret the type as a function');
- $this->assertSame('(min-width: 768px)', $oMediaQuery->atRuleArgs(), 'The media query is the value');
- }
-}
diff --git a/tests/Sabberworm/CSS/CSSList/DocumentTest.php b/tests/Sabberworm/CSS/CSSList/DocumentTest.php
deleted file mode 100644
index 647b662a2..000000000
--- a/tests/Sabberworm/CSS/CSSList/DocumentTest.php
+++ /dev/null
@@ -1,27 +0,0 @@
-parse();
- $aContents = $oDoc->getContents();
- $this->assertCount(1, $aContents);
-
- $sCss2 = '.otherthing { right: 10px; }';
- $oParser2 = new Parser($sCss);
- $oDoc2 = $oParser2->parse();
- $aContents2 = $oDoc2->getContents();
-
- $oDoc->setContents([$aContents[0], $aContents2[0]]);
- $aFinalContents = $oDoc->getContents();
- $this->assertCount(2, $aFinalContents);
- }
-}
diff --git a/tests/Sabberworm/CSS/OutputFormatTest.php b/tests/Sabberworm/CSS/OutputFormatTest.php
deleted file mode 100644
index 45cab3f87..000000000
--- a/tests/Sabberworm/CSS/OutputFormatTest.php
+++ /dev/null
@@ -1,189 +0,0 @@
-oParser = new Parser($TEST_CSS);
- $this->oDocument = $this->oParser->parse();
- }
-
- public function testPlain()
- {
- $this->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->oDocument->render());
- }
-
- public function testCompact()
- {
- $this->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->oDocument->render(OutputFormat::createCompact()));
- }
-
- public function testPretty()
- {
- global $TEST_CSS;
- $this->assertSame($TEST_CSS, $this->oDocument->render(OutputFormat::createPretty()));
- }
-
- public function testSpaceAfterListArgumentSeparator()
- {
- $this->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->oDocument->render(OutputFormat::create()->setSpaceAfterListArgumentSeparator(" ")));
- }
-
- public function testSpaceAfterListArgumentSeparatorComplex()
- {
- $this->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->oDocument->render(OutputFormat::create()->setSpaceAfterListArgumentSeparator(['default' => ' ', ',' => "\t", '/' => '', ' ' => ''])));
- }
-
- public function testSpaceAfterSelectorSeparator()
- {
- $this->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->oDocument->render(OutputFormat::create()->setSpaceAfterSelectorSeparator("\n")));
- }
-
- public function testStringQuotingType()
- {
- $this->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->oDocument->render(OutputFormat::create()->setStringQuotingType("'")));
- }
-
- public function testRGBHashNotation()
- {
- $this->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);}}', $this->oDocument->render(OutputFormat::create()->setRGBHashNotation(false)));
- }
-
- public function testSemicolonAfterLastRule()
- {
- $this->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->oDocument->render(OutputFormat::create()->setSemicolonAfterLastRule(false)));
- }
-
- public function testSpaceAfterRuleName()
- {
- $this->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->oDocument->render(OutputFormat::create()->setSpaceAfterRuleName("\t")));
- }
-
- public function testSpaceRules()
- {
- $this->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->oDocument->render(OutputFormat::create()->set('Space*Rules', "\n")));
- }
-
- public function testSpaceBlocks()
- {
- $this->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->oDocument->render(OutputFormat::create()->set('Space*Blocks', "\n")));
- }
-
- public function testSpaceBoth()
- {
- $this->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->oDocument->render(OutputFormat::create()->set('Space*Rules', "\n")->set('Space*Blocks', "\n")));
- }
-
- public function testSpaceBetweenBlocks()
- {
- $this->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->oDocument->render(OutputFormat::create()->setSpaceBetweenBlocks('')));
- }
-
- public function testIndentation()
- {
- $this->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->oDocument->render(OutputFormat::create()->set('Space*Rules', "\n")->set('Space*Blocks', "\n")->setIndentation('')));
- }
-
- public function testSpaceBeforeBraces()
- {
- $this->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->oDocument->render(OutputFormat::create()->setSpaceBeforeOpeningBrace('')));
- }
-
- /**
- * @expectedException Sabberworm\CSS\Parsing\OutputException
- */
- public function testIgnoreExceptionsOff()
- {
- $aBlocks = $this->oDocument->getAllDeclarationBlocks();
- $oFirstBlock = $aBlocks[0];
- $oFirstBlock->removeSelector('.main');
- $this->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;}}', $this->oDocument->render(OutputFormat::create()->setIgnoreExceptions(false)));
- $oFirstBlock->removeSelector('.test');
- $this->oDocument->render(OutputFormat::create()->setIgnoreExceptions(false));
- }
-
- public function testIgnoreExceptionsOn()
- {
- $aBlocks = $this->oDocument->getAllDeclarationBlocks();
- $oFirstBlock = $aBlocks[0];
- $oFirstBlock->removeSelector('.main');
- $oFirstBlock->removeSelector('.test');
- $this->assertSame('@media screen {.main {background-size: 100% 100%;font-size: 1.3em;background-color: #fff;}}', $this->oDocument->render(OutputFormat::create()->setIgnoreExceptions(true)));
- }
-}
diff --git a/tests/Sabberworm/CSS/ParserTest.php b/tests/Sabberworm/CSS/ParserTest.php
deleted file mode 100644
index c96abb651..000000000
--- a/tests/Sabberworm/CSS/ParserTest.php
+++ /dev/null
@@ -1,850 +0,0 @@
-assertNotEquals('', $oParser->parse()->render());
- } catch (\Exception $e) {
- $this->fail($e);
- }
- }
- closedir($rHandle);
- }
- }
-
- /**
- * @depends testFiles
- */
- public function testColorParsing()
- {
- $oDoc = $this->parsedStructureForFile('colortest');
- foreach ($oDoc->getAllRuleSets() as $oRuleSet) {
- if (!$oRuleSet instanceof DeclarationBlock) {
- continue;
- }
- $sSelector = $oRuleSet->getSelectors();
- $sSelector = $sSelector[0]->getSelector();
- if ($sSelector === '#mine') {
- $aColorRule = $oRuleSet->getRules('color');
- $oColor = $aColorRule[0]->getValue();
- $this->assertSame('red', $oColor);
- $aColorRule = $oRuleSet->getRules('background-');
- $oColor = $aColorRule[0]->getValue();
- $this->assertEquals(['r' => new Size(35.0, null, true, $oColor->getLineNo()), 'g' => new Size(35.0, null, true, $oColor->getLineNo()), 'b' => new Size(35.0, null, true, $oColor->getLineNo())], $oColor->getColor());
- $aColorRule = $oRuleSet->getRules('border-color');
- $oColor = $aColorRule[0]->getValue();
- $this->assertEquals(['r' => new Size(10.0, null, true, $oColor->getLineNo()), 'g' => new Size(100.0, null, true, $oColor->getLineNo()), 'b' => new Size(230.0, null, true, $oColor->getLineNo())], $oColor->getColor());
- $oColor = $aColorRule[1]->getValue();
- $this->assertEquals(['r' => new Size(10.0, null, true, $oColor->getLineNo()), 'g' => new Size(100.0, null, true, $oColor->getLineNo()), 'b' => new Size(231.0, null, true, $oColor->getLineNo()), 'a' => new Size("0000.3", null, true, $oColor->getLineNo())], $oColor->getColor());
- $aColorRule = $oRuleSet->getRules('outline-color');
- $oColor = $aColorRule[0]->getValue();
- $this->assertEquals(['r' => new Size(34.0, null, true, $oColor->getLineNo()), 'g' => new Size(34.0, null, true, $oColor->getLineNo()), 'b' => new Size(34.0, null, true, $oColor->getLineNo())], $oColor->getColor());
- } elseif ($sSelector === '#yours') {
- $aColorRule = $oRuleSet->getRules('background-color');
- $oColor = $aColorRule[0]->getValue();
- $this->assertEquals(['h' => new Size(220.0, null, true, $oColor->getLineNo()), 's' => new Size(10.0, '%', true, $oColor->getLineNo()), 'l' => new Size(220.0, '%', true, $oColor->getLineNo())], $oColor->getColor());
- $oColor = $aColorRule[1]->getValue();
- $this->assertEquals(['h' => new Size(220.0, null, true, $oColor->getLineNo()), 's' => new Size(10.0, '%', true, $oColor->getLineNo()), 'l' => new Size(220.0, '%', true, $oColor->getLineNo()), 'a' => new Size(0000.3, null, true, $oColor->getLineNo())], $oColor->getColor());
- }
- }
- foreach ($oDoc->getAllValues('color') as $sColor) {
- $this->assertSame('red', $sColor);
- }
- $this->assertSame('#mine {color: red;border-color: #0a64e6;border-color: rgba(10,100,231,.3);outline-color: #222;background-color: #232323;}
-#yours {background-color: hsl(220,10%,220%);background-color: hsla(220,10%,220%,.3);}
-#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));}
-#variables-alpha {background-color: rgba(var(--some-rgb),.1);background-color: rgba(var(--some-rg),255,.1);background-color: hsla(var(--some-hsl),.1);}', $oDoc->render());
- }
-
- public function testUnicodeParsing()
- {
- $oDoc = $this->parsedStructureForFile('unicode');
- foreach ($oDoc->getAllDeclarationBlocks() as $oRuleSet) {
- $sSelector = $oRuleSet->getSelectors();
- $sSelector = $sSelector[0]->getSelector();
- if (substr($sSelector, 0, strlen('.test-')) !== '.test-') {
- continue;
- }
- $aContentRules = $oRuleSet->getRules('content');
- $aContents = $aContentRules[0]->getValues();
- $sString = $aContents[0][0]->__toString();
- if ($sSelector == '.test-1') {
- $this->assertSame('" "', $sString);
- }
- if ($sSelector == '.test-2') {
- $this->assertSame('"é"', $sString);
- }
- if ($sSelector == '.test-3') {
- $this->assertSame('" "', $sString);
- }
- if ($sSelector == '.test-4') {
- $this->assertSame('"𝄞"', $sString);
- }
- if ($sSelector == '.test-5') {
- $this->assertSame('"水"', $sString);
- }
- if ($sSelector == '.test-6') {
- $this->assertSame('"¥"', $sString);
- }
- if ($sSelector == '.test-7') {
- $this->assertSame('"\A"', $sString);
- }
- if ($sSelector == '.test-8') {
- $this->assertSame('"\"\""', $sString);
- }
- if ($sSelector == '.test-9') {
- $this->assertSame('"\"\\\'"', $sString);
- }
- if ($sSelector == '.test-10') {
- $this->assertSame('"\\\'\\\\"', $sString);
- }
- if ($sSelector == '.test-11') {
- $this->assertSame('"test"', $sString);
- }
- }
- }
-
- public function testUnicodeRangeParsing()
- {
- $oDoc = $this->parsedStructureForFile('unicode-range');
- $sExpected = "@font-face {unicode-range: U+0100-024F,U+0259,U+1E??-2EFF,U+202F;}";
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testSpecificity()
- {
- $oDoc = $this->parsedStructureForFile('specificity');
- $oDeclarationBlock = $oDoc->getAllDeclarationBlocks();
- $oDeclarationBlock = $oDeclarationBlock[0];
- $aSelectors = $oDeclarationBlock->getSelectors();
- foreach ($aSelectors as $oSelector) {
- switch ($oSelector->getSelector()) {
- case "#test .help":
- $this->assertSame(110, $oSelector->getSpecificity());
- break;
- case "#file":
- $this->assertSame(100, $oSelector->getSpecificity());
- break;
- case ".help:hover":
- $this->assertSame(20, $oSelector->getSpecificity());
- break;
- case "ol li::before":
- $this->assertSame(3, $oSelector->getSpecificity());
- break;
- case "li.green":
- $this->assertSame(11, $oSelector->getSpecificity());
- break;
- default:
- $this->fail("specificity: untested selector " . $oSelector->getSelector());
- }
- }
- $this->assertEquals([new Selector('#test .help', true)], $oDoc->getSelectorsBySpecificity('> 100'));
- $this->assertEquals([new Selector('#test .help', true), new Selector('#file', true)], $oDoc->getSelectorsBySpecificity('>= 100'));
- $this->assertEquals([new Selector('#file', true)], $oDoc->getSelectorsBySpecificity('=== 100'));
- $this->assertEquals([new Selector('#file', true)], $oDoc->getSelectorsBySpecificity('== 100'));
- $this->assertEquals([new Selector('#file', true), new Selector('.help:hover', true), new Selector('li.green', true), new Selector('ol li::before', true)], $oDoc->getSelectorsBySpecificity('<= 100'));
- $this->assertEquals([new Selector('.help:hover', true), new Selector('li.green', true), new Selector('ol li::before', true)], $oDoc->getSelectorsBySpecificity('< 100'));
- $this->assertEquals([new Selector('li.green', true)], $oDoc->getSelectorsBySpecificity('11'));
- $this->assertEquals([new Selector('ol li::before', true)], $oDoc->getSelectorsBySpecificity(3));
- }
-
- public function testManipulation()
- {
- $oDoc = $this->parsedStructureForFile('atrules');
- $this->assertSame('@charset "utf-8";
-@font-face {font-family: "CrassRoots";src: url("../media/cr.ttf");}
-html, body {font-size: -.6em;}
-@keyframes mymove {from {top: 0px;}
- to {top: 200px;}}
-@-moz-keyframes some-move {from {top: 0px;}
- to {top: 200px;}}
-@supports ( (perspective: 10px) or (-moz-perspective: 10px) or (-webkit-perspective: 10px) or (-ms-perspective: 10px) or (-o-perspective: 10px) ) {body {font-family: "Helvetica";}}
-@page :pseudo-class {margin: 2in;}
-@-moz-document url(https://www.w3.org/),
- url-prefix(https://www.w3.org/Style/),
- domain(mozilla.org),
- regexp("https:.*") {body {color: purple;background: yellow;}}
-@media screen and (orientation: landscape) {@-ms-viewport {width: 1024px;height: 768px;}}
-@region-style #intro {p {color: blue;}}', $oDoc->render());
- foreach ($oDoc->getAllDeclarationBlocks() as $oBlock) {
- foreach ($oBlock->getSelectors() as $oSelector) {
- //Loop over all selector parts (the comma-separated strings in a selector) and prepend the id
- $oSelector->setSelector('#my_id ' . $oSelector->getSelector());
- }
- }
- $this->assertSame('@charset "utf-8";
-@font-face {font-family: "CrassRoots";src: url("../media/cr.ttf");}
-#my_id html, #my_id body {font-size: -.6em;}
-@keyframes mymove {from {top: 0px;}
- to {top: 200px;}}
-@-moz-keyframes some-move {from {top: 0px;}
- to {top: 200px;}}
-@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";}}
-@page :pseudo-class {margin: 2in;}
-@-moz-document url(https://www.w3.org/),
- url-prefix(https://www.w3.org/Style/),
- domain(mozilla.org),
- regexp("https:.*") {#my_id body {color: purple;background: yellow;}}
-@media screen and (orientation: landscape) {@-ms-viewport {width: 1024px;height: 768px;}}
-@region-style #intro {#my_id p {color: blue;}}', $oDoc->render());
-
- $oDoc = $this->parsedStructureForFile('values');
- $this->assertSame('#header {margin: 10px 2em 1cm 2%;font-family: Verdana,Helvetica,"Gill Sans",sans-serif;font-size: 10px;color: red !important;background-color: green;background-color: rgba(0,128,0,.7);frequency: 30Hz;}
-body {color: green;font: 75% "Lucida Grande","Trebuchet MS",Verdana,sans-serif;}', $oDoc->render());
- foreach ($oDoc->getAllRuleSets() as $oRuleSet) {
- $oRuleSet->removeRule('font-');
- }
- $this->assertSame('#header {margin: 10px 2em 1cm 2%;color: red !important;background-color: green;background-color: rgba(0,128,0,.7);frequency: 30Hz;}
-body {color: green;}', $oDoc->render());
- foreach ($oDoc->getAllRuleSets() as $oRuleSet) {
- $oRuleSet->removeRule('background-');
- }
- $this->assertSame('#header {margin: 10px 2em 1cm 2%;color: red !important;frequency: 30Hz;}
-body {color: green;}', $oDoc->render());
- }
-
- public function testRuleGetters()
- {
- $oDoc = $this->parsedStructureForFile('values');
- $aBlocks = $oDoc->getAllDeclarationBlocks();
- $oHeaderBlock = $aBlocks[0];
- $oBodyBlock = $aBlocks[1];
- $aHeaderRules = $oHeaderBlock->getRules('background-');
- $this->assertCount(2, $aHeaderRules);
- $this->assertSame('background-color', $aHeaderRules[0]->getRule());
- $this->assertSame('background-color', $aHeaderRules[1]->getRule());
- $aHeaderRules = $oHeaderBlock->getRulesAssoc('background-');
- $this->assertCount(1, $aHeaderRules);
- $this->assertTrue($aHeaderRules['background-color']->getValue() instanceof \Sabberworm\CSS\Value\Color);
- $this->assertSame('rgba', $aHeaderRules['background-color']->getValue()->getColorDescription());
- $oHeaderBlock->removeRule($aHeaderRules['background-color']);
- $aHeaderRules = $oHeaderBlock->getRules('background-');
- $this->assertCount(1, $aHeaderRules);
- $this->assertSame('green', $aHeaderRules[0]->getValue());
- }
-
- public function testSlashedValues()
- {
- $oDoc = $this->parsedStructureForFile('slashed');
- $this->assertSame('.test {font: 12px/1.5 Verdana,Arial,sans-serif;border-radius: 5px 10px 5px 10px/10px 5px 10px 5px;}', $oDoc->render());
- foreach ($oDoc->getAllValues(null) as $mValue) {
- if ($mValue instanceof Size && $mValue->isSize() && !$mValue->isRelative()) {
- $mValue->setSize($mValue->getSize() * 3);
- }
- }
- foreach ($oDoc->getAllDeclarationBlocks() as $oBlock) {
- $oRule = $oBlock->getRules('font');
- $oRule = $oRule[0];
- $oSpaceList = $oRule->getValue();
- $this->assertEquals(' ', $oSpaceList->getListSeparator());
- $oSlashList = $oSpaceList->getListComponents();
- $oCommaList = $oSlashList[1];
- $oSlashList = $oSlashList[0];
- $this->assertEquals(',', $oCommaList->getListSeparator());
- $this->assertEquals('/', $oSlashList->getListSeparator());
- $oRule = $oBlock->getRules('border-radius');
- $oRule = $oRule[0];
- $oSlashList = $oRule->getValue();
- $this->assertEquals('/', $oSlashList->getListSeparator());
- $oSpaceList1 = $oSlashList->getListComponents();
- $oSpaceList2 = $oSpaceList1[1];
- $oSpaceList1 = $oSpaceList1[0];
- $this->assertEquals(' ', $oSpaceList1->getListSeparator());
- $this->assertEquals(' ', $oSpaceList2->getListSeparator());
- }
- $this->assertSame('.test {font: 36px/1.5 Verdana,Arial,sans-serif;border-radius: 15px 30px 15px 30px/30px 15px 30px 15px;}', $oDoc->render());
- }
-
- public function testFunctionSyntax()
- {
- $oDoc = $this->parsedStructureForFile('functions');
- $sExpected = 'div.main {background-image: linear-gradient(#000,#fff);}
-.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%;}
-.collapser.expanded::before, .collapser.expanded::-moz-before, .collapser.expanded::-webkit-before {-moz-transform: rotate(90deg);}
-.collapser + * {height: 0;overflow: hidden;-moz-transition-property: height;-moz-transition-duration: .3s;}
-.collapser.expanded + * {height: auto;}';
- $this->assertSame($sExpected, $oDoc->render());
-
- foreach ($oDoc->getAllValues(null, true) as $mValue) {
- if ($mValue instanceof Size && $mValue->isSize()) {
- $mValue->setSize($mValue->getSize() * 3);
- }
- }
- $sExpected = str_replace(['1.2em', '.2em', '60%'], ['3.6em', '.6em', '180%'], $sExpected);
- $this->assertSame($sExpected, $oDoc->render());
-
- foreach ($oDoc->getAllValues(null, true) as $mValue) {
- if ($mValue instanceof Size && !$mValue->isRelative() && !$mValue->isColorComponent()) {
- $mValue->setSize($mValue->getSize() * 2);
- }
- }
- $sExpected = str_replace(['.2s', '.3s', '90deg'], ['.4s', '.6s', '180deg'], $sExpected);
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testExpandShorthands()
- {
- $oDoc = $this->parsedStructureForFile('expand-shorthands');
- $sExpected = 'body {font: italic 500 14px/1.618 "Trebuchet MS",Georgia,serif;border: 2px solid #f0f;background: #ccc url("/images/foo.png") no-repeat left top;margin: 1em !important;padding: 2px 6px 3px;}';
- $this->assertSame($sExpected, $oDoc->render());
- $oDoc->expandShorthands();
- $sExpected = 'body {margin-top: 1em !important;margin-right: 1em !important;margin-bottom: 1em !important;margin-left: 1em !important;padding-top: 2px;padding-right: 6px;padding-bottom: 3px;padding-left: 6px;border-top-color: #f0f;border-right-color: #f0f;border-bottom-color: #f0f;border-left-color: #f0f;border-top-style: solid;border-right-style: solid;border-bottom-style: solid;border-left-style: solid;border-top-width: 2px;border-right-width: 2px;border-bottom-width: 2px;border-left-width: 2px;font-style: italic;font-variant: normal;font-weight: 500;font-size: 14px;line-height: 1.618;font-family: "Trebuchet MS",Georgia,serif;background-color: #ccc;background-image: url("/images/foo.png");background-repeat: no-repeat;background-attachment: scroll;background-position: left top;}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testCreateShorthands()
- {
- $oDoc = $this->parsedStructureForFile('create-shorthands');
- $sExpected = 'body {font-size: 2em;font-family: Helvetica,Arial,sans-serif;font-weight: bold;border-width: 2px;border-color: #999;border-style: dotted;background-color: #fff;background-image: url("foobar.png");background-repeat: repeat-y;margin-top: 2px;margin-right: 3px;margin-bottom: 4px;margin-left: 5px;}';
- $this->assertSame($sExpected, $oDoc->render());
- $oDoc->createShorthands();
- $sExpected = 'body {background: #fff url("foobar.png") repeat-y;margin: 2px 5px 4px 3px;border: 2px dotted #999;font: bold 2em Helvetica,Arial,sans-serif;}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testNamespaces()
- {
- $oDoc = $this->parsedStructureForFile('namespaces');
- $sExpected = '@namespace toto "http://toto.example.org";
-@namespace "http://example.com/foo";
-@namespace foo url("http://www.example.com/");
-@namespace foo url("http://www.example.com/");
-foo|test {gaga: 1;}
-|test {gaga: 2;}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testInnerColors()
- {
- $oDoc = $this->parsedStructureForFile('inner-color');
- $sExpected = 'test {background: -webkit-gradient(linear,0 0,0 bottom,from(#006cad),to(hsl(202,100%,49%)));}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testPrefixedGradient()
- {
- $oDoc = $this->parsedStructureForFile('webkit');
- $sExpected = '.test {background: -webkit-linear-gradient(top right,white,black);}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testListValueRemoval()
- {
- $oDoc = $this->parsedStructureForFile('atrules');
- foreach ($oDoc->getContents() as $oItem) {
- if ($oItem instanceof AtRule) {
- $oDoc->remove($oItem);
- continue;
- }
- }
- $this->assertSame('html, body {font-size: -.6em;}', $oDoc->render());
-
- $oDoc = $this->parsedStructureForFile('nested');
- foreach ($oDoc->getAllDeclarationBlocks() as $oBlock) {
- $oDoc->removeDeclarationBlockBySelector($oBlock, false);
- break;
- }
- $this->assertSame('html {some-other: -test(val1);}
-@media screen {html {some: -test(val2);}}
-#unrelated {other: yes;}', $oDoc->render());
-
- $oDoc = $this->parsedStructureForFile('nested');
- foreach ($oDoc->getAllDeclarationBlocks() as $oBlock) {
- $oDoc->removeDeclarationBlockBySelector($oBlock, true);
- break;
- }
- $this->assertSame('@media screen {html {some: -test(val2);}}
-#unrelated {other: yes;}', $oDoc->render());
- }
-
- /**
- * @expectedException Sabberworm\CSS\Parsing\OutputException
- */
- public function testSelectorRemoval()
- {
- $oDoc = $this->parsedStructureForFile('1readme');
- $aBlocks = $oDoc->getAllDeclarationBlocks();
- $oBlock1 = $aBlocks[0];
- $this->assertTrue($oBlock1->removeSelector('html'));
- $sExpected = '@charset "utf-8";
-@font-face {font-family: "CrassRoots";src: url("../media/cr.ttf");}
-body {font-size: 1.6em;}';
- $this->assertSame($sExpected, $oDoc->render());
- $this->assertFalse($oBlock1->removeSelector('html'));
- $this->assertTrue($oBlock1->removeSelector('body'));
- // This tries to output a declaration block without a selector and throws.
- $oDoc->render();
- }
-
- public function testComments()
- {
- $oDoc = $this->parsedStructureForFile('comments');
- $sExpected = '@import url("some/url.css") screen;
-.foo, #bar {background-color: #000;}
-@media screen {#foo.bar {position: absolute;}}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testUrlInFile()
- {
- $oDoc = $this->parsedStructureForFile('url', Settings::create()->withMultibyteSupport(true));
- $sExpected = 'body {background: #fff url("https://somesite.com/images/someimage.gif") repeat top center;}
-body {background-url: url("https://somesite.com/images/someimage.gif");}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testHexAlphaInFile()
- {
- $oDoc = $this->parsedStructureForFile('hex-alpha', Settings::create()->withMultibyteSupport(true));
- $sExpected = 'div {background: rgba(17,34,51,.27);}
-div {background: rgba(17,34,51,.27);}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testCalcInFile()
- {
- $oDoc = $this->parsedStructureForFile('calc', Settings::create()->withMultibyteSupport(true));
- $sExpected = 'div {width: calc(100% / 4);}
-div {margin-top: calc(-120% - 4px);}
-div {height: -webkit-calc(9 / 16 * 100%) !important;width: -moz-calc(( 50px - 50% ) * 2);}
-div {width: calc(50% - ( ( 4% ) * .5 ));}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testCalcNestedInFile()
- {
- $oDoc = $this->parsedStructureForFile('calc-nested', Settings::create()->withMultibyteSupport(true));
- $sExpected = '.test {font-size: calc(( 3 * 4px ) + -2px);top: calc(200px - calc(20 * 3px));}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testGridLineNameInFile()
- {
- $oDoc = $this->parsedStructureForFile('grid-linename', Settings::create()->withMultibyteSupport(true));
- $sExpected = "div {grid-template-columns: [linename] 100px;}\nspan {grid-template-columns: [linename1 linename2] 100px;}";
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testEmptyGridLineNameLenientInFile()
- {
- $oDoc = $this->parsedStructureForFile('empty-grid-linename');
- $sExpected = '.test {grid-template-columns: [] 100px;}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testInvalidGridLineNameInFile()
- {
- $oDoc = $this->parsedStructureForFile('invalid-grid-linename', Settings::create()->withMultibyteSupport(true));
- $sExpected = "div {}";
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testUnmatchedBracesInFile()
- {
- $oDoc = $this->parsedStructureForFile('unmatched_braces', Settings::create()->withMultibyteSupport(true));
- $sExpected = 'button, input, checkbox, textarea {outline: 0;margin: 0;}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testInvalidSelectorsInFile()
- {
- $oDoc = $this->parsedStructureForFile('invalid-selectors', Settings::create()->withMultibyteSupport(true));
- $sExpected = '@keyframes mymove {from {top: 0px;}}
-#test {color: white;background: green;}
-#test {display: block;background: white;color: black;}';
- $this->assertSame($sExpected, $oDoc->render());
-
- $oDoc = $this->parsedStructureForFile('invalid-selectors-2', Settings::create()->withMultibyteSupport(true));
- $sExpected = '@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;}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testSelectorEscapesInFile()
- {
- $oDoc = $this->parsedStructureForFile('selector-escapes', Settings::create()->withMultibyteSupport(true));
- $sExpected = '#\# {color: red;}
-.col-sm-1\/5 {width: 20%;}';
- $this->assertSame($sExpected, $oDoc->render());
-
- $oDoc = $this->parsedStructureForFile('invalid-selectors-2', Settings::create()->withMultibyteSupport(true));
- $sExpected = '@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;}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testIdentifierEscapesInFile()
- {
- $oDoc = $this->parsedStructureForFile('identifier-escapes', Settings::create()->withMultibyteSupport(true));
- $sExpected = 'div {font: 14px Font Awesome\ 5 Pro;font: 14px Font Awesome\} 5 Pro;font: 14px Font Awesome\; 5 Pro;f\;ont: 14px Font Awesome\; 5 Pro;}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testSelectorIgnoresInFile()
- {
- $oDoc = $this->parsedStructureForFile('selector-ignores', Settings::create()->withMultibyteSupport(true));
- $sExpected = '.some[selectors-may=\'contain-a-{\'] {}
-.this-selector .valid {width: 100px;}
-@media only screen and (min-width: 200px) {.test {prop: val;}}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testKeyframeSelectors()
- {
- $oDoc = $this->parsedStructureForFile('keyframe-selector-validation', Settings::create()->withMultibyteSupport(true));
- $sExpected = '@-webkit-keyframes zoom {0% {-webkit-transform: scale(1,1);}
- 50% {-webkit-transform: scale(1.2,1.2);}
- 100% {-webkit-transform: scale(1,1);}}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- /**
- * @expectedException Sabberworm\CSS\Parsing\UnexpectedTokenException
- */
- public function testLineNameFailure()
- {
- $this->parsedStructureForFile('-empty-grid-linename', Settings::create()->withLenientParsing(false));
- }
-
- /**
- * @expectedException Sabberworm\CSS\Parsing\UnexpectedTokenException
- */
- public function testCalcFailure()
- {
- $this->parsedStructureForFile('-calc-no-space-around-minus', Settings::create()->withLenientParsing(false));
- }
-
- public function testUrlInFileMbOff()
- {
- $oDoc = $this->parsedStructureForFile('url', Settings::create()->withMultibyteSupport(false));
- $sExpected = 'body {background: #fff url("https://somesite.com/images/someimage.gif") repeat top center;}
-body {background-url: url("https://somesite.com/images/someimage.gif");}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testEmptyFile()
- {
- $oDoc = $this->parsedStructureForFile('-empty', Settings::create()->withMultibyteSupport(true));
- $sExpected = '';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testEmptyFileMbOff()
- {
- $oDoc = $this->parsedStructureForFile('-empty', Settings::create()->withMultibyteSupport(false));
- $sExpected = '';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testCharsetLenient1()
- {
- $oDoc = $this->parsedStructureForFile('-charset-after-rule', Settings::create()->withLenientParsing(true));
- $sExpected = '#id {prop: var(--val);}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testCharsetLenient2()
- {
- $oDoc = $this->parsedStructureForFile('-charset-in-block', Settings::create()->withLenientParsing(true));
- $sExpected = '@media print {}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testTrailingWhitespace()
- {
- $oDoc = $this->parsedStructureForFile('trailing-whitespace', Settings::create()->withLenientParsing(false));
- $sExpected = 'div {width: 200px;}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- /**
- * @expectedException \Sabberworm\CSS\Parsing\UnexpectedTokenException
- */
- public function testCharsetFailure1()
- {
- $this->parsedStructureForFile('-charset-after-rule', Settings::create()->withLenientParsing(false));
- }
-
- /**
- * @expectedException \Sabberworm\CSS\Parsing\UnexpectedTokenException
- */
- public function testCharsetFailure2()
- {
- $this->parsedStructureForFile('-charset-in-block', Settings::create()->withLenientParsing(false));
- }
-
- /**
- * @expectedException \Sabberworm\CSS\Parsing\SourceException
- */
- public function testUnopenedClosingBracketFailure()
- {
- $this->parsedStructureForFile('-unopened-close-brackets', Settings::create()->withLenientParsing(false));
- }
-
- /**
- * Ensure that a missing property value raises an exception.
- *
- * @expectedException \Sabberworm\CSS\Parsing\UnexpectedTokenException
- * @covers \Sabberworm\CSS\Value\Value::parseValue()
- */
- public function testMissingPropertyValueStrict()
- {
- $this->parsedStructureForFile('missing-property-value', Settings::create()->withLenientParsing(false));
- }
-
- /**
- * Ensure that a missing property value is ignored when in lenient parsing mode.
- *
- * @covers \Sabberworm\CSS\Value\Value::parseValue()
- */
- public function testMissingPropertyValueLenient()
- {
- $parsed = $this->parsedStructureForFile('missing-property-value', Settings::create()->withLenientParsing(true));
- $rulesets = $parsed->getAllRuleSets();
- $this->assertCount(1, $rulesets);
- $block = $rulesets[0];
- $this->assertTrue($block instanceof DeclarationBlock);
- $this->assertEquals(['div'], $block->getSelectors());
- $rules = $block->getRules();
- $this->assertCount(1, $rules);
- $rule = $rules[0];
- $this->assertEquals('display', $rule->getRule());
- $this->assertEquals('inline-block', $rule->getValue());
- }
-
- /**
- * Parse structure for file.
- *
- * @param string $sFileName Filename.
- * @param null|obJeCt $oSettings Settings.
- *
- * @return CSSList\Document Parsed document.
- */
- private function parsedStructureForFile($sFileName, $oSettings = null)
- {
- $sFile = __DIR__ . '/../../files' . DIRECTORY_SEPARATOR . "$sFileName.css";
- $oParser = new Parser(file_get_contents($sFile), $oSettings);
- return $oParser->parse();
- }
-
- /**
- * @depends testFiles
- */
- public function testLineNumbersParsing()
- {
- $oDoc = $this->parsedStructureForFile('line-numbers');
- // array key is the expected line number
- $aExpected = [
- 1 => [Charset::class],
- 3 => [CSSNamespace::class],
- 5 => [AtRuleSet::class],
- 11 => [DeclarationBlock::class],
- // Line Numbers of the inner declaration blocks
- 17 => [KeyFrame::class, 18, 20],
- 23 => [Import::class],
- 25 => [DeclarationBlock::class]
- ];
-
- $aActual = [];
- foreach ($oDoc->getContents() as $oContent) {
- $aActual[$oContent->getLineNo()] = [get_class($oContent)];
- if ($oContent instanceof KeyFrame) {
- foreach ($oContent->getContents() as $block) {
- $aActual[$oContent->getLineNo()][] = $block->getLineNo();
- }
- }
- }
-
- $aUrlExpected = [7, 26]; // expected line numbers
- $aUrlActual = [];
- foreach ($oDoc->getAllValues() as $oValue) {
- if ($oValue instanceof URL) {
- $aUrlActual[] = $oValue->getLineNo();
- }
- }
-
- // Checking for the multiline color rule lines 27-31
- $aExpectedColorLines = [28, 29, 30];
- $aDeclBlocks = $oDoc->getAllDeclarationBlocks();
- // Choose the 2nd one
- $oDeclBlock = $aDeclBlocks[1];
- $aRules = $oDeclBlock->getRules();
- // Choose the 2nd one
- $oColor = $aRules[1]->getValue();
- $this->assertEquals(27, $aRules[1]->getLineNo());
-
- foreach ($oColor->getColor() as $oSize) {
- $aActualColorLines[] = $oSize->getLineNo();
- }
-
- $this->assertEquals($aExpectedColorLines, $aActualColorLines);
- $this->assertEquals($aUrlExpected, $aUrlActual);
- $this->assertEquals($aExpected, $aActual);
- }
-
- /**
- * @expectedException \Sabberworm\CSS\Parsing\UnexpectedTokenException
- * Credit: This test by @sabberworm (from https://github.com/sabberworm/PHP-CSS-Parser/pull/105#issuecomment-229643910 )
- */
- public function testUnexpectedTokenExceptionLineNo()
- {
- $oParser = new Parser("\ntest: 1;", Settings::create()->beStrict());
- try {
- $oParser->parse();
- } catch (UnexpectedTokenException $e) {
- $this->assertSame(2, $e->getLineNo());
- throw $e;
- }
- }
-
- /**
- * @expectedException Sabberworm\CSS\Parsing\UnexpectedTokenException
- */
- public function testIeHacksStrictParsing()
- {
- // We can't strictly parse IE hacks.
- $this->parsedStructureForFile('ie-hacks', Settings::create()->beStrict());
- }
-
- public function testIeHacksParsing()
- {
- $oDoc = $this->parsedStructureForFile('ie-hacks', Settings::create()->withLenientParsing(true));
- $sExpected = 'p {padding-right: .75rem \9;background-image: none \9;color: red \9\0;background-color: red \9\0;background-color: red \9\0 !important;content: "red \0";content: "red઼";}';
- $this->assertEquals($sExpected, $oDoc->render());
- }
-
- /**
- * @depends testFiles
- */
- public function testCommentExtracting()
- {
- $oDoc = $this->parsedStructureForFile('comments');
- $aNodes = $oDoc->getContents();
-
- // Import property.
- $importComments = $aNodes[0]->getComments();
- $this->assertCount(1, $importComments);
- $this->assertEquals("*\n * Comments Hell.\n ", $importComments[0]->getComment());
-
- // Declaration block.
- $fooBarBlock = $aNodes[1];
- $fooBarBlockComments = $fooBarBlock->getComments();
- // TODO Support comments in selectors.
- // $this->assertCount(2, $fooBarBlockComments);
- // $this->assertEquals("* Number 4 *", $fooBarBlockComments[0]->getComment());
- // $this->assertEquals("* Number 5 *", $fooBarBlockComments[1]->getComment());
-
- // Declaration rules.
- $fooBarRules = $fooBarBlock->getRules();
- $fooBarRule = $fooBarRules[0];
- $fooBarRuleComments = $fooBarRule->getComments();
- $this->assertCount(1, $fooBarRuleComments);
- $this->assertEquals(" Number 6 ", $fooBarRuleComments[0]->getComment());
-
- // Media property.
- $mediaComments = $aNodes[2]->getComments();
- $this->assertCount(0, $mediaComments);
-
- // Media children.
- $mediaRules = $aNodes[2]->getContents();
- $fooBarComments = $mediaRules[0]->getComments();
- $this->assertCount(1, $fooBarComments);
- $this->assertEquals("* Number 10 *", $fooBarComments[0]->getComment());
-
- // Media -> declaration -> rule.
- $fooBarRules = $mediaRules[0]->getRules();
- $fooBarChildComments = $fooBarRules[0]->getComments();
- $this->assertCount(1, $fooBarChildComments);
- $this->assertEquals("* Number 10b *", $fooBarChildComments[0]->getComment());
- }
-
- public function testFlatCommentExtracting()
- {
- $parser = new Parser('div {/*Find Me!*/left:10px; text-align:left;}');
- $doc = $parser->parse();
- $contents = $doc->getContents();
- $divRules = $contents[0]->getRules();
- $comments = $divRules[0]->getComments();
- $this->assertCount(1, $comments);
- $this->assertEquals("Find Me!", $comments[0]->getComment());
- }
-
- public function testTopLevelCommentExtracting()
- {
- $parser = new Parser('/*Find Me!*/div {left:10px; text-align:left;}');
- $doc = $parser->parse();
- $contents = $doc->getContents();
- $comments = $contents[0]->getComments();
- $this->assertCount(1, $comments);
- $this->assertEquals("Find Me!", $comments[0]->getComment());
- }
-
- /**
- * @expectedException Sabberworm\CSS\Parsing\UnexpectedTokenException
- */
- public function testMicrosoftFilterStrictParsing()
- {
- $oDoc = $this->parsedStructureForFile('ms-filter', Settings::create()->beStrict());
- }
-
- public function testMicrosoftFilterParsing()
- {
- $oDoc = $this->parsedStructureForFile('ms-filter');
- $sExpected = ".test {filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=\"#80000000\",endColorstr=\"#00000000\",GradientType=1);}";
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testLargeSizeValuesInFile()
- {
- $oDoc = $this->parsedStructureForFile('large-z-index', Settings::create()->withMultibyteSupport(false));
- $sExpected = '.overlay {z-index: 10000000000000000000000;}';
- $this->assertSame($sExpected, $oDoc->render());
- }
-
- public function testLonelyImport()
- {
- $oDoc = $this->parsedStructureForFile('lonely-import');
- $sExpected = "@import url(\"example.css\") only screen and (max-width: 600px);";
- $this->assertSame($sExpected, $oDoc->render());
- }
-}
diff --git a/tests/Sabberworm/CSS/RuleSet/DeclarationBlockTest.php b/tests/Sabberworm/CSS/RuleSet/DeclarationBlockTest.php
deleted file mode 100644
index f36ea4566..000000000
--- a/tests/Sabberworm/CSS/RuleSet/DeclarationBlockTest.php
+++ /dev/null
@@ -1,308 +0,0 @@
-parse();
- foreach ($oDoc->getAllDeclarationBlocks() as $oDeclaration) {
- $oDeclaration->expandBorderShorthand();
- }
- $this->assertSame(trim((string) $oDoc), $sExpected);
- }
-
- public function expandBorderShorthandProvider()
- {
- return [
- ['body{ border: 2px solid #000 }', 'body {border-width: 2px;border-style: solid;border-color: #000;}'],
- ['body{ border: none }', 'body {border-style: none;}'],
- ['body{ border: 2px }', 'body {border-width: 2px;}'],
- ['body{ border: #f00 }', 'body {border-color: #f00;}'],
- ['body{ border: 1em solid }', 'body {border-width: 1em;border-style: solid;}'],
- ['body{ margin: 1em; }', 'body {margin: 1em;}']
- ];
- }
-
- /**
- * @dataProvider expandFontShorthandProvider
- * */
- public function testExpandFontShorthand($sCss, $sExpected)
- {
- $oParser = new Parser($sCss);
- $oDoc = $oParser->parse();
- foreach ($oDoc->getAllDeclarationBlocks() as $oDeclaration) {
- $oDeclaration->expandFontShorthand();
- }
- $this->assertSame(trim((string) $oDoc), $sExpected);
- }
-
- public function expandFontShorthandProvider()
- {
- return [
- [
- 'body{ margin: 1em; }',
- 'body {margin: 1em;}'
- ],
- [
- 'body {font: 12px serif;}',
- 'body {font-style: normal;font-variant: normal;font-weight: normal;font-size: 12px;line-height: normal;font-family: serif;}'
- ],
- [
- 'body {font: italic 12px serif;}',
- 'body {font-style: italic;font-variant: normal;font-weight: normal;font-size: 12px;line-height: normal;font-family: serif;}'
- ],
- [
- 'body {font: italic bold 12px serif;}',
- 'body {font-style: italic;font-variant: normal;font-weight: bold;font-size: 12px;line-height: normal;font-family: serif;}'
- ],
- [
- 'body {font: italic bold 12px/1.6 serif;}',
- 'body {font-style: italic;font-variant: normal;font-weight: bold;font-size: 12px;line-height: 1.6;font-family: serif;}'
- ],
- [
- 'body {font: italic small-caps bold 12px/1.6 serif;}',
- 'body {font-style: italic;font-variant: small-caps;font-weight: bold;font-size: 12px;line-height: 1.6;font-family: serif;}'
- ],
- ];
- }
-
- /**
- * @dataProvider expandBackgroundShorthandProvider
- * */
- public function testExpandBackgroundShorthand($sCss, $sExpected)
- {
- $oParser = new Parser($sCss);
- $oDoc = $oParser->parse();
- foreach ($oDoc->getAllDeclarationBlocks() as $oDeclaration) {
- $oDeclaration->expandBackgroundShorthand();
- }
- $this->assertSame(trim((string) $oDoc), $sExpected);
- }
-
- public function expandBackgroundShorthandProvider()
- {
- return [
- ['body {border: 1px;}', 'body {border: 1px;}'],
- ['body {background: #f00;}', 'body {background-color: #f00;background-image: none;background-repeat: repeat;background-attachment: scroll;background-position: 0% 0%;}'],
- ['body {background: #f00 url("foobar.png");}', 'body {background-color: #f00;background-image: url("foobar.png");background-repeat: repeat;background-attachment: scroll;background-position: 0% 0%;}'],
- ['body {background: #f00 url("foobar.png") no-repeat;}', 'body {background-color: #f00;background-image: url("foobar.png");background-repeat: no-repeat;background-attachment: scroll;background-position: 0% 0%;}'],
- ['body {background: #f00 url("foobar.png") no-repeat center;}', 'body {background-color: #f00;background-image: url("foobar.png");background-repeat: no-repeat;background-attachment: scroll;background-position: center center;}'],
- ['body {background: #f00 url("foobar.png") no-repeat top left;}', 'body {background-color: #f00;background-image: url("foobar.png");background-repeat: no-repeat;background-attachment: scroll;background-position: top left;}'],
- ];
- }
-
- /**
- * @dataProvider expandDimensionsShorthandProvider
- * */
- public function testExpandDimensionsShorthand($sCss, $sExpected)
- {
- $oParser = new Parser($sCss);
- $oDoc = $oParser->parse();
- foreach ($oDoc->getAllDeclarationBlocks() as $oDeclaration) {
- $oDeclaration->expandDimensionsShorthand();
- }
- $this->assertSame(trim((string) $oDoc), $sExpected);
- }
-
- public function expandDimensionsShorthandProvider()
- {
- return [
- ['body {border: 1px;}', 'body {border: 1px;}'],
- ['body {margin-top: 1px;}', 'body {margin-top: 1px;}'],
- ['body {margin: 1em;}', 'body {margin-top: 1em;margin-right: 1em;margin-bottom: 1em;margin-left: 1em;}'],
- ['body {margin: 1em 2em;}', 'body {margin-top: 1em;margin-right: 2em;margin-bottom: 1em;margin-left: 2em;}'],
- ['body {margin: 1em 2em 3em;}', 'body {margin-top: 1em;margin-right: 2em;margin-bottom: 3em;margin-left: 2em;}'],
- ];
- }
-
- /**
- * @dataProvider createBorderShorthandProvider
- * */
- public function testCreateBorderShorthand($sCss, $sExpected)
- {
- $oParser = new Parser($sCss);
- $oDoc = $oParser->parse();
- foreach ($oDoc->getAllDeclarationBlocks() as $oDeclaration) {
- $oDeclaration->createBorderShorthand();
- }
- $this->assertSame(trim((string) $oDoc), $sExpected);
- }
-
- public function createBorderShorthandProvider()
- {
- return [
- ['body {border-width: 2px;border-style: solid;border-color: #000;}', 'body {border: 2px solid #000;}'],
- ['body {border-style: none;}', 'body {border: none;}'],
- ['body {border-width: 1em;border-style: solid;}', 'body {border: 1em solid;}'],
- ['body {margin: 1em;}', 'body {margin: 1em;}']
- ];
- }
-
- /**
- * @dataProvider createFontShorthandProvider
- * */
- public function testCreateFontShorthand($sCss, $sExpected)
- {
- $oParser = new Parser($sCss);
- $oDoc = $oParser->parse();
- foreach ($oDoc->getAllDeclarationBlocks() as $oDeclaration) {
- $oDeclaration->createFontShorthand();
- }
- $this->assertSame(trim((string) $oDoc), $sExpected);
- }
-
- public function createFontShorthandProvider()
- {
- return [
- ['body {font-size: 12px; font-family: serif}', 'body {font: 12px serif;}'],
- ['body {font-size: 12px; font-family: serif; font-style: italic;}', 'body {font: italic 12px serif;}'],
- ['body {font-size: 12px; font-family: serif; font-style: italic; font-weight: bold;}', 'body {font: italic bold 12px serif;}'],
- ['body {font-size: 12px; font-family: serif; font-style: italic; font-weight: bold; line-height: 1.6;}', 'body {font: italic bold 12px/1.6 serif;}'],
- ['body {font-size: 12px; font-family: serif; font-style: italic; font-weight: bold; line-height: 1.6; font-variant: small-caps;}', 'body {font: italic small-caps bold 12px/1.6 serif;}'],
- ['body {margin: 1em;}', 'body {margin: 1em;}']
- ];
- }
-
- /**
- * @dataProvider createDimensionsShorthandProvider
- * */
- public function testCreateDimensionsShorthand($sCss, $sExpected)
- {
- $oParser = new Parser($sCss);
- $oDoc = $oParser->parse();
- foreach ($oDoc->getAllDeclarationBlocks() as $oDeclaration) {
- $oDeclaration->createDimensionsShorthand();
- }
- $this->assertSame(trim((string) $oDoc), $sExpected);
- }
-
- public function createDimensionsShorthandProvider()
- {
- return [
- ['body {border: 1px;}', 'body {border: 1px;}'],
- ['body {margin-top: 1px;}', 'body {margin-top: 1px;}'],
- ['body {margin-top: 1em; margin-right: 1em; margin-bottom: 1em; margin-left: 1em;}', 'body {margin: 1em;}'],
- ['body {margin-top: 1em; margin-right: 2em; margin-bottom: 1em; margin-left: 2em;}', 'body {margin: 1em 2em;}'],
- ['body {margin-top: 1em; margin-right: 2em; margin-bottom: 3em; margin-left: 2em;}', 'body {margin: 1em 2em 3em;}'],
- ];
- }
-
- /**
- * @dataProvider createBackgroundShorthandProvider
- * */
- public function testCreateBackgroundShorthand($sCss, $sExpected)
- {
- $oParser = new Parser($sCss);
- $oDoc = $oParser->parse();
- foreach ($oDoc->getAllDeclarationBlocks() as $oDeclaration) {
- $oDeclaration->createBackgroundShorthand();
- }
- $this->assertSame(trim((string) $oDoc), $sExpected);
- }
-
- public function createBackgroundShorthandProvider()
- {
- return [
- ['body {border: 1px;}', 'body {border: 1px;}'],
- ['body {background-color: #f00;}', 'body {background: #f00;}'],
- ['body {background-color: #f00;background-image: url(foobar.png);}', 'body {background: #f00 url("foobar.png");}'],
- ['body {background-color: #f00;background-image: url(foobar.png);background-repeat: no-repeat;}', 'body {background: #f00 url("foobar.png") no-repeat;}'],
- ['body {background-color: #f00;background-image: url(foobar.png);background-repeat: no-repeat;}', 'body {background: #f00 url("foobar.png") no-repeat;}'],
- ['body {background-color: #f00;background-image: url(foobar.png);background-repeat: no-repeat;background-position: center;}', 'body {background: #f00 url("foobar.png") no-repeat center;}'],
- ['body {background-color: #f00;background-image: url(foobar.png);background-repeat: no-repeat;background-position: top left;}', 'body {background: #f00 url("foobar.png") no-repeat top left;}'],
- ];
- }
-
- public function testOverrideRules()
- {
- $sCss = '.wrapper { left: 10px; text-align: left; }';
- $oParser = new Parser($sCss);
- $oDoc = $oParser->parse();
- $oRule = new Rule('right');
- $oRule->setValue('-10px');
- $aContents = $oDoc->getContents();
- $oWrapper = $aContents[0];
-
- $this->assertCount(2, $oWrapper->getRules());
- $aContents[0]->setRules([$oRule]);
-
- $aRules = $oWrapper->getRules();
- $this->assertCount(1, $aRules);
- $this->assertEquals('right', $aRules[0]->getRule());
- $this->assertEquals('-10px', $aRules[0]->getValue());
- }
-
- public function testRuleInsertion()
- {
- $sCss = '.wrapper { left: 10px; text-align: left; }';
- $oParser = new Parser($sCss);
- $oDoc = $oParser->parse();
- $aContents = $oDoc->getContents();
- $oWrapper = $aContents[0];
-
- $oFirst = $oWrapper->getRules('left');
- $this->assertCount(1, $oFirst);
- $oFirst = $oFirst[0];
-
- $oSecond = $oWrapper->getRules('text-');
- $this->assertCount(1, $oSecond);
- $oSecond = $oSecond[0];
-
- $oBefore = new Rule('left');
- $oBefore->setValue(new Size(16, 'em'));
-
- $oMiddle = new Rule('text-align');
- $oMiddle->setValue(new Size(1));
-
- $oAfter = new Rule('border-bottom-width');
- $oAfter->setValue(new Size(1, 'px'));
-
- $oWrapper->addRule($oAfter);
- $oWrapper->addRule($oBefore, $oFirst);
- $oWrapper->addRule($oMiddle, $oSecond);
-
- $aRules = $oWrapper->getRules();
-
- $this->assertSame($oBefore, $aRules[0]);
- $this->assertSame($oFirst, $aRules[1]);
- $this->assertSame($oMiddle, $aRules[2]);
- $this->assertSame($oSecond, $aRules[3]);
- $this->assertSame($oAfter, $aRules[4]);
-
- $this->assertSame('.wrapper {left: 16em;left: 10px;text-align: 1;text-align: left;border-bottom-width: 1px;}', $oDoc->render());
- }
-
- public function testOrderOfElementsMatchingOriginalOrderAfterExpandingShorthands()
- {
- $sCss = '.rule{padding:5px;padding-top: 20px}';
- $oParser = new Parser($sCss);
- $oDoc = $oParser->parse();
- $aDocs = $oDoc->getAllDeclarationBlocks();
-
- $this->assertCount(1, $aDocs);
-
- $oDeclaration = array_pop($aDocs);
- $oDeclaration->expandShorthands();
-
- $this->assertEquals(
- [
- 'padding-top' => 'padding-top: 20px;',
- 'padding-right' => 'padding-right: 5px;',
- 'padding-bottom' => 'padding-bottom: 5px;',
- 'padding-left' => 'padding-left: 5px;',
- ],
- array_map('strval', $oDeclaration->getRulesAssoc())
- );
- }
-}
diff --git a/tests/Sabberworm/CSS/RuleSet/LenientParsingTest.php b/tests/Sabberworm/CSS/RuleSet/LenientParsingTest.php
deleted file mode 100644
index 0b967f3dc..000000000
--- a/tests/Sabberworm/CSS/RuleSet/LenientParsingTest.php
+++ /dev/null
@@ -1,84 +0,0 @@
-beStrict());
- $oParser->parse();
- }
-
- public function testFaultToleranceOn()
- {
- $sFile = __DIR__ . '/../../../files' . DIRECTORY_SEPARATOR . "-fault-tolerance.css";
- $oParser = new Parser(file_get_contents($sFile), Settings::create()->withLenientParsing(true));
- $oResult = $oParser->parse();
- $this->assertSame('.test1 {}' . "\n" . '.test2 {hello: 2.2;hello: 2000000000000.2;}' . "\n" . '#test {}' . "\n" . '#test2 {help: none;}', $oResult->render());
- }
-
- /**
- * @expectedException Sabberworm\CSS\Parsing\UnexpectedTokenException
- */
- public function testEndToken()
- {
- $sFile = __DIR__ . '/../../../files' . DIRECTORY_SEPARATOR . "-end-token.css";
- $oParser = new Parser(file_get_contents($sFile), Settings::create()->beStrict());
- $oParser->parse();
- }
-
- /**
- * @expectedException Sabberworm\CSS\Parsing\UnexpectedTokenException
- */
- public function testEndToken2()
- {
- $sFile = __DIR__ . '/../../../files' . DIRECTORY_SEPARATOR . "-end-token-2.css";
- $oParser = new Parser(file_get_contents($sFile), Settings::create()->beStrict());
- $oParser->parse();
- }
-
- public function testEndTokenPositive()
- {
- $sFile = __DIR__ . '/../../../files' . DIRECTORY_SEPARATOR . "-end-token.css";
- $oParser = new Parser(file_get_contents($sFile), Settings::create()->withLenientParsing(true));
- $oResult = $oParser->parse();
- $this->assertSame("", $oResult->render());
- }
-
- public function testEndToken2Positive()
- {
- $sFile = __DIR__ . '/../../../files' . DIRECTORY_SEPARATOR . "-end-token-2.css";
- $oParser = new Parser(file_get_contents($sFile), Settings::create()->withLenientParsing(true));
- $oResult = $oParser->parse();
- $this->assertSame('#home .bg-layout {background-image: url("/bundles/main/img/bg1.png?5");}', $oResult->render());
- }
-
- public function testLocaleTrap()
- {
- setlocale(LC_ALL, "pt_PT", "no");
- $sFile = __DIR__ . '/../../../files' . DIRECTORY_SEPARATOR . "-fault-tolerance.css";
- $oParser = new Parser(file_get_contents($sFile), Settings::create()->withLenientParsing(true));
- $oResult = $oParser->parse();
- $this->assertSame('.test1 {}' . "\n" . '.test2 {hello: 2.2;hello: 2000000000000.2;}' . "\n" . '#test {}' . "\n" . '#test2 {help: none;}', $oResult->render());
- }
-
- public function testCaseInsensitivity()
- {
- $sFile = __DIR__ . '/../../../files' . DIRECTORY_SEPARATOR . "case-insensitivity.css";
- $oParser = new Parser(file_get_contents($sFile));
- $oResult = $oParser->parse();
- $this->assertSame('@charset "utf-8";
-@import url("test.css");
-@media screen {}
-#myid {case: insensitive !important;frequency: 30Hz;font-size: 1em;color: #ff0;color: hsl(40,40%,30%);font-family: Arial;}', $oResult->render());
- }
-}
diff --git a/tests/Unit/CSSList/AtRuleBlockListTest.php b/tests/Unit/CSSList/AtRuleBlockListTest.php
new file mode 100644
index 000000000..0252f7d3f
--- /dev/null
+++ b/tests/Unit/CSSList/AtRuleBlockListTest.php
@@ -0,0 +1,138 @@
+atRuleName());
+ }
+
+ /**
+ * @test
+ */
+ public function atRuleArgsByDefaultReturnsEmptyString(): void
+ {
+ $subject = new AtRuleBlockList('supports');
+
+ self::assertSame('', $subject->atRuleArgs());
+ }
+
+ /**
+ * @test
+ */
+ public function atRuleArgsReturnsArgumentsProvidedToConstructor(): void
+ {
+ $arguments = 'bar';
+
+ $subject = new AtRuleBlockList('', $arguments);
+
+ self::assertSame($arguments, $subject->atRuleArgs());
+ }
+
+ /**
+ * @test
+ */
+ public function getLineNoReturnsLineNumberProvidedToConstructor(): void
+ {
+ $lineNumber = 42;
+
+ $subject = new AtRuleBlockList('', '', $lineNumber);
+
+ self::assertSame($lineNumber, $subject->getLineNo());
+ }
+
+ /**
+ * @test
+ */
+ public function isRootListAlwaysReturnsFalse(): void
+ {
+ $subject = new AtRuleBlockList('supports');
+
+ self::assertFalse($subject->isRootList());
+ }
+}
diff --git a/tests/Unit/CSSList/CSSBlockListTest.php b/tests/Unit/CSSList/CSSBlockListTest.php
new file mode 100644
index 000000000..41f2b41f0
--- /dev/null
+++ b/tests/Unit/CSSList/CSSBlockListTest.php
@@ -0,0 +1,474 @@
+getAllDeclarationBlocks());
+ }
+
+ /**
+ * @test
+ */
+ public function getAllDeclarationBlocksReturnsOneDeclarationBlockDirectlySetAsContent(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ $declarationBlock = new DeclarationBlock();
+ $subject->setContents([$declarationBlock]);
+
+ $result = $subject->getAllDeclarationBlocks();
+
+ self::assertSame([$declarationBlock], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getAllDeclarationBlocksReturnsMultipleDeclarationBlocksDirectlySetAsContents(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ $declarationBlock1 = new DeclarationBlock();
+ $declarationBlock2 = new DeclarationBlock();
+ $subject->setContents([$declarationBlock1, $declarationBlock2]);
+
+ $result = $subject->getAllDeclarationBlocks();
+
+ self::assertSame([$declarationBlock1, $declarationBlock2], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getAllDeclarationBlocksReturnsDeclarationBlocksWithinAtRuleBlockList(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ $declarationBlock = new DeclarationBlock();
+ $atRuleBlockList = new AtRuleBlockList('media');
+ $atRuleBlockList->setContents([$declarationBlock]);
+ $subject->setContents([$atRuleBlockList]);
+
+ $result = $subject->getAllDeclarationBlocks();
+
+ self::assertSame([$declarationBlock], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getAllDeclarationBlocksIgnoresImport(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ $import = new Import(new URL(new CSSString('https://www.example.com/')), '');
+ $subject->setContents([$import]);
+
+ $result = $subject->getAllDeclarationBlocks();
+
+ self::assertSame([], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getAllDeclarationBlocksIgnoresCharset(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ $charset = new Charset(new CSSString('UTF-8'));
+ $subject->setContents([$charset]);
+
+ $result = $subject->getAllDeclarationBlocks();
+
+ self::assertSame([], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getAllRuleSetsWhenNoContentSetReturnsEmptyArray(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ self::assertSame([], $subject->getAllRuleSets());
+ }
+
+ /**
+ * @test
+ */
+ public function getAllRuleSetsReturnsOneDeclarationBlockDirectlySetAsContent(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ $declarationBlock = new DeclarationBlock();
+ $subject->setContents([$declarationBlock]);
+
+ $result = $subject->getAllRuleSets();
+
+ self::assertSame([$declarationBlock], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getAllRuleSetsReturnsOneAtRuleSetDirectlySetAsContent(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ $atRuleSet = new AtRuleSet('media');
+ $subject->setContents([$atRuleSet]);
+
+ $result = $subject->getAllRuleSets();
+
+ self::assertSame([$atRuleSet], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getAllRuleSetsReturnsMultipleDeclarationBlocksDirectlySetAsContents(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ $declarationBlock1 = new DeclarationBlock();
+ $declarationBlock2 = new DeclarationBlock();
+ $subject->setContents([$declarationBlock1, $declarationBlock2]);
+
+ $result = $subject->getAllRuleSets();
+
+ self::assertSame([$declarationBlock1, $declarationBlock2], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getAllRuleSetsReturnsMultipleAtRuleSetsDirectlySetAsContents(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ $atRuleSet1 = new AtRuleSet('media');
+ $atRuleSet2 = new AtRuleSet('media');
+ $subject->setContents([$atRuleSet1, $atRuleSet2]);
+
+ $result = $subject->getAllRuleSets();
+
+ self::assertSame([$atRuleSet1, $atRuleSet2], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getAllRuleSetsReturnsDeclarationBlocksWithinAtRuleBlockList(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ $declarationBlock = new DeclarationBlock();
+ $atRuleBlockList = new AtRuleBlockList('media');
+ $atRuleBlockList->setContents([$declarationBlock]);
+ $subject->setContents([$atRuleBlockList]);
+
+ $result = $subject->getAllRuleSets();
+
+ self::assertSame([$declarationBlock], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getAllRuleSetsReturnsAtRuleSetsWithinAtRuleBlockList(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ $atRule = new AtRuleSet('media');
+ $atRuleBlockList = new AtRuleBlockList('media');
+ $atRuleBlockList->setContents([$atRule]);
+ $subject->setContents([$atRuleBlockList]);
+
+ $result = $subject->getAllRuleSets();
+
+ self::assertSame([$atRule], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getAllRuleSetsIgnoresImport(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ $import = new Import(new URL(new CSSString('https://www.example.com/')), '');
+ $subject->setContents([$import]);
+
+ $result = $subject->getAllRuleSets();
+
+ self::assertSame([], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getAllRuleSetsIgnoresCharset(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ $charset = new Charset(new CSSString('UTF-8'));
+ $subject->setContents([$charset]);
+
+ $result = $subject->getAllRuleSets();
+
+ self::assertSame([], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getAllValuesWhenNoContentSetReturnsEmptyArray(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ self::assertSame([], $subject->getAllValues());
+ }
+
+ /**
+ * @test
+ */
+ public function getAllValuesReturnsOneValueDirectlySetAsContent(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ $value = new CSSString('Superfont');
+
+ $declarationBlock = new DeclarationBlock();
+ $rule = new Rule('font-family');
+ $rule->setValue($value);
+ $declarationBlock->addRule($rule);
+ $subject->setContents([$declarationBlock]);
+
+ $result = $subject->getAllValues();
+
+ self::assertSame([$value], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getAllValuesReturnsMultipleValuesDirectlySetAsContentInOneDeclarationBlock(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ $value1 = new CSSString('Superfont');
+ $value2 = new CSSString('aquamarine');
+
+ $declarationBlock = new DeclarationBlock();
+ $rule1 = new Rule('font-family');
+ $rule1->setValue($value1);
+ $declarationBlock->addRule($rule1);
+ $rule2 = new Rule('color');
+ $rule2->setValue($value2);
+ $declarationBlock->addRule($rule2);
+ $subject->setContents([$declarationBlock]);
+
+ $result = $subject->getAllValues();
+
+ self::assertSame([$value1, $value2], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getAllValuesReturnsMultipleValuesDirectlySetAsContentInMultipleDeclarationBlocks(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ $value1 = new CSSString('Superfont');
+ $value2 = new CSSString('aquamarine');
+
+ $declarationBlock1 = new DeclarationBlock();
+ $rule1 = new Rule('font-family');
+ $rule1->setValue($value1);
+ $declarationBlock1->addRule($rule1);
+ $declarationBlock2 = new DeclarationBlock();
+ $rule2 = new Rule('color');
+ $rule2->setValue($value2);
+ $declarationBlock2->addRule($rule2);
+ $subject->setContents([$declarationBlock1, $declarationBlock2]);
+
+ $result = $subject->getAllValues();
+
+ self::assertSame([$value1, $value2], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getAllValuesReturnsValuesWithinAtRuleBlockList(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ $value = new CSSString('Superfont');
+
+ $declarationBlock = new DeclarationBlock();
+ $rule = new Rule('font-family');
+ $rule->setValue($value);
+ $declarationBlock->addRule($rule);
+ $atRuleBlockList = new AtRuleBlockList('media');
+ $atRuleBlockList->setContents([$declarationBlock]);
+ $subject->setContents([$atRuleBlockList]);
+
+ $result = $subject->getAllValues();
+
+ self::assertSame([$value], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getAllValuesWithElementProvidedReturnsOnlyValuesWithinThatElement(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ $value1 = new CSSString('Superfont');
+ $value2 = new CSSString('aquamarine');
+
+ $declarationBlock1 = new DeclarationBlock();
+ $rule1 = new Rule('font-family');
+ $rule1->setValue($value1);
+ $declarationBlock1->addRule($rule1);
+ $declarationBlock2 = new DeclarationBlock();
+ $rule2 = new Rule('color');
+ $rule2->setValue($value2);
+ $declarationBlock2->addRule($rule2);
+ $subject->setContents([$declarationBlock1, $declarationBlock2]);
+
+ $result = $subject->getAllValues($declarationBlock1);
+
+ self::assertSame([$value1], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getAllValuesWithSearchStringProvidedReturnsOnlyValuesFromMatchingRules(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ $value1 = new CSSString('Superfont');
+ $value2 = new CSSString('aquamarine');
+
+ $declarationBlock = new DeclarationBlock();
+ $rule1 = new Rule('font-family');
+ $rule1->setValue($value1);
+ $declarationBlock->addRule($rule1);
+ $rule2 = new Rule('color');
+ $rule2->setValue($value2);
+ $declarationBlock->addRule($rule2);
+ $subject->setContents([$declarationBlock]);
+
+ $result = $subject->getAllValues(null, 'font-');
+
+ self::assertSame([$value1], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getAllValuesByDefaultDoesNotReturnValuesInFunctionArguments(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ $value1 = new Size(10, 'px');
+ $value2 = new Size(2, '%');
+
+ $declarationBlock = new DeclarationBlock();
+ $rule = new Rule('margin');
+ $rule->setValue(new CSSFunction('max', [$value1, $value2]));
+ $declarationBlock->addRule($rule);
+ $subject->setContents([$declarationBlock]);
+
+ $result = $subject->getAllValues();
+
+ self::assertSame([], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getAllValuesWithSearchInFunctionArgumentsReturnsValuesInFunctionArguments(): void
+ {
+ $subject = new ConcreteCSSBlockList();
+
+ $value1 = new Size(10, 'px');
+ $value2 = new Size(2, '%');
+
+ $declarationBlock = new DeclarationBlock();
+ $rule = new Rule('margin');
+ $rule->setValue(new CSSFunction('max', [$value1, $value2]));
+ $declarationBlock->addRule($rule);
+ $subject->setContents([$declarationBlock]);
+
+ $result = $subject->getAllValues(null, null, true);
+
+ self::assertSame([$value1, $value2], $result);
+ }
+}
diff --git a/tests/Unit/CSSList/CSSListTest.php b/tests/Unit/CSSList/CSSListTest.php
new file mode 100644
index 000000000..03539533e
--- /dev/null
+++ b/tests/Unit/CSSList/CSSListTest.php
@@ -0,0 +1,193 @@
+getLineNo());
+ }
+
+ /**
+ * @test
+ */
+ public function getLineNoReturnsLineNumberProvidedToConstructor(): void
+ {
+ $lineNumber = 42;
+
+ $subject = new ConcreteCSSList($lineNumber);
+
+ self::assertSame($lineNumber, $subject->getLineNo());
+ }
+
+ /**
+ * @test
+ */
+ public function getContentsInitiallyReturnsEmptyArray(): void
+ {
+ $subject = new ConcreteCSSList();
+
+ self::assertSame([], $subject->getContents());
+ }
+
+ /**
+ * @return array}>
+ */
+ public static function contentsDataProvider(): array
+ {
+ return [
+ 'empty array' => [[]],
+ '1 item' => [[new DeclarationBlock()]],
+ '2 items' => [[new DeclarationBlock(), new DeclarationBlock()]],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @param list $contents
+ *
+ * @dataProvider contentsDataProvider
+ */
+ public function setContentsSetsContents(array $contents): void
+ {
+ $subject = new ConcreteCSSList();
+
+ $subject->setContents($contents);
+
+ self::assertSame($contents, $subject->getContents());
+ }
+
+ /**
+ * @test
+ */
+ public function setContentsReplacesContentsSetInPreviousCall(): void
+ {
+ $subject = new ConcreteCSSList();
+
+ $contents2 = [new DeclarationBlock()];
+
+ $subject->setContents([new DeclarationBlock()]);
+ $subject->setContents($contents2);
+
+ self::assertSame($contents2, $subject->getContents());
+ }
+
+ /**
+ * @test
+ */
+ public function insertBeforeInsertsContentBeforeSibling(): void
+ {
+ $subject = new ConcreteCSSList();
+
+ $bogusOne = new DeclarationBlock();
+ $bogusOne->setSelectors('.bogus-one');
+ $bogusTwo = new DeclarationBlock();
+ $bogusTwo->setSelectors('.bogus-two');
+
+ $item = new DeclarationBlock();
+ $item->setSelectors('.item');
+
+ $sibling = new DeclarationBlock();
+ $sibling->setSelectors('.sibling');
+
+ $subject->setContents([$bogusOne, $sibling, $bogusTwo]);
+
+ self::assertCount(3, $subject->getContents());
+
+ $subject->insertBefore($item, $sibling);
+
+ self::assertCount(4, $subject->getContents());
+ self::assertSame([$bogusOne, $item, $sibling, $bogusTwo], $subject->getContents());
+ }
+
+ /**
+ * @test
+ */
+ public function insertBeforeAppendsIfSiblingNotFound(): void
+ {
+ $subject = new ConcreteCSSList();
+
+ $bogusOne = new DeclarationBlock();
+ $bogusOne->setSelectors('.bogus-one');
+ $bogusTwo = new DeclarationBlock();
+ $bogusTwo->setSelectors('.bogus-two');
+
+ $item = new DeclarationBlock();
+ $item->setSelectors('.item');
+
+ $sibling = new DeclarationBlock();
+ $sibling->setSelectors('.sibling');
+
+ $orphan = new DeclarationBlock();
+ $orphan->setSelectors('.forever-alone');
+
+ $subject->setContents([$bogusOne, $sibling, $bogusTwo]);
+
+ self::assertCount(3, $subject->getContents());
+
+ $subject->insertBefore($item, $orphan);
+
+ self::assertCount(4, $subject->getContents());
+ self::assertSame([$bogusOne, $sibling, $bogusTwo, $item], $subject->getContents());
+ }
+}
diff --git a/tests/Unit/CSSList/DocumentTest.php b/tests/Unit/CSSList/DocumentTest.php
new file mode 100644
index 000000000..77e8aa81c
--- /dev/null
+++ b/tests/Unit/CSSList/DocumentTest.php
@@ -0,0 +1,66 @@
+isRootList());
+ }
+}
diff --git a/tests/Unit/CSSList/Fixtures/ConcreteCSSBlockList.php b/tests/Unit/CSSList/Fixtures/ConcreteCSSBlockList.php
new file mode 100644
index 000000000..956c7036a
--- /dev/null
+++ b/tests/Unit/CSSList/Fixtures/ConcreteCSSBlockList.php
@@ -0,0 +1,21 @@
+getLineNo());
+ }
+
+ /**
+ * @test
+ */
+ public function getAnimationNameByDefaultReturnsNone(): void
+ {
+ $subject = new KeyFrame();
+
+ self::assertSame('none', $subject->getAnimationName());
+ }
+
+ /**
+ * @test
+ */
+ public function getVendorKeyFrameByDefaultReturnsKeyframes(): void
+ {
+ $subject = new KeyFrame();
+
+ self::assertSame('keyframes', $subject->getVendorKeyFrame());
+ }
+}
diff --git a/tests/Unit/Comment/CommentContainerTest.php b/tests/Unit/Comment/CommentContainerTest.php
new file mode 100644
index 000000000..d0e844a45
--- /dev/null
+++ b/tests/Unit/Comment/CommentContainerTest.php
@@ -0,0 +1,236 @@
+subject = new ConcreteCommentContainer();
+ }
+
+ /**
+ * @test
+ */
+ public function getCommentsInitiallyReturnsEmptyArray(): void
+ {
+ self::assertSame([], $this->subject->getComments());
+ }
+
+ /**
+ * @return array}>
+ */
+ public function provideCommentArray(): array
+ {
+ return [
+ 'no comment' => [[]],
+ 'one comment' => [[new Comment('Is this really a spoon?')]],
+ 'two comments' => [
+ [
+ new Comment('I’m a teapot.'),
+ new Comment('I’m a cafetière.'),
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @param list $comments
+ *
+ * @dataProvider provideCommentArray
+ */
+ public function addCommentsOnVirginContainerAddsCommentsProvided(array $comments): void
+ {
+ $this->subject->addComments($comments);
+
+ self::assertSame($comments, $this->subject->getComments());
+ }
+
+ /**
+ * @test
+ *
+ * @param list $comments
+ *
+ * @dataProvider provideCommentArray
+ */
+ public function addCommentsWithEmptyArrayKeepsOriginalCommentsUnchanged(array $comments): void
+ {
+ $this->subject->setComments($comments);
+
+ $this->subject->addComments([]);
+
+ self::assertSame($comments, $this->subject->getComments());
+ }
+
+ /**
+ * @return array}>
+ */
+ public function provideAlternativeCommentArray(): array
+ {
+ return [
+ 'no comment' => [[]],
+ 'one comment' => [[new Comment('Can I eat it with my hands?')]],
+ 'two comments' => [
+ [
+ new Comment('I’m a beer barrel.'),
+ new Comment('I’m a vineyard.'),
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @return array}>
+ */
+ public function provideAlternativeNonemptyCommentArray(): array
+ {
+ $data = $this->provideAlternativeCommentArray();
+
+ unset($data['no comment']);
+
+ return $data;
+ }
+
+ /**
+ * This provider crosses two comment arrays (0, 1 or 2 comments) with different comments,
+ * so that all combinations can be tested.
+ *
+ * @return DataProvider, 1: list}>
+ */
+ public function provideTwoDistinctCommentArrays(): DataProvider
+ {
+ return DataProvider::cross($this->provideCommentArray(), $this->provideAlternativeCommentArray());
+ }
+
+ /**
+ * @return DataProvider, 1: non-empty-list}>
+ */
+ public function provideTwoDistinctCommentArraysWithSecondNonempty(): DataProvider
+ {
+ return DataProvider::cross($this->provideCommentArray(), $this->provideAlternativeNonemptyCommentArray());
+ }
+
+ private static function createContainsConstraint(Comment $comment): TraversableContains
+ {
+ return new TraversableContains($comment);
+ }
+
+ /**
+ * @param non-empty-list $comments
+ *
+ * @return non-empty-list
+ */
+ private static function createContainsConstraints(array $comments): array
+ {
+ return \array_map([self::class, 'createContainsConstraint'], $comments);
+ }
+
+ /**
+ * @test
+ *
+ * @param list $commentsToAdd
+ * @param non-empty-list $originalComments
+ *
+ * @dataProvider provideTwoDistinctCommentArraysWithSecondNonempty
+ */
+ public function addCommentsKeepsOriginalComments(array $commentsToAdd, array $originalComments): void
+ {
+ $this->subject->setComments($originalComments);
+
+ $this->subject->addComments($commentsToAdd);
+
+ self::assertThat(
+ $this->subject->getComments(),
+ LogicalAnd::fromConstraints(...self::createContainsConstraints($originalComments))
+ );
+ }
+
+ /**
+ * @test
+ *
+ * @param list $originalComments
+ * @param non-empty-list $commentsToAdd
+ *
+ * @dataProvider provideTwoDistinctCommentArraysWithSecondNonempty
+ */
+ public function addCommentsAfterCommentsSetAddsCommentsProvided(array $originalComments, array $commentsToAdd): void
+ {
+ $this->subject->setComments($originalComments);
+
+ $this->subject->addComments($commentsToAdd);
+
+ self::assertThat(
+ $this->subject->getComments(),
+ LogicalAnd::fromConstraints(...self::createContainsConstraints($commentsToAdd))
+ );
+ }
+
+ /**
+ * @test
+ *
+ * @param non-empty-list $comments
+ *
+ * @dataProvider provideAlternativeNonemptyCommentArray
+ */
+ public function addCommentsAppends(array $comments): void
+ {
+ $firstComment = new Comment('I must be first!');
+ $this->subject->setComments([$firstComment]);
+
+ $this->subject->addComments($comments);
+
+ $result = $this->subject->getComments();
+ self::assertNotEmpty($result);
+ self::assertSame($firstComment, $result[0]);
+ }
+
+ /**
+ * @test
+ *
+ * @param list $comments
+ *
+ * @dataProvider provideCommentArray
+ */
+ public function setCommentsOnVirginContainerSetsCommentsProvided(array $comments): void
+ {
+ $this->subject->setComments($comments);
+
+ self::assertSame($comments, $this->subject->getComments());
+ }
+
+ /**
+ * @test
+ *
+ * @param list $originalComments
+ * @param list $commentsToSet
+ *
+ * @dataProvider provideTwoDistinctCommentArrays
+ */
+ public function setCommentsReplacesWithCommentsProvided(array $originalComments, array $commentsToSet): void
+ {
+ $this->subject->setComments($originalComments);
+
+ $this->subject->setComments($commentsToSet);
+
+ self::assertSame($commentsToSet, $this->subject->getComments());
+ }
+}
diff --git a/tests/Unit/Comment/CommentTest.php b/tests/Unit/Comment/CommentTest.php
new file mode 100644
index 000000000..2bfe670c4
--- /dev/null
+++ b/tests/Unit/Comment/CommentTest.php
@@ -0,0 +1,80 @@
+getComment());
+ }
+
+ /**
+ * @test
+ */
+ public function getCommentInitiallyReturnsCommentPassedToConstructor(): void
+ {
+ $comment = 'There is no spoon.';
+ $subject = new Comment($comment);
+
+ self::assertSame($comment, $subject->getComment());
+ }
+
+ /**
+ * @test
+ */
+ public function setCommentSetsComments(): void
+ {
+ $comment = 'There is no spoon.';
+ $subject = new Comment();
+
+ $subject->setComment($comment);
+
+ self::assertSame($comment, $subject->getComment());
+ }
+
+ /**
+ * @test
+ */
+ public function getLineNoOnEmptyInstanceReturnsZero(): void
+ {
+ $subject = new Comment();
+
+ self::assertSame(0, $subject->getLineNo());
+ }
+
+ /**
+ * @test
+ */
+ public function getLineNoInitiallyReturnsLineNumberPassedToConstructor(): void
+ {
+ $lineNumber = 42;
+ $subject = new Comment('', $lineNumber);
+
+ self::assertSame($lineNumber, $subject->getLineNo());
+ }
+}
diff --git a/tests/Unit/Comment/Fixtures/ConcreteCommentContainer.php b/tests/Unit/Comment/Fixtures/ConcreteCommentContainer.php
new file mode 100644
index 000000000..39f6ec37f
--- /dev/null
+++ b/tests/Unit/Comment/Fixtures/ConcreteCommentContainer.php
@@ -0,0 +1,13 @@
+subject = new OutputFormat();
+ }
+
+ /**
+ * @test
+ */
+ public function getStringQuotingTypeInitiallyReturnsDoubleQuote(): void
+ {
+ self::assertSame('"', $this->subject->getStringQuotingType());
+ }
+
+ /**
+ * @test
+ */
+ public function setStringQuotingTypeSetsStringQuotingType(): void
+ {
+ $value = "'";
+ $this->subject->setStringQuotingType($value);
+
+ self::assertSame($value, $this->subject->getStringQuotingType());
+ }
+
+ /**
+ * @test
+ */
+ public function setStringQuotingTypeProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setStringQuotingType('"'));
+ }
+
+ /**
+ * @test
+ */
+ public function usesRgbHashNotationInitiallyReturnsTrue(): void
+ {
+ self::assertTrue($this->subject->usesRgbHashNotation());
+ }
+
+ /**
+ * @return array
+ */
+ public static function provideBooleans(): array
+ {
+ return [
+ 'true' => [true],
+ 'false' => [false],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideBooleans
+ */
+ public function setRGBHashNotationSetsRGBHashNotation(bool $value): void
+ {
+ $this->subject->setRGBHashNotation($value);
+
+ self::assertSame($value, $this->subject->usesRgbHashNotation());
+ }
+
+ /**
+ * @test
+ */
+ public function setRGBHashNotationProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setRGBHashNotation(true));
+ }
+
+ /**
+ * @test
+ */
+ public function shouldRenderSemicolonAfterLastRuleInitiallyReturnsTrue(): void
+ {
+ self::assertTrue($this->subject->shouldRenderSemicolonAfterLastRule());
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideBooleans
+ */
+ public function setSemicolonAfterLastRuleSetsSemicolonAfterLastRule(bool $value): void
+ {
+ $this->subject->setSemicolonAfterLastRule($value);
+
+ self::assertSame($value, $this->subject->shouldRenderSemicolonAfterLastRule());
+ }
+
+ /**
+ * @test
+ */
+ public function setSemicolonAfterLastRuleProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setSemicolonAfterLastRule(true));
+ }
+
+ /**
+ * @test
+ */
+ public function getSpaceAfterRuleNameInitiallyReturnsSingleSpace(): void
+ {
+ self::assertSame(' ', $this->subject->getSpaceAfterRuleName());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceAfterRuleNameSetsSpaceAfterRuleName(): void
+ {
+ $value = "\n";
+ $this->subject->setSpaceAfterRuleName($value);
+
+ self::assertSame($value, $this->subject->getSpaceAfterRuleName());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceAfterRuleNameProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setSpaceAfterRuleName("\n"));
+ }
+
+ /**
+ * @test
+ */
+ public function getSpaceBeforeRulesInitiallyReturnsEmptyString(): void
+ {
+ self::assertSame('', $this->subject->getSpaceBeforeRules());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceBeforeRulesSetsSpaceBeforeRules(): void
+ {
+ $value = ' ';
+ $this->subject->setSpaceBeforeRules($value);
+
+ self::assertSame($value, $this->subject->getSpaceBeforeRules());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceBeforeRulesProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setSpaceBeforeRules(' '));
+ }
+
+ /**
+ * @test
+ */
+ public function getSpaceAfterRulesInitiallyReturnsEmptyString(): void
+ {
+ self::assertSame('', $this->subject->getSpaceAfterRules());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceAfterRulesSetsSpaceAfterRules(): void
+ {
+ $value = ' ';
+ $this->subject->setSpaceAfterRules($value);
+
+ self::assertSame($value, $this->subject->getSpaceAfterRules());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceAfterRulesProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setSpaceAfterRules(' '));
+ }
+
+ /**
+ * @test
+ */
+ public function getSpaceBetweenRulesInitiallyReturnsEmptyString(): void
+ {
+ self::assertSame('', $this->subject->getSpaceBetweenRules());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceBetweenRulesSetsSpaceBetweenRules(): void
+ {
+ $value = ' ';
+ $this->subject->setSpaceBetweenRules($value);
+
+ self::assertSame($value, $this->subject->getSpaceBetweenRules());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceBetweenRulesProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setSpaceBetweenRules(' '));
+ }
+
+ /**
+ * @test
+ */
+ public function getSpaceBeforeBlocksInitiallyReturnsEmptyString(): void
+ {
+ self::assertSame('', $this->subject->getSpaceBeforeBlocks());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceBeforeBlocksSetsSpaceBeforeBlocks(): void
+ {
+ $value = ' ';
+ $this->subject->setSpaceBeforeBlocks($value);
+
+ self::assertSame($value, $this->subject->getSpaceBeforeBlocks());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceBeforeBlocksProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setSpaceBeforeBlocks(' '));
+ }
+
+ /**
+ * @test
+ */
+ public function getSpaceAfterBlocksInitiallyReturnsEmptyString(): void
+ {
+ self::assertSame('', $this->subject->getSpaceAfterBlocks());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceAfterBlocksSetsSpaceAfterBlocks(): void
+ {
+ $value = ' ';
+ $this->subject->setSpaceAfterBlocks($value);
+
+ self::assertSame($value, $this->subject->getSpaceAfterBlocks());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceAfterBlocksProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setSpaceAfterBlocks(' '));
+ }
+
+ /**
+ * @test
+ */
+ public function getSpaceBetweenBlocksInitiallyReturnsNewline(): void
+ {
+ self::assertSame("\n", $this->subject->getSpaceBetweenBlocks());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceBetweenBlocksSetsSpaceBetweenBlocks(): void
+ {
+ $value = ' ';
+ $this->subject->setSpaceBetweenBlocks($value);
+
+ self::assertSame($value, $this->subject->getSpaceBetweenBlocks());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceBetweenBlocksProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setSpaceBetweenBlocks(' '));
+ }
+
+ /**
+ * @test
+ */
+ public function getContentBeforeAtRuleBlockInitiallyReturnsEmptyString(): void
+ {
+ self::assertSame('', $this->subject->getContentBeforeAtRuleBlock());
+ }
+
+ /**
+ * @test
+ */
+ public function setBeforeAtRuleBlockSetsBeforeAtRuleBlock(): void
+ {
+ $value = ' ';
+ $this->subject->setBeforeAtRuleBlock($value);
+
+ self::assertSame($value, $this->subject->getContentBeforeAtRuleBlock());
+ }
+
+ /**
+ * @test
+ */
+ public function setBeforeAtRuleBlockProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setBeforeAtRuleBlock(' '));
+ }
+
+ /**
+ * @test
+ */
+ public function getContentAfterAtRuleBlockInitiallyReturnsEmptyString(): void
+ {
+ self::assertSame('', $this->subject->getContentAfterAtRuleBlock());
+ }
+
+ /**
+ * @test
+ */
+ public function setAfterAtRuleBlockSetsAfterAtRuleBlock(): void
+ {
+ $value = ' ';
+ $this->subject->setAfterAtRuleBlock($value);
+
+ self::assertSame($value, $this->subject->getContentAfterAtRuleBlock());
+ }
+
+ /**
+ * @test
+ */
+ public function setAfterAtRuleBlockProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setAfterAtRuleBlock(' '));
+ }
+
+ /**
+ * @test
+ */
+ public function getSpaceBeforeSelectorSeparatorInitiallyReturnsEmptyString(): void
+ {
+ self::assertSame('', $this->subject->getSpaceBeforeSelectorSeparator());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceBeforeSelectorSeparatorSetsSpaceBeforeSelectorSeparator(): void
+ {
+ $value = ' ';
+ $this->subject->setSpaceBeforeSelectorSeparator($value);
+
+ self::assertSame($value, $this->subject->getSpaceBeforeSelectorSeparator());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceBeforeSelectorSeparatorProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setSpaceBeforeSelectorSeparator(' '));
+ }
+
+ /**
+ * @test
+ */
+ public function getSpaceAfterSelectorSeparatorInitiallyReturnsSpace(): void
+ {
+ self::assertSame(' ', $this->subject->getSpaceAfterSelectorSeparator());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceAfterSelectorSeparatorSetsSpaceAfterSelectorSeparator(): void
+ {
+ $value = ' ';
+ $this->subject->setSpaceAfterSelectorSeparator($value);
+
+ self::assertSame($value, $this->subject->getSpaceAfterSelectorSeparator());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceAfterSelectorSeparatorProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setSpaceAfterSelectorSeparator(' '));
+ }
+
+ /**
+ * @test
+ */
+ public function getSpaceBeforeListArgumentSeparatorInitiallyReturnsEmptyString(): void
+ {
+ self::assertSame('', $this->subject->getSpaceBeforeListArgumentSeparator());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceBeforeListArgumentSeparatorSetsSpaceBeforeListArgumentSeparator(): void
+ {
+ $value = ' ';
+ $this->subject->setSpaceBeforeListArgumentSeparator($value);
+
+ self::assertSame($value, $this->subject->getSpaceBeforeListArgumentSeparator());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceBeforeListArgumentSeparatorProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setSpaceBeforeListArgumentSeparator(' '));
+ }
+
+ /**
+ * @test
+ */
+ public function getSpaceBeforeListArgumentSeparatorsInitiallyReturnsEmptyArray(): void
+ {
+ self::assertSame([], $this->subject->getSpaceBeforeListArgumentSeparators());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceBeforeListArgumentSeparatorsSetsSpaceBeforeListArgumentSeparators(): void
+ {
+ $value = ['/' => ' '];
+ $this->subject->setSpaceBeforeListArgumentSeparators($value);
+
+ self::assertSame($value, $this->subject->getSpaceBeforeListArgumentSeparators());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceBeforeListArgumentSeparatorsProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setSpaceBeforeListArgumentSeparators([]));
+ }
+
+ /**
+ * @test
+ */
+ public function getSpaceAfterListArgumentSeparatorInitiallyReturnsEmptyString(): void
+ {
+ self::assertSame('', $this->subject->getSpaceAfterListArgumentSeparator());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceAfterListArgumentSeparatorSetsSpaceAfterListArgumentSeparator(): void
+ {
+ $value = ' ';
+ $this->subject->setSpaceAfterListArgumentSeparator($value);
+
+ self::assertSame($value, $this->subject->getSpaceAfterListArgumentSeparator());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceAfterListArgumentSeparatorProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setSpaceAfterListArgumentSeparator(' '));
+ }
+
+ /**
+ * @test
+ */
+ public function getSpaceAfterListArgumentSeparatorsInitiallyReturnsEmptyArray(): void
+ {
+ self::assertSame([], $this->subject->getSpaceAfterListArgumentSeparators());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceAfterListArgumentSeparatorsSetsSpaceAfterListArgumentSeparators(): void
+ {
+ $value = [',' => ' '];
+ $this->subject->setSpaceAfterListArgumentSeparators($value);
+
+ self::assertSame($value, $this->subject->getSpaceAfterListArgumentSeparators());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceAfterListArgumentSeparatorsProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setSpaceAfterListArgumentSeparators([]));
+ }
+
+ /**
+ * @test
+ */
+ public function getSpaceBeforeOpeningBraceInitiallyReturnsSpace(): void
+ {
+ self::assertSame(' ', $this->subject->getSpaceBeforeOpeningBrace());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceBeforeOpeningBraceSetsSpaceBeforeOpeningBrace(): void
+ {
+ $value = "\t";
+ $this->subject->setSpaceBeforeOpeningBrace($value);
+
+ self::assertSame($value, $this->subject->getSpaceBeforeOpeningBrace());
+ }
+
+ /**
+ * @test
+ */
+ public function setSpaceBeforeOpeningBraceProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setSpaceBeforeOpeningBrace(' '));
+ }
+
+ /**
+ * @test
+ */
+ public function getContentBeforeDeclarationBlockInitiallyReturnsEmptyString(): void
+ {
+ self::assertSame('', $this->subject->getContentBeforeDeclarationBlock());
+ }
+
+ /**
+ * @test
+ */
+ public function setBeforeDeclarationBlockSetsBeforeDeclarationBlock(): void
+ {
+ $value = ' ';
+ $this->subject->setBeforeDeclarationBlock($value);
+
+ self::assertSame($value, $this->subject->getContentBeforeDeclarationBlock());
+ }
+
+ /**
+ * @test
+ */
+ public function setBeforeDeclarationBlockProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setBeforeDeclarationBlock(' '));
+ }
+
+ /**
+ * @test
+ */
+ public function getContentAfterDeclarationBlockSelectorsInitiallyReturnsEmptyString(): void
+ {
+ self::assertSame('', $this->subject->getContentAfterDeclarationBlockSelectors());
+ }
+
+ /**
+ * @test
+ */
+ public function setAfterDeclarationBlockSelectorsSetsAfterDeclarationBlockSelectors(): void
+ {
+ $value = ' ';
+ $this->subject->setAfterDeclarationBlockSelectors($value);
+
+ self::assertSame($value, $this->subject->getContentAfterDeclarationBlockSelectors());
+ }
+
+ /**
+ * @test
+ */
+ public function setAfterDeclarationBlockSelectorsProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setAfterDeclarationBlockSelectors(' '));
+ }
+
+ /**
+ * @test
+ */
+ public function getContentAfterDeclarationBlockInitiallyReturnsEmptyString(): void
+ {
+ self::assertSame('', $this->subject->getContentAfterDeclarationBlock());
+ }
+
+ /**
+ * @test
+ */
+ public function setAfterDeclarationBlockSetsAfterDeclarationBlock(): void
+ {
+ $value = ' ';
+ $this->subject->setAfterDeclarationBlock($value);
+
+ self::assertSame($value, $this->subject->getContentAfterDeclarationBlock());
+ }
+
+ /**
+ * @test
+ */
+ public function setAfterDeclarationBlockProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setAfterDeclarationBlock(' '));
+ }
+
+ /**
+ * @test
+ */
+ public function getIndentationInitiallyReturnsTab(): void
+ {
+ self::assertSame("\t", $this->subject->getIndentation());
+ }
+
+ /**
+ * @test
+ */
+ public function setIndentationSetsIndentation(): void
+ {
+ $value = ' ';
+ $this->subject->setIndentation($value);
+
+ self::assertSame($value, $this->subject->getIndentation());
+ }
+
+ /**
+ * @test
+ */
+ public function setIndentationProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setIndentation(' '));
+ }
+
+ /**
+ * @test
+ */
+ public function shouldIgnoreExceptionsInitiallyReturnsFalse(): void
+ {
+ self::assertFalse($this->subject->shouldIgnoreExceptions());
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideBooleans
+ */
+ public function setIgnoreExceptionsSetsIgnoreExceptions(bool $value): void
+ {
+ $this->subject->setIgnoreExceptions($value);
+
+ self::assertSame($value, $this->subject->shouldIgnoreExceptions());
+ }
+
+ /**
+ * @test
+ */
+ public function setIgnoreExceptionsProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setIgnoreExceptions(true));
+ }
+
+ /**
+ * @test
+ */
+ public function shouldRenderCommentsInitiallyReturnsFalse(): void
+ {
+ self::assertFalse($this->subject->shouldRenderComments());
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideBooleans
+ */
+ public function setRenderCommentsSetsRenderComments(bool $value): void
+ {
+ $this->subject->setRenderComments($value);
+
+ self::assertSame($value, $this->subject->shouldRenderComments());
+ }
+
+ /**
+ * @test
+ */
+ public function setRenderCommentsProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->setRenderComments(true));
+ }
+
+ /**
+ * @test
+ */
+ public function getIndentationLevelInitiallyReturnsZero(): void
+ {
+ self::assertSame(0, $this->subject->getIndentationLevel());
+ }
+
+ /**
+ * @test
+ */
+ public function indentWithTabsByDefaultSetsIndentationToOneTab(): void
+ {
+ $this->subject->indentWithTabs();
+
+ self::assertSame("\t", $this->subject->getIndentation());
+ }
+
+ /**
+ * @return array, 1: string}>
+ */
+ public static function provideTabIndentation(): array
+ {
+ return [
+ 'zero tabs' => [0, ''],
+ 'one tab' => [1, "\t"],
+ 'two tabs' => [2, "\t\t"],
+ 'three tabs' => [3, "\t\t\t"],
+ ];
+ }
+
+ /**
+ * @test
+ * @dataProvider provideTabIndentation
+ */
+ public function indentWithTabsSetsIndentationToTheProvidedNumberOfTabs(
+ int $numberOfTabs,
+ string $expectedIndentation
+ ): void {
+ $this->subject->indentWithTabs($numberOfTabs);
+
+ self::assertSame($expectedIndentation, $this->subject->getIndentation());
+ }
+
+ /**
+ * @test
+ */
+ public function indentWithTabsProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->indentWithTabs());
+ }
+
+ /**
+ * @test
+ */
+ public function indentWithSpacesByDefaultSetsIndentationToTwoSpaces(): void
+ {
+ $this->subject->indentWithSpaces();
+
+ self::assertSame(' ', $this->subject->getIndentation());
+ }
+
+ /**
+ * @return array, 1: string}>
+ */
+ public static function provideSpaceIndentation(): array
+ {
+ return [
+ 'zero spaces' => [0, ''],
+ 'one space' => [1, ' '],
+ 'two spaces' => [2, ' '],
+ 'three spaces' => [3, ' '],
+ 'four spaces' => [4, ' '],
+ ];
+ }
+
+ /**
+ * @test
+ * @dataProvider provideSpaceIndentation
+ */
+ public function indentWithSpacesSetsIndentationToTheProvidedNumberOfSpaces(
+ int $numberOfSpaces,
+ string $expectedIndentation
+ ): void {
+ $this->subject->indentWithSpaces($numberOfSpaces);
+
+ self::assertSame($expectedIndentation, $this->subject->getIndentation());
+ }
+
+ /**
+ * @test
+ */
+ public function indentWithSpacesProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->indentWithSpaces());
+ }
+
+ /**
+ * @test
+ */
+ public function nextLevelReturnsOutputFormatInstance(): void
+ {
+ self::assertInstanceOf(OutputFormat::class, $this->subject->nextLevel());
+ }
+
+ /**
+ * @test
+ */
+ public function nextLevelReturnsDifferentInstance(): void
+ {
+ self::assertNotSame($this->subject, $this->subject->nextLevel());
+ }
+
+ /**
+ * @test
+ */
+ public function nextLevelReturnsCloneWithSameProperties(): void
+ {
+ $space = ' ';
+ $this->subject->setSpaceAfterRuleName($space);
+
+ self::assertSame($space, $this->subject->nextLevel()->getSpaceAfterRuleName());
+ }
+
+ /**
+ * @test
+ */
+ public function nextLevelReturnsInstanceWithIndentationLevelIncreasedByOne(): void
+ {
+ $originalIndentationLevel = $this->subject->getIndentationLevel();
+
+ self::assertSame($originalIndentationLevel + 1, $this->subject->nextLevel()->getIndentationLevel());
+ }
+
+ /**
+ * @test
+ */
+ public function nextLevelReturnsInstanceWithDifferentFormatterInstance(): void
+ {
+ $formatter = $this->subject->getFormatter();
+
+ self::assertNotSame($formatter, $this->subject->nextLevel()->getFormatter());
+ }
+
+ /**
+ * @test
+ */
+ public function beLenientSetsIgnoreExceptionsToTrue(): void
+ {
+ $this->subject->setIgnoreExceptions(false);
+
+ $this->subject->beLenient();
+
+ self::assertTrue($this->subject->shouldIgnoreExceptions());
+ }
+
+ /**
+ * @test
+ */
+ public function getFormatterReturnsOutputFormatterInstance(): void
+ {
+ self::assertInstanceOf(OutputFormatter::class, $this->subject->getFormatter());
+ }
+
+ /**
+ * @test
+ */
+ public function getFormatterCalledTwoTimesReturnsSameInstance(): void
+ {
+ $firstCallResult = $this->subject->getFormatter();
+ $secondCallResult = $this->subject->getFormatter();
+
+ self::assertSame($firstCallResult, $secondCallResult);
+ }
+
+ /**
+ * @test
+ */
+ public function createReturnsOutputFormatInstance(): void
+ {
+ self::assertInstanceOf(OutputFormat::class, OutputFormat::create());
+ }
+
+ /**
+ * @test
+ */
+ public function createCreatesInstanceWithDefaultSettings(): void
+ {
+ self::assertEquals(new OutputFormat(), OutputFormat::create());
+ }
+
+ /**
+ * @test
+ */
+ public function createCalledTwoTimesReturnsDifferentInstances(): void
+ {
+ $firstCallResult = OutputFormat::create();
+ $secondCallResult = OutputFormat::create();
+
+ self::assertNotSame($firstCallResult, $secondCallResult);
+ }
+
+ /**
+ * @test
+ */
+ public function createCompactReturnsOutputFormatInstance(): void
+ {
+ self::assertInstanceOf(OutputFormat::class, OutputFormat::createCompact());
+ }
+
+ /**
+ * @test
+ */
+ public function createCompactCalledTwoTimesReturnsDifferentInstances(): void
+ {
+ $firstCallResult = OutputFormat::createCompact();
+ $secondCallResult = OutputFormat::createCompact();
+
+ self::assertNotSame($firstCallResult, $secondCallResult);
+ }
+
+ /**
+ * @test
+ */
+ public function createCompactReturnsInstanceWithSpaceBeforeRulesSetToEmptyString(): void
+ {
+ $newInstance = OutputFormat::createCompact();
+
+ self::assertSame('', $newInstance->getSpaceBeforeRules());
+ }
+
+ /**
+ * @test
+ */
+ public function createCompactReturnsInstanceWithSpaceBetweenRulesSetToEmptyString(): void
+ {
+ $newInstance = OutputFormat::createCompact();
+
+ self::assertSame('', $newInstance->getSpaceBetweenRules());
+ }
+
+ /**
+ * @test
+ */
+ public function createCompactReturnsInstanceWithSpaceAfterRulesSetToEmptyString(): void
+ {
+ $newInstance = OutputFormat::createCompact();
+
+ self::assertSame('', $newInstance->getSpaceAfterRules());
+ }
+
+ /**
+ * @test
+ */
+ public function createCompactReturnsInstanceWithSpaceBeforeBlocksSetToEmptyString(): void
+ {
+ $newInstance = OutputFormat::createCompact();
+
+ self::assertSame('', $newInstance->getSpaceBeforeBlocks());
+ }
+
+ /**
+ * @test
+ */
+ public function createCompactReturnsInstanceWithSpaceBetweenBlocksSetToEmptyString(): void
+ {
+ $newInstance = OutputFormat::createCompact();
+
+ self::assertSame('', $newInstance->getSpaceBetweenBlocks());
+ }
+
+ /**
+ * @test
+ */
+ public function createCompactReturnsInstanceWithSpaceAfterBlocksSetToEmptyString(): void
+ {
+ $newInstance = OutputFormat::createCompact();
+
+ self::assertSame('', $newInstance->getSpaceAfterBlocks());
+ }
+
+ /**
+ * @test
+ */
+ public function createCompactReturnsInstanceWithSpaceAfterRuleNameSetToEmptyString(): void
+ {
+ $newInstance = OutputFormat::createCompact();
+
+ self::assertSame('', $newInstance->getSpaceAfterRuleName());
+ }
+
+ /**
+ * @test
+ */
+ public function createCompactReturnsInstanceWithSpaceBeforeOpeningBraceSetToEmptyString(): void
+ {
+ $newInstance = OutputFormat::createCompact();
+
+ self::assertSame('', $newInstance->getSpaceBeforeOpeningBrace());
+ }
+
+ /**
+ * @test
+ */
+ public function createCompactReturnsInstanceWithSpaceAfterSelectorSeparatorSetToEmptyString(): void
+ {
+ $newInstance = OutputFormat::createCompact();
+
+ self::assertSame('', $newInstance->getSpaceAfterSelectorSeparator());
+ }
+
+ /**
+ * @test
+ */
+ public function createCompactReturnsInstanceWithSpaceAfterListArgumentSeparatorsSetToEmptyArray(): void
+ {
+ $newInstance = OutputFormat::createCompact();
+
+ self::assertSame([], $newInstance->getSpaceAfterListArgumentSeparators());
+ }
+
+ /**
+ * @test
+ */
+ public function createCompactReturnsInstanceWithRenderCommentsDisabled(): void
+ {
+ $newInstance = OutputFormat::createCompact();
+
+ self::assertFalse($newInstance->shouldRenderComments());
+ }
+
+ /**
+ * @test
+ */
+ public function createPrettyReturnsOutputFormatInstance(): void
+ {
+ self::assertInstanceOf(OutputFormat::class, OutputFormat::createPretty());
+ }
+
+ /**
+ * @test
+ */
+ public function createPrettyCalledTwoTimesReturnsDifferentInstances(): void
+ {
+ $firstCallResult = OutputFormat::createPretty();
+ $secondCallResult = OutputFormat::createPretty();
+
+ self::assertNotSame($firstCallResult, $secondCallResult);
+ }
+
+ /**
+ * @test
+ */
+ public function createPrettyReturnsInstanceWithSpaceBeforeRulesSetToNewline(): void
+ {
+ $newInstance = OutputFormat::createPretty();
+
+ self::assertSame("\n", $newInstance->getSpaceBeforeRules());
+ }
+
+ /**
+ * @test
+ */
+ public function createPrettyReturnsInstanceWithSpaceBetweenRulesSetToNewline(): void
+ {
+ $newInstance = OutputFormat::createPretty();
+
+ self::assertSame("\n", $newInstance->getSpaceBetweenRules());
+ }
+
+ /**
+ * @test
+ */
+ public function createPrettyReturnsInstanceWithSpaceAfterRulesSetToNewline(): void
+ {
+ $newInstance = OutputFormat::createPretty();
+
+ self::assertSame("\n", $newInstance->getSpaceAfterRules());
+ }
+
+ /**
+ * @test
+ */
+ public function createPrettyReturnsInstanceWithSpaceBeforeBlocksSetToNewline(): void
+ {
+ $newInstance = OutputFormat::createPretty();
+
+ self::assertSame("\n", $newInstance->getSpaceBeforeBlocks());
+ }
+
+ /**
+ * @test
+ */
+ public function createPrettyReturnsInstanceWithSpaceBetweenBlocksSetToTwoNewlines(): void
+ {
+ $newInstance = OutputFormat::createPretty();
+
+ self::assertSame("\n\n", $newInstance->getSpaceBetweenBlocks());
+ }
+
+ /**
+ * @test
+ */
+ public function createPrettyReturnsInstanceWithSpaceAfterBlocksSetToNewline(): void
+ {
+ $newInstance = OutputFormat::createPretty();
+
+ self::assertSame("\n", $newInstance->getSpaceAfterBlocks());
+ }
+
+ /**
+ * @test
+ */
+ public function createPrettyReturnsInstanceWithSpaceAfterRuleNameSetToSpace(): void
+ {
+ $newInstance = OutputFormat::createPretty();
+
+ self::assertSame(' ', $newInstance->getSpaceAfterRuleName());
+ }
+
+ /**
+ * @test
+ */
+ public function createPrettyReturnsInstanceWithSpaceBeforeOpeningBraceSetToSpace(): void
+ {
+ $newInstance = OutputFormat::createPretty();
+
+ self::assertSame(' ', $newInstance->getSpaceBeforeOpeningBrace());
+ }
+
+ /**
+ * @test
+ */
+ public function createPrettyReturnsInstanceWithSpaceAfterSelectorSeparatorSetToSpace(): void
+ {
+ $newInstance = OutputFormat::createPretty();
+
+ self::assertSame(' ', $newInstance->getSpaceAfterSelectorSeparator());
+ }
+
+ /**
+ * @test
+ */
+ public function createPrettyReturnsInstanceWithSpaceAfterListArgumentSeparatorsSetToSpaceForCommaOnly(): void
+ {
+ $newInstance = OutputFormat::createPretty();
+
+ self::assertSame([',' => ' '], $newInstance->getSpaceAfterListArgumentSeparators());
+ }
+
+ /**
+ * @test
+ */
+ public function createPrettyReturnsInstanceWithRenderCommentsEnabled(): void
+ {
+ $newInstance = OutputFormat::createPretty();
+
+ self::assertTrue($newInstance->shouldRenderComments());
+ }
+}
diff --git a/tests/Unit/OutputFormatterTest.php b/tests/Unit/OutputFormatterTest.php
new file mode 100644
index 000000000..2caf30e40
--- /dev/null
+++ b/tests/Unit/OutputFormatterTest.php
@@ -0,0 +1,622 @@
+outputFormat = new OutputFormat();
+ $this->subject = new OutputFormatter($this->outputFormat);
+ }
+
+ /**
+ * @test
+ */
+ public function spaceAfterRuleNameReturnsSpaceAfterRuleNameFromOutputFormat(): void
+ {
+ $space = ' ';
+ $this->outputFormat->setSpaceAfterRuleName($space);
+
+ self::assertSame($space, $this->subject->spaceAfterRuleName());
+ }
+
+ /**
+ * @test
+ */
+ public function spaceBeforeRulesReturnsSpaceBeforeRulesFromOutputFormat(): void
+ {
+ $space = ' ';
+ $this->outputFormat->setSpaceBeforeRules($space);
+
+ self::assertSame($space, $this->subject->spaceBeforeRules());
+ }
+
+ /**
+ * @test
+ */
+ public function spaceAfterRulesReturnsSpaceAfterRulesFromOutputFormat(): void
+ {
+ $space = ' ';
+ $this->outputFormat->setSpaceAfterRules($space);
+
+ self::assertSame($space, $this->subject->spaceAfterRules());
+ }
+
+ /**
+ * @test
+ */
+ public function spaceBetweenRulesReturnsSpaceBetweenRulesFromOutputFormat(): void
+ {
+ $space = ' ';
+ $this->outputFormat->setSpaceBetweenRules($space);
+
+ self::assertSame($space, $this->subject->spaceBetweenRules());
+ }
+
+ /**
+ * @test
+ */
+ public function spaceBeforeBlocksReturnsSpaceBeforeBlocksFromOutputFormat(): void
+ {
+ $space = ' ';
+ $this->outputFormat->setSpaceBeforeBlocks($space);
+
+ self::assertSame($space, $this->subject->spaceBeforeBlocks());
+ }
+
+ /**
+ * @test
+ */
+ public function spaceAfterBlocksReturnsSpaceAfterBlocksFromOutputFormat(): void
+ {
+ $space = ' ';
+ $this->outputFormat->setSpaceAfterBlocks($space);
+
+ self::assertSame($space, $this->subject->spaceAfterBlocks());
+ }
+
+ /**
+ * @test
+ */
+ public function spaceBetweenBlocksReturnsSpaceBetweenBlocksFromOutputFormat(): void
+ {
+ $space = ' ';
+ $this->outputFormat->setSpaceBetweenBlocks($space);
+
+ self::assertSame($space, $this->subject->spaceBetweenBlocks());
+ }
+
+ /**
+ * @test
+ */
+ public function spaceBeforeSelectorSeparatorReturnsSpaceBeforeSelectorSeparatorFromOutputFormat(): void
+ {
+ $space = ' ';
+ $this->outputFormat->setSpaceBeforeSelectorSeparator($space);
+
+ self::assertSame($space, $this->subject->spaceBeforeSelectorSeparator());
+ }
+
+ /**
+ * @test
+ */
+ public function spaceAfterSelectorSeparatorReturnsSpaceAfterSelectorSeparatorFromOutputFormat(): void
+ {
+ $space = ' ';
+ $this->outputFormat->setSpaceAfterSelectorSeparator($space);
+
+ self::assertSame($space, $this->subject->spaceAfterSelectorSeparator());
+ }
+
+ /**
+ * @test
+ */
+ public function spaceBeforeListArgumentSeparatorReturnsSpaceSetForSpecificSeparator(): void
+ {
+ $separator = ',';
+ $space = ' ';
+ $this->outputFormat->setSpaceBeforeListArgumentSeparators([$separator => $space]);
+ $defaultSpace = "\t\t\t\t";
+ $this->outputFormat->setSpaceBeforeListArgumentSeparator($defaultSpace);
+
+ self::assertSame($space, $this->subject->spaceBeforeListArgumentSeparator($separator));
+ }
+
+ /**
+ * @test
+ */
+ public function spaceBeforeListArgumentSeparatorWithoutSpecificSettingReturnsDefaultSpace(): void
+ {
+ $space = ' ';
+ $this->outputFormat->setSpaceBeforeListArgumentSeparators([',' => $space]);
+ $defaultSpace = "\t\t\t\t";
+ $this->outputFormat->setSpaceBeforeListArgumentSeparator($defaultSpace);
+
+ self::assertSame($defaultSpace, $this->subject->spaceBeforeListArgumentSeparator(';'));
+ }
+
+ /**
+ * @test
+ */
+ public function spaceAfterListArgumentSeparatorReturnsSpaceSetForSpecificSeparator(): void
+ {
+ $separator = ',';
+ $space = ' ';
+ $this->outputFormat->setSpaceAfterListArgumentSeparators([$separator => $space]);
+ $defaultSpace = "\t\t\t\t";
+ $this->outputFormat->setSpaceAfterListArgumentSeparator($defaultSpace);
+
+ self::assertSame($space, $this->subject->spaceAfterListArgumentSeparator($separator));
+ }
+
+ /**
+ * @test
+ */
+ public function spaceAfterListArgumentSeparatorWithoutSpecificSettingReturnsDefaultSpace(): void
+ {
+ $space = ' ';
+ $this->outputFormat->setSpaceAfterListArgumentSeparators([',' => $space]);
+ $defaultSpace = "\t\t\t\t";
+ $this->outputFormat->setSpaceAfterListArgumentSeparator($defaultSpace);
+
+ self::assertSame($defaultSpace, $this->subject->spaceAfterListArgumentSeparator(';'));
+ }
+
+ /**
+ * @test
+ */
+ public function spaceBeforeOpeningBraceReturnsSpaceBeforeOpeningBraceFromOutputFormat(): void
+ {
+ $space = ' ';
+ $this->outputFormat->setSpaceBeforeOpeningBrace($space);
+
+ self::assertSame($space, $this->subject->spaceBeforeOpeningBrace());
+ }
+
+ /**
+ * @test
+ */
+ public function implodeForEmptyValuesReturnsEmptyString(): void
+ {
+ $values = [];
+
+ $result = $this->subject->implode(', ', $values);
+
+ self::assertSame('', $result);
+ }
+
+ /**
+ * @test
+ */
+ public function implodeWithOneStringValueReturnsStringValue(): void
+ {
+ $value = 'tea';
+ $values = [$value];
+
+ $result = $this->subject->implode(', ', $values);
+
+ self::assertSame($value, $result);
+ }
+
+ /**
+ * @test
+ */
+ public function implodeWithMultipleStringValuesReturnsValuesSeparatedBySeparator(): void
+ {
+ $value1 = 'tea';
+ $value2 = 'coffee';
+ $values = [$value1, $value2];
+ $separator = ', ';
+
+ $result = $this->subject->implode($separator, $values);
+
+ self::assertSame($value1 . $separator . $value2, $result);
+ }
+
+ /**
+ * @test
+ */
+ public function implodeWithOneRenderableReturnsRenderedRenderable(): void
+ {
+ $renderable = $this->createMock(Renderable::class);
+ $renderedRenderable = 'tea';
+ $renderable->method('render')->with($this->outputFormat)->willReturn($renderedRenderable);
+ $values = [$renderable];
+
+ $result = $this->subject->implode(', ', $values);
+
+ self::assertSame($renderedRenderable, $result);
+ }
+
+ /**
+ * @test
+ */
+ public function implodeWithMultipleRenderablesReturnsRenderedRenderablesSeparatedBySeparator(): void
+ {
+ $renderable1 = $this->createMock(Renderable::class);
+ $renderedRenderable1 = 'tea';
+ $renderable1->method('render')->with($this->outputFormat)->willReturn($renderedRenderable1);
+ $renderable2 = $this->createMock(Renderable::class);
+ $renderedRenderable2 = 'coffee';
+ $renderable2->method('render')->with($this->outputFormat)->willReturn($renderedRenderable2);
+ $values = [$renderable1, $renderable2];
+ $separator = ', ';
+
+ $result = $this->subject->implode($separator, $values);
+
+ self::assertSame($renderedRenderable1 . $separator . $renderedRenderable2, $result);
+ }
+
+ /**
+ * @test
+ */
+ public function implodeWithIncreaseLevelFalseUsesDefaultIndentationLevelForRendering(): void
+ {
+ $renderable = $this->createMock(Renderable::class);
+ $renderedRenderable = 'tea';
+ $renderable->method('render')->with($this->outputFormat)->willReturn($renderedRenderable);
+ $values = [$renderable];
+
+ $result = $this->subject->implode(', ', $values, false);
+
+ self::assertSame($renderedRenderable, $result);
+ }
+
+ /**
+ * @test
+ */
+ public function implodeWithIncreaseLevelTrueIncreasesIndentationLevelForRendering(): void
+ {
+ $renderable = $this->createMock(Renderable::class);
+ $renderedRenderable = 'tea';
+ $renderable->method('render')->with($this->outputFormat->nextLevel())->willReturn($renderedRenderable);
+ $values = [$renderable];
+
+ $result = $this->subject->implode(', ', $values, true);
+
+ self::assertSame($renderedRenderable, $result);
+ }
+
+ /**
+ * @return array
+ */
+ public function provideUnchangedStringForRemoveLastSemicolon(): array
+ {
+ return [
+ 'empty string' => [''],
+ 'string without semicolon' => ['earl-grey: hot'],
+ 'string with trailing semicolon' => ['Earl Grey: hot;'],
+ 'string with semicolon in the middle' => ['Earl Grey: hot; Coffee: Americano'],
+ 'string with semicolons in the middle and trailing' => ['Earl Grey: hot; Coffee: Americano;'],
+ ];
+ }
+
+ /**
+ * @test
+ * @dataProvider provideUnchangedStringForRemoveLastSemicolon
+ */
+ public function removeLastSemicolonWithSemicolonAfterLastRuleEnabledReturnsUnchangedArgument(string $string): void
+ {
+ $this->outputFormat->setSemicolonAfterLastRule(true);
+
+ $result = $this->subject->removeLastSemicolon($string);
+
+ self::assertSame($string, $result);
+ }
+
+ /**
+ * @return array
+ */
+ public function provideChangedStringForRemoveLastSemicolon(): array
+ {
+ return [
+ 'empty string' => ['', ''],
+ 'non-empty string without semicolon' => ['Earl Grey: hot', 'Earl Grey: hot'],
+ 'just 1 semicolon' => [';', ''],
+ 'just 2 semicolons' => [';;', ';'],
+ 'string with trailing semicolon' => ['Earl Grey: hot;', 'Earl Grey: hot'],
+ 'string with semicolon in the middle' => [
+ 'Earl Grey: hot; Coffee: Americano',
+ 'Earl Grey: hot Coffee: Americano',
+ ],
+ 'string with semicolon in the middle and trailing' => [
+ 'Earl Grey: hot; Coffee: Americano;',
+ 'Earl Grey: hot; Coffee: Americano',
+ ],
+ 'string with 2 semicolons in the middle' => ['tea; coffee; Club-Mate', 'tea; coffee Club-Mate'],
+ 'string with 2 semicolons in the middle surrounded by spaces' => [
+ 'Earl Grey: hot ; Coffee: Americano ; Club-Mate: cold',
+ 'Earl Grey: hot ; Coffee: Americano Club-Mate: cold',
+ ],
+ 'string with 2 adjacent semicolons in the middle' => [
+ 'Earl Grey: hot;; Coffee: Americano',
+ 'Earl Grey: hot; Coffee: Americano',
+ ],
+ 'string with 3 adjacent semicolons in the middle' => [
+ 'Earl Grey: hot;;; Coffee: Americano',
+ 'Earl Grey: hot;; Coffee: Americano',
+ ],
+ ];
+ }
+
+ /**
+ * @test
+ * @dataProvider provideChangedStringForRemoveLastSemicolon
+ */
+ public function removeLastSemicolonWithSemicolonAfterLastRuleDisabledRemovesLastSemicolon(
+ string $input,
+ string $expected
+ ): void {
+ $this->outputFormat->setSemicolonAfterLastRule(false);
+
+ $result = $this->subject->removeLastSemicolon($input);
+
+ self::assertSame($expected, $result);
+ }
+
+ /**
+ * @test
+ */
+ public function commentsWithEmptyCommentableAndRenderCommentsDisabledDoesNotReturnSpaceBetweenBlocks(): void
+ {
+ $this->outputFormat->setRenderComments(false);
+ $spaceBetweenBlocks = ' between-space ';
+ $this->outputFormat->setSpaceBetweenBlocks($spaceBetweenBlocks);
+
+ $commentable = $this->createMock(Commentable::class);
+ $commentable->method('getComments')->willReturn([]);
+
+ $result = $this->subject->comments($commentable);
+
+ self::assertStringNotContainsString($spaceBetweenBlocks, $result);
+ }
+
+ /**
+ * @test
+ */
+ public function commentsWithEmptyCommentableAndRenderCommentsDisabledDoesNotReturnSpaceAfterBlocks(): void
+ {
+ $this->outputFormat->setRenderComments(false);
+ $spaceAfterBlocks = ' after-space ';
+ $this->outputFormat->setSpaceAfterBlocks($spaceAfterBlocks);
+
+ $commentable = $this->createMock(Commentable::class);
+ $commentable->method('getComments')->willReturn([]);
+
+ $result = $this->subject->comments($commentable);
+
+ self::assertStringNotContainsString($spaceAfterBlocks, $result);
+ }
+
+ /**
+ * @test
+ */
+ public function commentsWithEmptyCommentableAndRenderCommentsDisabledReturnsEmptyString(): void
+ {
+ $this->outputFormat->setRenderComments(false);
+
+ $commentable = $this->createMock(Commentable::class);
+ $commentable->method('getComments')->willReturn([]);
+
+ $result = $this->subject->comments($commentable);
+
+ self::assertSame('', $result);
+ }
+
+ /**
+ * @test
+ */
+ public function commentsWithEmptyCommentableAndRenderCommentsEnabledDoesNotReturnSpaceBetweenBlocks(): void
+ {
+ $this->outputFormat->setRenderComments(true);
+ $spaceBetweenBlocks = ' between-space ';
+ $this->outputFormat->setSpaceBetweenBlocks($spaceBetweenBlocks);
+
+ $commentable = $this->createMock(Commentable::class);
+ $commentable->method('getComments')->willReturn([]);
+
+ $result = $this->subject->comments($commentable);
+
+ self::assertStringNotContainsString($spaceBetweenBlocks, $result);
+ }
+
+ /**
+ * @test
+ */
+ public function commentsWithEmptyCommentableAndRenderCommentsEnabledDoesNotReturnSpaceAfterBlocks(): void
+ {
+ $this->outputFormat->setRenderComments(true);
+ $spaceAfterBlocks = ' after-space ';
+ $this->outputFormat->setSpaceAfterBlocks($spaceAfterBlocks);
+
+ $commentable = $this->createMock(Commentable::class);
+ $commentable->method('getComments')->willReturn([]);
+
+ $result = $this->subject->comments($commentable);
+
+ self::assertStringNotContainsString($spaceAfterBlocks, $result);
+ }
+
+ /**
+ * @test
+ */
+ public function commentsWithEmptyCommentableAndRenderCommentsEnabledReturnsEmptyString(): void
+ {
+ $this->outputFormat->setRenderComments(true);
+
+ $commentable = $this->createMock(Commentable::class);
+ $commentable->method('getComments')->willReturn([]);
+
+ $result = $this->subject->comments($commentable);
+
+ self::assertSame('', $result);
+ }
+
+ /**
+ * @test
+ */
+ public function commentsWithCommentableWithOneCommentAndRenderCommentsDisabledReturnsEmptyString(): void
+ {
+ $this->outputFormat->setRenderComments(false);
+
+ $commentText = 'I am a teapot.';
+ $comment = new Comment($commentText);
+ $commentable = $this->createMock(Commentable::class);
+ $commentable->method('getComments')->willReturn([$comment]);
+
+ $result = $this->subject->comments($commentable);
+
+ self::assertSame('', $result);
+ }
+
+ /**
+ * @test
+ */
+ public function commentsWithCommentableWithOneCommentRendersComment(): void
+ {
+ $this->outputFormat->setRenderComments(true);
+
+ $commentText = 'I am a teapot.';
+ $comment = new Comment($commentText);
+ $commentable = $this->createMock(Commentable::class);
+ $commentable->method('getComments')->willReturn([$comment]);
+
+ $result = $this->subject->comments($commentable);
+
+ self::assertStringContainsString('/*' . $commentText . '*/', $result);
+ }
+
+ /**
+ * @test
+ */
+ public function commentsWithCommentableWithOneCommentPutsSpaceAfterBlocksAfterRenderedComment(): void
+ {
+ $this->outputFormat->setRenderComments(true);
+ $afterSpace = ' after-space ';
+ $this->outputFormat->setSpaceAfterBlocks($afterSpace);
+
+ $commentText = 'I am a teapot.';
+ $comment = new Comment($commentText);
+ $commentable = $this->createMock(Commentable::class);
+ $commentable->method('getComments')->willReturn([$comment]);
+
+ $result = $this->subject->comments($commentable);
+
+ self::assertSame('/*' . $commentText . '*/' . $afterSpace, $result);
+ }
+
+ /**
+ * @test
+ */
+ public function commentsWithCommentableWithTwoCommentsPutsSpaceAfterBlocksAfterLastRenderedComment(): void
+ {
+ $this->outputFormat->setRenderComments(true);
+ $afterSpace = ' after-space ';
+ $this->outputFormat->setSpaceAfterBlocks($afterSpace);
+
+ $commentText1 = 'I am a teapot.';
+ $comment1 = new Comment($commentText1);
+ $commentText2 = 'But I am not.';
+ $comment2 = new Comment($commentText2);
+ $commentable = $this->createMock(Commentable::class);
+ $commentable->method('getComments')->willReturn([$comment1, $comment2]);
+
+ $result = $this->subject->comments($commentable);
+
+ self::assertStringContainsString('/*' . $commentText2 . '*/' . $afterSpace, $result);
+ }
+
+ /**
+ * @test
+ */
+ public function commentsWithCommentableWithTwoCommentsSeparatesCommentsBySpaceBetweenBlocks(): void
+ {
+ $this->outputFormat->setRenderComments(true);
+ $betweenSpace = ' between-space ';
+ $this->outputFormat->setSpaceBetweenBlocks($betweenSpace);
+
+ $commentText1 = 'I am a teapot.';
+ $comment1 = new Comment($commentText1);
+ $commentText2 = 'But I am not.';
+ $comment2 = new Comment($commentText2);
+ $commentable = $this->createMock(Commentable::class);
+ $commentable->method('getComments')->willReturn([$comment1, $comment2]);
+
+ $result = $this->subject->comments($commentable);
+
+ $expected = '/*' . $commentText1 . '*/' . $betweenSpace . '/*' . $commentText2 . '*/';
+ self::assertStringContainsString($expected, $result);
+ }
+
+ /**
+ * @test
+ */
+ public function commentsWithCommentableWithMoreThanTwoCommentsPutsSpaceAfterBlocksAfterLastRenderedComment(): void
+ {
+ $this->outputFormat->setRenderComments(true);
+ $afterSpace = ' after-space ';
+ $this->outputFormat->setSpaceAfterBlocks($afterSpace);
+
+ $commentText1 = 'I am a teapot.';
+ $comment1 = new Comment($commentText1);
+ $commentText2 = 'But I am not.';
+ $comment2 = new Comment($commentText2);
+ $commentText3 = 'So what am I then?';
+ $comment3 = new Comment($commentText3);
+ $commentable = $this->createMock(Commentable::class);
+ $commentable->method('getComments')->willReturn([$comment1, $comment2, $comment3]);
+
+ $result = $this->subject->comments($commentable);
+
+ self::assertStringContainsString('/*' . $commentText3 . '*/' . $afterSpace, $result);
+ }
+
+ /**
+ * @test
+ */
+ public function commentsWithCommentableWithMoreThanTwoCommentsSeparatesCommentsBySpaceBetweenBlocks(): void
+ {
+ $this->outputFormat->setRenderComments(true);
+ $betweenSpace = ' between-space ';
+ $this->outputFormat->setSpaceBetweenBlocks($betweenSpace);
+
+ $commentText1 = 'I am a teapot.';
+ $comment1 = new Comment($commentText1);
+ $commentText2 = 'But I am not.';
+ $comment2 = new Comment($commentText2);
+ $commentText3 = 'So what am I then?';
+ $comment3 = new Comment($commentText3);
+ $commentable = $this->createMock(Commentable::class);
+ $commentable->method('getComments')->willReturn([$comment1, $comment2, $comment3]);
+
+ $result = $this->subject->comments($commentable);
+
+ $expected = '/*' . $commentText1 . '*/'
+ . $betweenSpace . '/*' . $commentText2 . '*/'
+ . $betweenSpace . '/*' . $commentText3 . '*/';
+ self::assertStringContainsString($expected, $result);
+ }
+}
diff --git a/tests/Unit/Parsing/OutputExceptionTest.php b/tests/Unit/Parsing/OutputExceptionTest.php
new file mode 100644
index 000000000..d3409aa49
--- /dev/null
+++ b/tests/Unit/Parsing/OutputExceptionTest.php
@@ -0,0 +1,76 @@
+getMessage());
+ }
+
+ /**
+ * @test
+ */
+ public function getLineNoByDefaultReturnsZero(): void
+ {
+ $exception = new OutputException('foo');
+
+ self::assertSame(0, $exception->getLineNo());
+ }
+
+ /**
+ * @test
+ */
+ public function getLineNoReturnsLineNumberProvidedToConstructor(): void
+ {
+ $lineNumber = 17;
+ $exception = new OutputException('foo', $lineNumber);
+
+ self::assertSame($lineNumber, $exception->getLineNo());
+ }
+
+ /**
+ * @test
+ */
+ public function getMessageWithLineNumberProvidedIncludesLineNumber(): void
+ {
+ $lineNumber = 17;
+ $exception = new OutputException('foo', $lineNumber);
+
+ self::assertStringContainsString(' [line no: ' . $lineNumber . ']', $exception->getMessage());
+ }
+
+ /**
+ * @test
+ */
+ public function canBeThrown(): void
+ {
+ $this->expectException(OutputException::class);
+
+ throw new OutputException('foo');
+ }
+}
diff --git a/tests/Unit/Parsing/SourceExceptionTest.php b/tests/Unit/Parsing/SourceExceptionTest.php
new file mode 100644
index 000000000..b497ff52c
--- /dev/null
+++ b/tests/Unit/Parsing/SourceExceptionTest.php
@@ -0,0 +1,67 @@
+getMessage());
+ }
+
+ /**
+ * @test
+ */
+ public function getLineNoByDefaultReturnsZero(): void
+ {
+ $exception = new SourceException('foo');
+
+ self::assertSame(0, $exception->getLineNo());
+ }
+
+ /**
+ * @test
+ */
+ public function getLineNoReturnsLineNumberProvidedToConstructor(): void
+ {
+ $lineNumber = 17;
+ $exception = new SourceException('foo', $lineNumber);
+
+ self::assertSame($lineNumber, $exception->getLineNo());
+ }
+
+ /**
+ * @test
+ */
+ public function getMessageWithLineNumberProvidedIncludesLineNumber(): void
+ {
+ $lineNumber = 17;
+ $exception = new SourceException('foo', $lineNumber);
+
+ self::assertStringContainsString(' [line no: ' . $lineNumber . ']', $exception->getMessage());
+ }
+
+ /**
+ * @test
+ */
+ public function canBeThrown(): void
+ {
+ $this->expectException(SourceException::class);
+
+ throw new SourceException('foo');
+ }
+}
diff --git a/tests/Unit/Parsing/UnexpectedEOFExceptionTest.php b/tests/Unit/Parsing/UnexpectedEOFExceptionTest.php
new file mode 100644
index 000000000..929609efd
--- /dev/null
+++ b/tests/Unit/Parsing/UnexpectedEOFExceptionTest.php
@@ -0,0 +1,177 @@
+getLineNo());
+ }
+
+ /**
+ * @test
+ */
+ public function getLineNoReturnsLineNumberProvidedToConstructor(): void
+ {
+ $lineNumber = 17;
+ $exception = new UnexpectedEOFException('expected', 'found', 'literal', $lineNumber);
+
+ self::assertSame($lineNumber, $exception->getLineNo());
+ }
+
+ /**
+ * @test
+ */
+ public function getMessageWithLineNumberProvidedIncludesLineNumber(): void
+ {
+ $lineNumber = 17;
+ $exception = new UnexpectedEOFException('expected', 'found', 'literal', $lineNumber);
+
+ self::assertStringContainsString(' [line no: ' . $lineNumber . ']', $exception->getMessage());
+ }
+
+ /**
+ * @test
+ */
+ public function canBeThrown(): void
+ {
+ $this->expectException(UnexpectedEOFException::class);
+
+ throw new UnexpectedEOFException('expected', 'found');
+ }
+
+ /**
+ * @test
+ */
+ public function messageByDefaultRefersToTokenNotFound(): void
+ {
+ $expected = 'tea';
+ $found = 'coffee';
+
+ $exception = new UnexpectedEOFException($expected, $found);
+
+ $expectedMessage = 'Token “' . $expected . '” (literal) not found. Got “' . $found . '”.';
+ self::assertStringContainsString($expectedMessage, $exception->getMessage());
+ }
+
+ /**
+ * @test
+ */
+ public function messageForInvalidMatchTypeRefersToTokenNotFound(): void
+ {
+ $expected = 'tea';
+ $found = 'coffee';
+
+ $exception = new UnexpectedEOFException($expected, $found, 'coding');
+
+ $expectedMessage = 'Token “' . $expected . '” (coding) not found. Got “' . $found . '”.';
+ self::assertStringContainsString($expectedMessage, $exception->getMessage());
+ }
+
+ /**
+ * @test
+ */
+ public function messageForLiteralMatchTypeRefersToTokenNotFound(): void
+ {
+ $expected = 'tea';
+ $found = 'coffee';
+
+ $exception = new UnexpectedEOFException($expected, $found, 'literal');
+
+ $expectedMessage = 'Token “' . $expected . '” (literal) not found. Got “' . $found . '”.';
+ self::assertStringContainsString($expectedMessage, $exception->getMessage());
+ }
+
+ /**
+ * @test
+ */
+ public function messageForSearchMatchTypeRefersToNoResults(): void
+ {
+ $expected = 'tea';
+ $found = 'coffee';
+
+ $exception = new UnexpectedEOFException($expected, $found, 'search');
+
+ $expectedMessage = 'Search for “' . $expected . '” returned no results. Context: “' . $found . '”.';
+ self::assertStringContainsString($expectedMessage, $exception->getMessage());
+ }
+
+ /**
+ * @test
+ */
+ public function messageForCountMatchTypeRefersToNumberOfCharacters(): void
+ {
+ $expected = 'tea';
+ $found = 'coffee';
+
+ $exception = new UnexpectedEOFException($expected, $found, 'count');
+
+ $expectedMessage = 'Next token was expected to have ' . $expected . ' chars. Context: “' . $found . '”.';
+ self::assertStringContainsString($expectedMessage, $exception->getMessage());
+ }
+
+ /**
+ * @test
+ */
+ public function messageForIdentifierMatchTypeRefersToIdentifier(): void
+ {
+ $expected = 'tea';
+ $found = 'coffee';
+
+ $exception = new UnexpectedEOFException($expected, $found, 'identifier');
+
+ $expectedMessage = 'Identifier expected. Got “' . $found . '”';
+ self::assertStringContainsString($expectedMessage, $exception->getMessage());
+ }
+
+ /**
+ * @test
+ */
+ public function messageForCustomMatchTypeMentionsExpectedAndFound(): void
+ {
+ $expected = 'tea';
+ $found = 'coffee';
+
+ $exception = new UnexpectedEOFException($expected, $found, 'custom');
+
+ $expectedMessage = $expected . ' ' . $found;
+ self::assertStringContainsString($expectedMessage, $exception->getMessage());
+ }
+
+ /**
+ * @test
+ */
+ public function messageForCustomMatchTypeTrimsMessage(): void
+ {
+ $expected = 'tea';
+ $found = 'coffee';
+
+ $exception = new UnexpectedEOFException(' ' . $expected, $found . ' ', 'custom');
+
+ $expectedMessage = $expected . ' ' . $found;
+ self::assertStringContainsString($expectedMessage, $exception->getMessage());
+ }
+}
diff --git a/tests/Unit/Parsing/UnexpectedTokenExceptionTest.php b/tests/Unit/Parsing/UnexpectedTokenExceptionTest.php
new file mode 100644
index 000000000..e5c7a64d2
--- /dev/null
+++ b/tests/Unit/Parsing/UnexpectedTokenExceptionTest.php
@@ -0,0 +1,177 @@
+getLineNo());
+ }
+
+ /**
+ * @test
+ */
+ public function getLineNoReturnsLineNumberProvidedToConstructor(): void
+ {
+ $lineNumber = 17;
+ $exception = new UnexpectedTokenException('expected', 'found', 'literal', $lineNumber);
+
+ self::assertSame($lineNumber, $exception->getLineNo());
+ }
+
+ /**
+ * @test
+ */
+ public function getMessageWithLineNumberProvidedIncludesLineNumber(): void
+ {
+ $lineNumber = 17;
+ $exception = new UnexpectedTokenException('expected', 'found', 'literal', $lineNumber);
+
+ self::assertStringContainsString(' [line no: ' . $lineNumber . ']', $exception->getMessage());
+ }
+
+ /**
+ * @test
+ */
+ public function canBeThrown(): void
+ {
+ $this->expectException(UnexpectedTokenException::class);
+
+ throw new UnexpectedTokenException('expected', 'found');
+ }
+
+ /**
+ * @test
+ */
+ public function messageByDefaultRefersToTokenNotFound(): void
+ {
+ $expected = 'tea';
+ $found = 'coffee';
+
+ $exception = new UnexpectedTokenException($expected, $found);
+
+ $expectedMessage = 'Token “' . $expected . '” (literal) not found. Got “' . $found . '”.';
+ self::assertStringContainsString($expectedMessage, $exception->getMessage());
+ }
+
+ /**
+ * @test
+ */
+ public function messageForInvalidMatchTypeRefersToTokenNotFound(): void
+ {
+ $expected = 'tea';
+ $found = 'coffee';
+
+ $exception = new UnexpectedTokenException($expected, $found, 'coding');
+
+ $expectedMessage = 'Token “' . $expected . '” (coding) not found. Got “' . $found . '”.';
+ self::assertStringContainsString($expectedMessage, $exception->getMessage());
+ }
+
+ /**
+ * @test
+ */
+ public function messageForLiteralMatchTypeRefersToTokenNotFound(): void
+ {
+ $expected = 'tea';
+ $found = 'coffee';
+
+ $exception = new UnexpectedTokenException($expected, $found, 'literal');
+
+ $expectedMessage = 'Token “' . $expected . '” (literal) not found. Got “' . $found . '”.';
+ self::assertStringContainsString($expectedMessage, $exception->getMessage());
+ }
+
+ /**
+ * @test
+ */
+ public function messageForSearchMatchTypeRefersToNoResults(): void
+ {
+ $expected = 'tea';
+ $found = 'coffee';
+
+ $exception = new UnexpectedTokenException($expected, $found, 'search');
+
+ $expectedMessage = 'Search for “' . $expected . '” returned no results. Context: “' . $found . '”.';
+ self::assertStringContainsString($expectedMessage, $exception->getMessage());
+ }
+
+ /**
+ * @test
+ */
+ public function messageForCountMatchTypeRefersToNumberOfCharacters(): void
+ {
+ $expected = 'tea';
+ $found = 'coffee';
+
+ $exception = new UnexpectedTokenException($expected, $found, 'count');
+
+ $expectedMessage = 'Next token was expected to have ' . $expected . ' chars. Context: “' . $found . '”.';
+ self::assertStringContainsString($expectedMessage, $exception->getMessage());
+ }
+
+ /**
+ * @test
+ */
+ public function messageForIdentifierMatchTypeRefersToIdentifier(): void
+ {
+ $expected = 'tea';
+ $found = 'coffee';
+
+ $exception = new UnexpectedTokenException($expected, $found, 'identifier');
+
+ $expectedMessage = 'Identifier expected. Got “' . $found . '”';
+ self::assertStringContainsString($expectedMessage, $exception->getMessage());
+ }
+
+ /**
+ * @test
+ */
+ public function messageForCustomMatchTypeMentionsExpectedAndFound(): void
+ {
+ $expected = 'tea';
+ $found = 'coffee';
+
+ $exception = new UnexpectedTokenException($expected, $found, 'custom');
+
+ $expectedMessage = $expected . ' ' . $found;
+ self::assertStringContainsString($expectedMessage, $exception->getMessage());
+ }
+
+ /**
+ * @test
+ */
+ public function messageForCustomMatchTypeTrimsMessage(): void
+ {
+ $expected = 'tea';
+ $found = 'coffee';
+
+ $exception = new UnexpectedTokenException(' ' . $expected, $found . ' ', 'custom');
+
+ $expectedMessage = $expected . ' ' . $found;
+ self::assertStringContainsString($expectedMessage, $exception->getMessage());
+ }
+}
diff --git a/tests/Unit/Position/Fixtures/ConcretePosition.php b/tests/Unit/Position/Fixtures/ConcretePosition.php
new file mode 100644
index 000000000..0db387065
--- /dev/null
+++ b/tests/Unit/Position/Fixtures/ConcretePosition.php
@@ -0,0 +1,13 @@
+subject = new ConcretePosition();
+ }
+
+ /**
+ * @test
+ */
+ public function getLineNumberInitiallyReturnsNull(): void
+ {
+ self::assertNull($this->subject->getLineNumber());
+ }
+
+ /**
+ * @test
+ */
+ public function getColumnNumberInitiallyReturnsNull(): void
+ {
+ self::assertNull($this->subject->getColumnNumber());
+ }
+
+ /**
+ * @return array}>
+ */
+ public function provideLineNumber(): array
+ {
+ return [
+ 'line 1' => [1],
+ 'line 42' => [42],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @param int<1, max> $lineNumber
+ *
+ * @dataProvider provideLineNumber
+ */
+ public function setPositionOnVirginSetsLineNumber(int $lineNumber): void
+ {
+ $this->subject->setPosition($lineNumber);
+
+ self::assertSame($lineNumber, $this->subject->getLineNumber());
+ }
+
+ /**
+ * @test
+ *
+ * @param int<1, max> $lineNumber
+ *
+ * @dataProvider provideLineNumber
+ */
+ public function setPositionSetsNewLineNumber(int $lineNumber): void
+ {
+ $this->subject->setPosition(99);
+
+ $this->subject->setPosition($lineNumber);
+
+ self::assertSame($lineNumber, $this->subject->getLineNumber());
+ }
+
+ /**
+ * @test
+ */
+ public function setPositionWithNullClearsLineNumber(): void
+ {
+ $this->subject->setPosition(99);
+
+ $this->subject->setPosition(null);
+
+ self::assertNull($this->subject->getLineNumber());
+ }
+
+ /**
+ * @return array}>
+ */
+ public function provideColumnNumber(): array
+ {
+ return [
+ 'column 0' => [0],
+ 'column 14' => [14],
+ 'column 39' => [39],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @param int<0, max> $columnNumber
+ *
+ * @dataProvider provideColumnNumber
+ */
+ public function setPositionOnVirginSetsColumnNumber(int $columnNumber): void
+ {
+ $this->subject->setPosition(1, $columnNumber);
+
+ self::assertSame($columnNumber, $this->subject->getColumnNumber());
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideColumnNumber
+ */
+ public function setPositionSetsNewColumnNumber(int $columnNumber): void
+ {
+ $this->subject->setPosition(1, 99);
+
+ $this->subject->setPosition(2, $columnNumber);
+
+ self::assertSame($columnNumber, $this->subject->getColumnNumber());
+ }
+
+ /**
+ * @test
+ */
+ public function setPositionWithoutColumnNumberClearsColumnNumber(): void
+ {
+ $this->subject->setPosition(1, 99);
+
+ $this->subject->setPosition(2);
+
+ self::assertNull($this->subject->getColumnNumber());
+ }
+
+ /**
+ * @test
+ */
+ public function setPositionWithNullForColumnNumberClearsColumnNumber(): void
+ {
+ $this->subject->setPosition(1, 99);
+
+ $this->subject->setPosition(2, null);
+
+ self::assertNull($this->subject->getColumnNumber());
+ }
+
+ /**
+ * @return DataProvider, 1: int<0, max>}>
+ */
+ public function provideLineAndColumnNumber(): DataProvider
+ {
+ return DataProvider::cross($this->provideLineNumber(), $this->provideColumnNumber());
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideLineAndColumnNumber
+ */
+ public function setPositionOnVirginSetsLineAndColumnNumber(int $lineNumber, int $columnNumber): void
+ {
+ $this->subject->setPosition($lineNumber, $columnNumber);
+
+ self::assertSame($lineNumber, $this->subject->getLineNumber());
+ self::assertSame($columnNumber, $this->subject->getColumnNumber());
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideLineAndColumnNumber
+ */
+ public function setPositionSetsNewLineAndColumnNumber(int $lineNumber, int $columnNumber): void
+ {
+ $this->subject->setPosition(98, 99);
+
+ $this->subject->setPosition($lineNumber, $columnNumber);
+
+ self::assertSame($lineNumber, $this->subject->getLineNumber());
+ self::assertSame($columnNumber, $this->subject->getColumnNumber());
+ }
+}
diff --git a/tests/Unit/Property/CSSNamespaceTest.php b/tests/Unit/Property/CSSNamespaceTest.php
new file mode 100644
index 000000000..2e4d99222
--- /dev/null
+++ b/tests/Unit/Property/CSSNamespaceTest.php
@@ -0,0 +1,34 @@
+subject = new CSSNamespace(new CSSString('http://www.w3.org/2000/svg'));
+ }
+
+ /**
+ * @test
+ */
+ public function implementsCSSListItem(): void
+ {
+ self::assertInstanceOf(CSSListItem::class, $this->subject);
+ }
+}
diff --git a/tests/Unit/Property/CharsetTest.php b/tests/Unit/Property/CharsetTest.php
new file mode 100644
index 000000000..e0645f5ef
--- /dev/null
+++ b/tests/Unit/Property/CharsetTest.php
@@ -0,0 +1,34 @@
+subject = new Charset(new CSSString('UTF-8'));
+ }
+
+ /**
+ * @test
+ */
+ public function implementsCSSListItem(): void
+ {
+ self::assertInstanceOf(CSSListItem::class, $this->subject);
+ }
+}
diff --git a/tests/Unit/Property/ImportTest.php b/tests/Unit/Property/ImportTest.php
new file mode 100644
index 000000000..4ec028e3f
--- /dev/null
+++ b/tests/Unit/Property/ImportTest.php
@@ -0,0 +1,35 @@
+subject = new Import(new URL(new CSSString('https://example.org/')), null);
+ }
+
+ /**
+ * @test
+ */
+ public function implementsCSSListItem(): void
+ {
+ self::assertInstanceOf(CSSListItem::class, $this->subject);
+ }
+}
diff --git a/tests/Unit/Property/Selector/SpecificityCalculatorTest.php b/tests/Unit/Property/Selector/SpecificityCalculatorTest.php
new file mode 100644
index 000000000..088bd5179
--- /dev/null
+++ b/tests/Unit/Property/Selector/SpecificityCalculatorTest.php
@@ -0,0 +1,94 @@
+}>
+ */
+ public static function provideSelectorsAndSpecificities(): array
+ {
+ return [
+ 'element' => ['a', 1],
+ 'element and descendant with pseudo-selector' => ['ol li::before', 3],
+ 'class' => ['.highlighted', 10],
+ 'element with class' => ['li.green', 11],
+ 'class with pseudo-selector' => ['.help:hover', 20],
+ 'ID' => ['#file', 100],
+ 'ID and descendant class' => ['#test .help', 110],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @param non-empty-string $selector
+ * @param int<0, max> $expectedSpecificity
+ *
+ * @dataProvider provideSelectorsAndSpecificities
+ */
+ public function calculateReturnsSpecificityForProvidedSelector(
+ string $selector,
+ int $expectedSpecificity
+ ): void {
+ self::assertSame($expectedSpecificity, SpecificityCalculator::calculate($selector));
+ }
+
+ /**
+ * @test
+ *
+ * @param non-empty-string $selector
+ * @param int<0, max> $expectedSpecificity
+ *
+ * @dataProvider provideSelectorsAndSpecificities
+ */
+ public function calculateAfterClearingCacheReturnsSpecificityForProvidedSelector(
+ string $selector,
+ int $expectedSpecificity
+ ): void {
+ SpecificityCalculator::clearCache();
+
+ self::assertSame($expectedSpecificity, SpecificityCalculator::calculate($selector));
+ }
+
+ /**
+ * @test
+ */
+ public function calculateCalledTwoTimesReturnsSameSpecificityForProvidedSelector(): void
+ {
+ $selector = '#test .help';
+
+ $firstResult = SpecificityCalculator::calculate($selector);
+ $secondResult = SpecificityCalculator::calculate($selector);
+
+ self::assertSame($firstResult, $secondResult);
+ }
+
+ /**
+ * @test
+ */
+ public function calculateCalledReturnsSameSpecificityForProvidedSelectorBeforeAndAfterClearingCache(): void
+ {
+ $selector = '#test .help';
+
+ $firstResult = SpecificityCalculator::calculate($selector);
+ SpecificityCalculator::clearCache();
+ $secondResult = SpecificityCalculator::calculate($selector);
+
+ self::assertSame($firstResult, $secondResult);
+ }
+}
diff --git a/tests/Unit/Property/SelectorTest.php b/tests/Unit/Property/SelectorTest.php
new file mode 100644
index 000000000..c2d59b60b
--- /dev/null
+++ b/tests/Unit/Property/SelectorTest.php
@@ -0,0 +1,141 @@
+getSelector());
+ }
+
+ /**
+ * @test
+ */
+ public function setSelectorOverwritesSelectorProvidedToConstructor(): void
+ {
+ $subject = new Selector('a');
+
+ $selector = 'input';
+ $subject->setSelector($selector);
+
+ self::assertSame($selector, $subject->getSelector());
+ }
+
+ /**
+ * @return array}>
+ */
+ public static function provideSelectorsAndSpecificities(): array
+ {
+ return [
+ 'element' => ['a', 1],
+ 'element and descendant with pseudo-selector' => ['ol li::before', 3],
+ 'class' => ['.highlighted', 10],
+ 'element with class' => ['li.green', 11],
+ 'class with pseudo-selector' => ['.help:hover', 20],
+ 'ID' => ['#file', 100],
+ 'ID and descendant class' => ['#test .help', 110],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @param non-empty-string $selector
+ * @param int<0, max> $expectedSpecificity
+ *
+ * @dataProvider provideSelectorsAndSpecificities
+ */
+ public function getSpecificityByDefaultReturnsSpecificityOfSelectorProvidedToConstructor(
+ string $selector,
+ int $expectedSpecificity
+ ): void {
+ $subject = new Selector($selector);
+
+ self::assertSame($expectedSpecificity, $subject->getSpecificity());
+ }
+
+ /**
+ * @test
+ *
+ * @param non-empty-string $selector
+ * @param int<0, max> $expectedSpecificity
+ *
+ * @dataProvider provideSelectorsAndSpecificities
+ */
+ public function getSpecificityReturnsSpecificityOfSelectorLastProvidedViaSetSelector(
+ string $selector,
+ int $expectedSpecificity
+ ): void {
+ $subject = new Selector('p');
+
+ $subject->setSelector($selector);
+
+ self::assertSame($expectedSpecificity, $subject->getSpecificity());
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideSelectorsAndSpecificities
+ */
+ public function isValidForValidSelectorReturnsTrue(string $selector): void
+ {
+ self::assertTrue(Selector::isValid($selector));
+ }
+
+ /**
+ * @return array
+ */
+ public static function provideInvalidSelectors(): array
+ {
+ return [
+ // This is currently broken.
+ // 'empty string' => [''],
+ 'percent sign' => ['%'],
+ // This is currently broken.
+ // 'hash only' => ['#'],
+ // This is currently broken.
+ // 'dot only' => ['.'],
+ 'slash' => ['/'],
+ 'less-than sign' => ['<'],
+ // This is currently broken.
+ // 'whitespace only' => [" \t\n\r"],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideInvalidSelectors
+ */
+ public function isValidForInvalidSelectorReturnsFalse(string $selector): void
+ {
+ self::assertFalse(Selector::isValid($selector));
+ }
+}
diff --git a/tests/Unit/Rule/RuleTest.php b/tests/Unit/Rule/RuleTest.php
new file mode 100644
index 000000000..008bcfc18
--- /dev/null
+++ b/tests/Unit/Rule/RuleTest.php
@@ -0,0 +1,73 @@
+}>
+ */
+ public static function provideRulesAndExpectedParsedValueListTypes(): array
+ {
+ return [
+ 'src (e.g. in @font-face)' => [
+ "
+ src: url('../fonts/open-sans-italic-300.woff2') format('woff2'),
+ url('../fonts/open-sans-italic-300.ttf') format('truetype');
+ ",
+ [RuleValueList::class, RuleValueList::class],
+ ],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @param list $expectedTypeClassnames
+ *
+ * @dataProvider provideRulesAndExpectedParsedValueListTypes
+ */
+ public function parsesValuesIntoExpectedTypeList(string $rule, array $expectedTypeClassnames): void
+ {
+ $subject = Rule::parse(new ParserState($rule, Settings::create()));
+
+ $value = $subject->getValue();
+ self::assertInstanceOf(ValueList::class, $value);
+
+ $actualClassnames = \array_map(
+ /**
+ * @param Value|string $component
+ */
+ static function ($component): string {
+ return \is_string($component) ? 'string' : \get_class($component);
+ },
+ $value->getListComponents()
+ );
+
+ self::assertSame($expectedTypeClassnames, $actualClassnames);
+ }
+}
diff --git a/tests/Unit/RuleSet/AtRuleSetTest.php b/tests/Unit/RuleSet/AtRuleSetTest.php
new file mode 100644
index 000000000..0e3e0c974
--- /dev/null
+++ b/tests/Unit/RuleSet/AtRuleSetTest.php
@@ -0,0 +1,33 @@
+subject = new AtRuleSet('supports');
+ }
+
+ /**
+ * @test
+ */
+ public function implementsCSSListItem(): void
+ {
+ self::assertInstanceOf(CSSListItem::class, $this->subject);
+ }
+}
diff --git a/tests/Unit/RuleSet/DeclarationBlockTest.php b/tests/Unit/RuleSet/DeclarationBlockTest.php
new file mode 100644
index 000000000..b473da354
--- /dev/null
+++ b/tests/Unit/RuleSet/DeclarationBlockTest.php
@@ -0,0 +1,33 @@
+subject = new DeclarationBlock();
+ }
+
+ /**
+ * @test
+ */
+ public function implementsCSSListItem(): void
+ {
+ self::assertInstanceOf(CSSListItem::class, $this->subject);
+ }
+}
diff --git a/tests/Unit/RuleSet/Fixtures/ConcreteRuleSet.php b/tests/Unit/RuleSet/Fixtures/ConcreteRuleSet.php
new file mode 100644
index 000000000..9c79c09dc
--- /dev/null
+++ b/tests/Unit/RuleSet/Fixtures/ConcreteRuleSet.php
@@ -0,0 +1,19 @@
+subject = new ConcreteRuleSet();
+ }
+
+ /**
+ * @test
+ */
+ public function implementsCSSElement(): void
+ {
+ self::assertInstanceOf(CSSElement::class, $this->subject);
+ }
+
+ /**
+ * @test
+ */
+ public function implementsCSSListItem(): void
+ {
+ self::assertInstanceOf(CSSListItem::class, $this->subject);
+ }
+
+ /**
+ * @test
+ */
+ public function implementsRuleContainer(): void
+ {
+ self::assertInstanceOf(RuleContainer::class, $this->subject);
+ }
+
+ /**
+ * @return array}>
+ */
+ public static function providePropertyNames(): array
+ {
+ return [
+ 'no properties' => [[]],
+ 'one property' => [['color']],
+ 'two different properties' => [['color', 'display']],
+ 'two of the same property' => [['color', 'color']],
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public static function provideAnotherPropertyName(): array
+ {
+ return [
+ 'property name `color` maybe matching that of existing declaration' => ['color'],
+ 'property name `display` maybe matching that of existing declaration' => ['display'],
+ 'property name `width` not matching that of existing declaration' => ['width'],
+ ];
+ }
+
+ /**
+ * @return DataProvider, 1: string}>
+ */
+ public static function provideInitialPropertyNamesAndAnotherPropertyName(): DataProvider
+ {
+ return DataProvider::cross(self::providePropertyNames(), self::provideAnotherPropertyName());
+ }
+
+ /**
+ * @test
+ *
+ * @param list $initialPropertyNames
+ *
+ * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName
+ */
+ public function addRuleWithoutPositionWithoutSiblingAddsRuleAfterInitialRules(
+ array $initialPropertyNames,
+ string $propertyNameToAdd
+ ): void {
+ $ruleToAdd = new Rule($propertyNameToAdd);
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+
+ $this->subject->addRule($ruleToAdd);
+
+ $rules = $this->subject->getRules();
+ self::assertSame($ruleToAdd, \end($rules));
+ }
+
+ /**
+ * @test
+ *
+ * @param list $initialPropertyNames
+ *
+ * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName
+ */
+ public function addRuleWithoutPositionWithoutSiblingSetsValidLineNumber(
+ array $initialPropertyNames,
+ string $propertyNameToAdd
+ ): void {
+ $ruleToAdd = new Rule($propertyNameToAdd);
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+
+ $this->subject->addRule($ruleToAdd);
+
+ self::assertIsInt($ruleToAdd->getLineNumber(), 'line number not set');
+ self::assertGreaterThanOrEqual(1, $ruleToAdd->getLineNumber(), 'line number not valid');
+ }
+
+ /**
+ * @test
+ *
+ * @param list $initialPropertyNames
+ *
+ * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName
+ */
+ public function addRuleWithoutPositionWithoutSiblingSetsValidColumnNumber(
+ array $initialPropertyNames,
+ string $propertyNameToAdd
+ ): void {
+ $ruleToAdd = new Rule($propertyNameToAdd);
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+
+ $this->subject->addRule($ruleToAdd);
+
+ self::assertIsInt($ruleToAdd->getColumnNumber(), 'column number not set');
+ self::assertGreaterThanOrEqual(0, $ruleToAdd->getColumnNumber(), 'column number not valid');
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName
+ *
+ * @param list $initialPropertyNames
+ */
+ public function addRuleWithOnlyLineNumberWithoutSiblingAddsRule(
+ array $initialPropertyNames,
+ string $propertyNameToAdd
+ ): void {
+ $ruleToAdd = new Rule($propertyNameToAdd);
+ $ruleToAdd->setPosition(42);
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+
+ $this->subject->addRule($ruleToAdd);
+
+ self::assertContains($ruleToAdd, $this->subject->getRules());
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName
+ *
+ * @param list $initialPropertyNames
+ */
+ public function addRuleWithOnlyLineNumberWithoutSiblingSetsColumnNumber(
+ array $initialPropertyNames,
+ string $propertyNameToAdd
+ ): void {
+ $ruleToAdd = new Rule($propertyNameToAdd);
+ $ruleToAdd->setPosition(42);
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+
+ $this->subject->addRule($ruleToAdd);
+
+ self::assertIsInt($ruleToAdd->getColumnNumber(), 'column number not set');
+ self::assertGreaterThanOrEqual(0, $ruleToAdd->getColumnNumber(), 'column number not valid');
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName
+ *
+ * @param list $initialPropertyNames
+ */
+ public function addRuleWithOnlyLineNumberWithoutSiblingPreservesLineNumber(
+ array $initialPropertyNames,
+ string $propertyNameToAdd
+ ): void {
+ $ruleToAdd = new Rule($propertyNameToAdd);
+ $ruleToAdd->setPosition(42);
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+
+ $this->subject->addRule($ruleToAdd);
+
+ self::assertSame(42, $ruleToAdd->getLineNumber(), 'line number not preserved');
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName
+ *
+ * @param list $initialPropertyNames
+ */
+ public function addRuleWithOnlyColumnNumberWithoutSiblingAddsRuleAfterInitialRules(
+ array $initialPropertyNames,
+ string $propertyNameToAdd
+ ): void {
+ $ruleToAdd = new Rule($propertyNameToAdd);
+ $ruleToAdd->setPosition(null, 42);
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+
+ $this->subject->addRule($ruleToAdd);
+
+ $rules = $this->subject->getRules();
+ self::assertSame($ruleToAdd, \end($rules));
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName
+ *
+ * @param list $initialPropertyNames
+ */
+ public function addRuleWithOnlyColumnNumberWithoutSiblingSetsLineNumber(
+ array $initialPropertyNames,
+ string $propertyNameToAdd
+ ): void {
+ $ruleToAdd = new Rule($propertyNameToAdd);
+ $ruleToAdd->setPosition(null, 42);
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+
+ $this->subject->addRule($ruleToAdd);
+
+ self::assertIsInt($ruleToAdd->getLineNumber(), 'line number not set');
+ self::assertGreaterThanOrEqual(1, $ruleToAdd->getLineNumber(), 'line number not valid');
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName
+ *
+ * @param list $initialPropertyNames
+ */
+ public function addRuleWithOnlyColumnNumberWithoutSiblingPreservesColumnNumber(
+ array $initialPropertyNames,
+ string $propertyNameToAdd
+ ): void {
+ $ruleToAdd = new Rule($propertyNameToAdd);
+ $ruleToAdd->setPosition(null, 42);
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+
+ $this->subject->addRule($ruleToAdd);
+
+ self::assertSame(42, $ruleToAdd->getColumnNumber(), 'column number not preserved');
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName
+ *
+ * @param list $initialPropertyNames
+ */
+ public function addRuleWithCompletePositionWithoutSiblingAddsRule(
+ array $initialPropertyNames,
+ string $propertyNameToAdd
+ ): void {
+ $ruleToAdd = new Rule($propertyNameToAdd);
+ $ruleToAdd->setPosition(42, 64);
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+
+ $this->subject->addRule($ruleToAdd);
+
+ self::assertContains($ruleToAdd, $this->subject->getRules());
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName
+ *
+ * @param list $initialPropertyNames
+ */
+ public function addRuleWithCompletePositionWithoutSiblingPreservesPosition(
+ array $initialPropertyNames,
+ string $propertyNameToAdd
+ ): void {
+ $ruleToAdd = new Rule($propertyNameToAdd);
+ $ruleToAdd->setPosition(42, 64);
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+
+ $this->subject->addRule($ruleToAdd);
+
+ self::assertSame(42, $ruleToAdd->getLineNumber(), 'line number not preserved');
+ self::assertSame(64, $ruleToAdd->getColumnNumber(), 'column number not preserved');
+ }
+
+ /**
+ * @return array, 1: int<0, max>}>
+ */
+ public static function provideInitialPropertyNamesAndIndexOfOne(): array
+ {
+ $initialPropertyNamesSets = self::providePropertyNames();
+
+ // Provide sets with each possible index for the initially set `Rule`s.
+ $initialPropertyNamesAndIndexSets = [];
+ foreach ($initialPropertyNamesSets as $setName => $data) {
+ $initialPropertyNames = $data[0];
+ for ($index = 0; $index < \count($initialPropertyNames); ++$index) {
+ $initialPropertyNamesAndIndexSets[$setName . ', index ' . $index] =
+ [$initialPropertyNames, $index];
+ }
+ }
+
+ return $initialPropertyNamesAndIndexSets;
+ }
+
+ /**
+ * @return DataProvider, 1: int<0, max>, 2: string}>
+ */
+ public static function provideInitialPropertyNamesAndSiblingIndexAndPropertyNameToAdd(): DataProvider
+ {
+ return DataProvider::cross(
+ self::provideInitialPropertyNamesAndIndexOfOne(),
+ self::provideAnotherPropertyName()
+ );
+ }
+
+ /**
+ * @test
+ *
+ * @param non-empty-list $initialPropertyNames
+ * @param int<0, max> $siblingIndex
+ *
+ * @dataProvider provideInitialPropertyNamesAndSiblingIndexAndPropertyNameToAdd
+ */
+ public function addRuleWithSiblingInsertsRuleBeforeSibling(
+ array $initialPropertyNames,
+ int $siblingIndex,
+ string $propertyNameToAdd
+ ): void {
+ $ruleToAdd = new Rule($propertyNameToAdd);
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+ $sibling = $this->subject->getRules()[$siblingIndex];
+
+ $this->subject->addRule($ruleToAdd, $sibling);
+
+ $rules = $this->subject->getRules();
+ $siblingPosition = \array_search($sibling, $rules, true);
+ self::assertIsInt($siblingPosition);
+ self::assertSame($siblingPosition - 1, \array_search($ruleToAdd, $rules, true));
+ }
+
+ /**
+ * @test
+ *
+ * @param non-empty-list $initialPropertyNames
+ * @param int<0, max> $siblingIndex
+ *
+ * @dataProvider provideInitialPropertyNamesAndSiblingIndexAndPropertyNameToAdd
+ */
+ public function addRuleWithSiblingSetsValidLineNumber(
+ array $initialPropertyNames,
+ int $siblingIndex,
+ string $propertyNameToAdd
+ ): void {
+ $ruleToAdd = new Rule($propertyNameToAdd);
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+ $sibling = $this->subject->getRules()[$siblingIndex];
+
+ $this->subject->addRule($ruleToAdd, $sibling);
+
+ self::assertIsInt($ruleToAdd->getLineNumber(), 'line number not set');
+ self::assertGreaterThanOrEqual(1, $ruleToAdd->getLineNumber(), 'line number not valid');
+ }
+
+ /**
+ * @test
+ *
+ * @param non-empty-list $initialPropertyNames
+ * @param int<0, max> $siblingIndex
+ *
+ * @dataProvider provideInitialPropertyNamesAndSiblingIndexAndPropertyNameToAdd
+ */
+ public function addRuleWithSiblingSetsValidColumnNumber(
+ array $initialPropertyNames,
+ int $siblingIndex,
+ string $propertyNameToAdd
+ ): void {
+ $ruleToAdd = new Rule($propertyNameToAdd);
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+ $sibling = $this->subject->getRules()[$siblingIndex];
+
+ $this->subject->addRule($ruleToAdd, $sibling);
+
+ self::assertIsInt($ruleToAdd->getColumnNumber(), 'column number not set');
+ self::assertGreaterThanOrEqual(0, $ruleToAdd->getColumnNumber(), 'column number not valid');
+ }
+
+ /**
+ * @test
+ *
+ * @param list $initialPropertyNames
+ *
+ * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName
+ */
+ public function addRuleWithSiblingNotInSetAddsRuleAfterInitialRules(
+ array $initialPropertyNames,
+ string $propertyNameToAdd
+ ): void {
+ $ruleToAdd = new Rule($propertyNameToAdd);
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+
+ // `display` is sometimes in `$initialPropertyNames` and sometimes the `$propertyNameToAdd`.
+ // Choosing this for the bogus sibling allows testing all combinations of whether it is or isn't.
+ $this->subject->addRule($ruleToAdd, new Rule('display'));
+
+ $rules = $this->subject->getRules();
+ self::assertSame($ruleToAdd, \end($rules));
+ }
+
+ /**
+ * @test
+ *
+ * @param list $initialPropertyNames
+ *
+ * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName
+ */
+ public function addRuleWithSiblingNotInSetSetsValidLineNumber(
+ array $initialPropertyNames,
+ string $propertyNameToAdd
+ ): void {
+ $ruleToAdd = new Rule($propertyNameToAdd);
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+
+ // `display` is sometimes in `$initialPropertyNames` and sometimes the `$propertyNameToAdd`.
+ // Choosing this for the bogus sibling allows testing all combinations of whether it is or isn't.
+ $this->subject->addRule($ruleToAdd, new Rule('display'));
+
+ self::assertIsInt($ruleToAdd->getLineNumber(), 'line number not set');
+ self::assertGreaterThanOrEqual(1, $ruleToAdd->getLineNumber(), 'line number not valid');
+ }
+
+ /**
+ * @test
+ *
+ * @param list $initialPropertyNames
+ *
+ * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName
+ */
+ public function addRuleWithSiblingNotInSetSetsValidColumnNumber(
+ array $initialPropertyNames,
+ string $propertyNameToAdd
+ ): void {
+ $ruleToAdd = new Rule($propertyNameToAdd);
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+
+ // `display` is sometimes in `$initialPropertyNames` and sometimes the `$propertyNameToAdd`.
+ // Choosing this for the bogus sibling allows testing all combinations of whether it is or isn't.
+ $this->subject->addRule($ruleToAdd, new Rule('display'));
+
+ self::assertIsInt($ruleToAdd->getColumnNumber(), 'column number not set');
+ self::assertGreaterThanOrEqual(0, $ruleToAdd->getColumnNumber(), 'column number not valid');
+ }
+
+ /**
+ * @test
+ *
+ * @param non-empty-list $initialPropertyNames
+ * @param int<0, max> $indexToRemove
+ *
+ * @dataProvider provideInitialPropertyNamesAndIndexOfOne
+ */
+ public function removeRuleRemovesRuleInSet(array $initialPropertyNames, int $indexToRemove): void
+ {
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+ $ruleToRemove = $this->subject->getRules()[$indexToRemove];
+
+ $this->subject->removeRule($ruleToRemove);
+
+ self::assertNotContains($ruleToRemove, $this->subject->getRules());
+ }
+
+ /**
+ * @test
+ *
+ * @param non-empty-list $initialPropertyNames
+ * @param int<0, max> $indexToRemove
+ *
+ * @dataProvider provideInitialPropertyNamesAndIndexOfOne
+ */
+ public function removeRuleRemovesExactlyOneRule(array $initialPropertyNames, int $indexToRemove): void
+ {
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+ $ruleToRemove = $this->subject->getRules()[$indexToRemove];
+
+ $this->subject->removeRule($ruleToRemove);
+
+ self::assertCount(\count($initialPropertyNames) - 1, $this->subject->getRules());
+ }
+
+ /**
+ * @test
+ *
+ * @param list $initialPropertyNames
+ *
+ * @dataProvider provideInitialPropertyNamesAndAnotherPropertyName
+ */
+ public function removeRuleWithRuleNotInSetKeepsSetUnchanged(
+ array $initialPropertyNames,
+ string $propertyNameToRemove
+ ): void {
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+ $initialRules = $this->subject->getRules();
+ $ruleToRemove = new Rule($propertyNameToRemove);
+
+ $this->subject->removeRule($ruleToRemove);
+
+ self::assertSame($initialRules, $this->subject->getRules());
+ }
+
+ /**
+ * @return array, 1: string, 2: list}>
+ */
+ public static function providePropertyNamesAndPropertyNameToRemoveAndExpectedRemainingPropertyNames(): array
+ {
+ return [
+ 'removing single rule' => [
+ ['color'],
+ 'color',
+ [],
+ ],
+ 'removing first rule' => [
+ ['color', 'display'],
+ 'color',
+ ['display'],
+ ],
+ 'removing last rule' => [
+ ['color', 'display'],
+ 'display',
+ ['color'],
+ ],
+ 'removing middle rule' => [
+ ['color', 'display', 'width'],
+ 'display',
+ ['color', 'width'],
+ ],
+ 'removing multiple rules' => [
+ ['color', 'color'],
+ 'color',
+ [],
+ ],
+ 'removing multiple rules with another kept' => [
+ ['color', 'color', 'display'],
+ 'color',
+ ['display'],
+ ],
+ 'removing nonexistent rule from empty list' => [
+ [],
+ 'color',
+ [],
+ ],
+ 'removing nonexistent rule from nonempty list' => [
+ ['color', 'display'],
+ 'width',
+ ['color', 'display'],
+ ],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @param list $initialPropertyNames
+ *
+ * @dataProvider providePropertyNamesAndPropertyNameToRemoveAndExpectedRemainingPropertyNames
+ */
+ public function removeMatchingRulesRemovesRulesWithPropertyName(
+ array $initialPropertyNames,
+ string $propertyNameToRemove
+ ): void {
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+
+ $this->subject->removeMatchingRules($propertyNameToRemove);
+
+ self::assertArrayNotHasKey($propertyNameToRemove, $this->subject->getRulesAssoc());
+ }
+
+ /**
+ * @test
+ *
+ * @param list $initialPropertyNames
+ * @param list $expectedRemainingPropertyNames
+ *
+ * @dataProvider providePropertyNamesAndPropertyNameToRemoveAndExpectedRemainingPropertyNames
+ */
+ public function removeMatchingRulesWithPropertyNameKeepsOtherRules(
+ array $initialPropertyNames,
+ string $propertyNameToRemove,
+ array $expectedRemainingPropertyNames
+ ): void {
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+
+ $this->subject->removeMatchingRules($propertyNameToRemove);
+
+ $remainingRules = $this->subject->getRulesAssoc();
+ if ($expectedRemainingPropertyNames === []) {
+ self::assertSame([], $remainingRules);
+ }
+ foreach ($expectedRemainingPropertyNames as $expectedPropertyName) {
+ self::assertArrayHasKey($expectedPropertyName, $remainingRules);
+ }
+ }
+
+ /**
+ * @return array, 1: string, 2: list}>
+ */
+ public static function providePropertyNamesAndPropertyNamePrefixToRemoveAndExpectedRemainingPropertyNames(): array
+ {
+ return [
+ 'removing shorthand rule' => [
+ ['font'],
+ 'font',
+ [],
+ ],
+ 'removing longhand rule' => [
+ ['font-size'],
+ 'font',
+ [],
+ ],
+ 'removing shorthand and longhand rule' => [
+ ['font', 'font-size'],
+ 'font',
+ [],
+ ],
+ 'removing shorthand rule with another kept' => [
+ ['font', 'color'],
+ 'font',
+ ['color'],
+ ],
+ 'removing longhand rule with another kept' => [
+ ['font-size', 'color'],
+ 'font',
+ ['color'],
+ ],
+ 'keeping other rules whose property names begin with the same characters' => [
+ ['contain', 'container', 'container-type'],
+ 'contain',
+ ['container', 'container-type'],
+ ],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @param list $initialPropertyNames
+ *
+ * @dataProvider providePropertyNamesAndPropertyNamePrefixToRemoveAndExpectedRemainingPropertyNames
+ */
+ public function removeMatchingRulesRemovesRulesWithPropertyNamePrefix(
+ array $initialPropertyNames,
+ string $propertyNamePrefix
+ ): void {
+ $propertyNamePrefixWithHyphen = $propertyNamePrefix . '-';
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+
+ $this->subject->removeMatchingRules($propertyNamePrefixWithHyphen);
+
+ $remainingRules = $this->subject->getRulesAssoc();
+ self::assertArrayNotHasKey($propertyNamePrefix, $remainingRules);
+ foreach (\array_keys($remainingRules) as $remainingPropertyName) {
+ self::assertStringStartsNotWith($propertyNamePrefixWithHyphen, $remainingPropertyName);
+ }
+ }
+
+ /**
+ * @test
+ *
+ * @param list $initialPropertyNames
+ * @param list $expectedRemainingPropertyNames
+ *
+ * @dataProvider providePropertyNamesAndPropertyNamePrefixToRemoveAndExpectedRemainingPropertyNames
+ */
+ public function removeMatchingRulesWithPropertyNamePrefixKeepsOtherRules(
+ array $initialPropertyNames,
+ string $propertyNamePrefix,
+ array $expectedRemainingPropertyNames
+ ): void {
+ $propertyNamePrefixWithHyphen = $propertyNamePrefix . '-';
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+
+ $this->subject->removeMatchingRules($propertyNamePrefixWithHyphen);
+
+ $remainingRules = $this->subject->getRulesAssoc();
+ if ($expectedRemainingPropertyNames === []) {
+ self::assertSame([], $remainingRules);
+ }
+ foreach ($expectedRemainingPropertyNames as $expectedPropertyName) {
+ self::assertArrayHasKey($expectedPropertyName, $remainingRules);
+ }
+ }
+
+ /**
+ * @test
+ *
+ * @param list $propertyNamesToRemove
+ *
+ * @dataProvider providePropertyNames
+ */
+ public function removeAllRulesRemovesAllRules(array $propertyNamesToRemove): void
+ {
+ $this->setRulesFromPropertyNames($propertyNamesToRemove);
+
+ $this->subject->removeAllRules();
+
+ self::assertSame([], $this->subject->getRules());
+ }
+
+ /**
+ * @test
+ *
+ * @param list $propertyNamesToSet
+ *
+ * @dataProvider providePropertyNames
+ */
+ public function setRulesOnVirginSetsRulesWithoutPositionInOrder(array $propertyNamesToSet): void
+ {
+ $rulesToSet = self::createRulesFromPropertyNames($propertyNamesToSet);
+
+ $this->subject->setRules($rulesToSet);
+
+ self::assertSame($rulesToSet, $this->subject->getRules());
+ }
+
+ /**
+ * @return DataProvider, 1: list}>
+ */
+ public static function provideInitialPropertyNamesAndPropertyNamesToSet(): DataProvider
+ {
+ return DataProvider::cross(self::providePropertyNames(), self::providePropertyNames());
+ }
+
+ /**
+ * @test
+ *
+ * @param list $initialPropertyNames
+ * @param list $propertyNamesToSet
+ *
+ * @dataProvider provideInitialPropertyNamesAndPropertyNamesToSet
+ */
+ public function setRulesReplacesRules(array $initialPropertyNames, array $propertyNamesToSet): void
+ {
+ $rulesToSet = self::createRulesFromPropertyNames($propertyNamesToSet);
+ $this->setRulesFromPropertyNames($initialPropertyNames);
+
+ $this->subject->setRules($rulesToSet);
+
+ self::assertSame($rulesToSet, $this->subject->getRules());
+ }
+
+ /**
+ * @test
+ */
+ public function setRulesWithRuleWithoutPositionSetsValidLineNumber(): void
+ {
+ $ruleToSet = new Rule('color');
+
+ $this->subject->setRules([$ruleToSet]);
+
+ self::assertIsInt($ruleToSet->getLineNumber(), 'line number not set');
+ self::assertGreaterThanOrEqual(1, $ruleToSet->getLineNumber(), 'line number not valid');
+ }
+
+ /**
+ * @test
+ */
+ public function setRulesWithRuleWithoutPositionSetsValidColumnNumber(): void
+ {
+ $ruleToSet = new Rule('color');
+
+ $this->subject->setRules([$ruleToSet]);
+
+ self::assertIsInt($ruleToSet->getColumnNumber(), 'column number not set');
+ self::assertGreaterThanOrEqual(0, $ruleToSet->getColumnNumber(), 'column number not valid');
+ }
+
+ /**
+ * @test
+ */
+ public function setRulesWithRuleWithOnlyLineNumberSetsColumnNumber(): void
+ {
+ $ruleToSet = new Rule('color');
+ $ruleToSet->setPosition(42);
+
+ $this->subject->setRules([$ruleToSet]);
+
+ self::assertIsInt($ruleToSet->getColumnNumber(), 'column number not set');
+ self::assertGreaterThanOrEqual(0, $ruleToSet->getColumnNumber(), 'column number not valid');
+ }
+
+ /**
+ * @test
+ */
+ public function setRulesWithRuleWithOnlyLineNumberPreservesLineNumber(): void
+ {
+ $ruleToSet = new Rule('color');
+ $ruleToSet->setPosition(42);
+
+ $this->subject->setRules([$ruleToSet]);
+
+ self::assertSame(42, $ruleToSet->getLineNumber(), 'line number not preserved');
+ }
+
+ /**
+ * @test
+ */
+ public function setRulesWithRuleWithOnlyColumnNumberSetsLineNumber(): void
+ {
+ $ruleToSet = new Rule('color');
+ $ruleToSet->setPosition(null, 42);
+
+ $this->subject->setRules([$ruleToSet]);
+
+ self::assertIsInt($ruleToSet->getLineNumber(), 'line number not set');
+ self::assertGreaterThanOrEqual(1, $ruleToSet->getLineNumber(), 'line number not valid');
+ }
+
+ /**
+ * @test
+ */
+ public function setRulesWithRuleWithOnlyColumnNumberPreservesColumnNumber(): void
+ {
+ $ruleToSet = new Rule('color');
+ $ruleToSet->setPosition(null, 42);
+
+ $this->subject->setRules([$ruleToSet]);
+
+ self::assertSame(42, $ruleToSet->getColumnNumber(), 'column number not preserved');
+ }
+
+ /**
+ * @test
+ */
+ public function setRulesWithRuleWithCompletePositionPreservesPosition(): void
+ {
+ $ruleToSet = new Rule('color');
+ $ruleToSet->setPosition(42, 64);
+
+ $this->subject->setRules([$ruleToSet]);
+
+ self::assertSame(42, $ruleToSet->getLineNumber(), 'line number not preserved');
+ self::assertSame(64, $ruleToSet->getColumnNumber(), 'column number not preserved');
+ }
+
+ /**
+ * @test
+ *
+ * @param list $propertyNamesToSet
+ *
+ * @dataProvider providePropertyNames
+ */
+ public function getRulesReturnsRulesSet(array $propertyNamesToSet): void
+ {
+ $rulesToSet = self::createRulesFromPropertyNames($propertyNamesToSet);
+ $this->subject->setRules($rulesToSet);
+
+ $result = $this->subject->getRules();
+
+ self::assertSame($rulesToSet, $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getRulesOrdersByLineNumber(): void
+ {
+ $first = (new Rule('color'))->setPosition(1, 64);
+ $second = (new Rule('display'))->setPosition(19, 42);
+ $third = (new Rule('color'))->setPosition(55, 11);
+ $this->subject->setRules([$third, $second, $first]);
+
+ $result = $this->subject->getRules();
+
+ self::assertSame([$first, $second, $third], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getRulesOrdersRulesWithSameLineNumberByColumnNumber(): void
+ {
+ $first = (new Rule('color'))->setPosition(1, 11);
+ $second = (new Rule('display'))->setPosition(1, 42);
+ $third = (new Rule('color'))->setPosition(1, 64);
+ $this->subject->setRules([$third, $second, $first]);
+
+ $result = $this->subject->getRules();
+
+ self::assertSame([$first, $second, $third], $result);
+ }
+
+ /**
+ * @return array, 1: string, 2: list}>
+ */
+ public static function providePropertyNamesAndSearchPatternAndMatchingPropertyNames(): array
+ {
+ return [
+ 'single rule matched' => [
+ ['color'],
+ 'color',
+ ['color'],
+ ],
+ 'first rule matched' => [
+ ['color', 'display'],
+ 'color',
+ ['color'],
+ ],
+ 'last rule matched' => [
+ ['color', 'display'],
+ 'display',
+ ['display'],
+ ],
+ 'middle rule matched' => [
+ ['color', 'display', 'width'],
+ 'display',
+ ['display'],
+ ],
+ 'multiple rules for the same property matched' => [
+ ['color', 'color'],
+ 'color',
+ ['color'],
+ ],
+ 'multiple rules for the same property matched in haystack' => [
+ ['color', 'display', 'color', 'width'],
+ 'color',
+ ['color'],
+ ],
+ 'shorthand rule matched' => [
+ ['font'],
+ 'font-',
+ ['font'],
+ ],
+ 'longhand rule matched' => [
+ ['font-size'],
+ 'font-',
+ ['font-size'],
+ ],
+ 'shorthand and longhand rule matched' => [
+ ['font', 'font-size'],
+ 'font-',
+ ['font', 'font-size'],
+ ],
+ 'shorthand rule matched in haystack' => [
+ ['font', 'color'],
+ 'font-',
+ ['font'],
+ ],
+ 'longhand rule matched in haystack' => [
+ ['font-size', 'color'],
+ 'font-',
+ ['font-size'],
+ ],
+ 'rules whose property names begin with the same characters not matched with pattern match' => [
+ ['contain', 'container', 'container-type'],
+ 'contain-',
+ ['contain'],
+ ],
+ 'rules whose property names begin with the same characters not matched with exact match' => [
+ ['contain', 'container', 'container-type'],
+ 'contain',
+ ['contain'],
+ ],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @param list $propertyNamesToSet
+ * @param list $matchingPropertyNames
+ *
+ * @dataProvider providePropertyNamesAndSearchPatternAndMatchingPropertyNames
+ */
+ public function getRulesWithPatternReturnsAllMatchingRules(
+ array $propertyNamesToSet,
+ string $searchPattern,
+ array $matchingPropertyNames
+ ): void {
+ $rulesToSet = self::createRulesFromPropertyNames($propertyNamesToSet);
+ $matchingRules = \array_filter(
+ $rulesToSet,
+ static function (Rule $rule) use ($matchingPropertyNames): bool {
+ return \in_array($rule->getRule(), $matchingPropertyNames, true);
+ }
+ );
+ $this->subject->setRules($rulesToSet);
+
+ $result = $this->subject->getRules($searchPattern);
+
+ foreach ($matchingRules as $expectedMatchingRule) {
+ self::assertContains($expectedMatchingRule, $result);
+ }
+ }
+
+ /**
+ * @test
+ *
+ * @param list $propertyNamesToSet
+ * @param list $matchingPropertyNames
+ *
+ * @dataProvider providePropertyNamesAndSearchPatternAndMatchingPropertyNames
+ */
+ public function getRulesWithPatternFiltersNonMatchingRules(
+ array $propertyNamesToSet,
+ string $searchPattern,
+ array $matchingPropertyNames
+ ): void {
+ $this->setRulesFromPropertyNames($propertyNamesToSet);
+
+ $result = $this->subject->getRules($searchPattern);
+
+ foreach ($result as $resultRule) {
+ // 'expected' and 'actual' are transposed here due to necessity
+ self::assertContains($resultRule->getRule(), $matchingPropertyNames);
+ }
+ }
+
+ /**
+ * @return array, 1: string}>
+ */
+ public static function providePropertyNamesAndNonMatchingSearchPattern(): array
+ {
+ return [
+ 'no match in empty list' => [
+ [],
+ 'color',
+ ],
+ 'no match for different property' => [
+ ['color'],
+ 'display',
+ ],
+ 'no match for property not in list' => [
+ ['color', 'display'],
+ 'width',
+ ],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @param list $propertyNamesToSet
+ *
+ * @dataProvider providePropertyNamesAndNonMatchingSearchPattern
+ */
+ public function getRulesWithNonMatchingPatternReturnsEmptyArray(
+ array $propertyNamesToSet,
+ string $searchPattern
+ ): void {
+ $this->setRulesFromPropertyNames($propertyNamesToSet);
+
+ $result = $this->subject->getRules($searchPattern);
+
+ self::assertSame([], $result);
+ }
+
+ /**
+ * @test
+ */
+ public function getRulesWithPatternOrdersRulesByPosition(): void
+ {
+ $first = (new Rule('color'))->setPosition(1, 42);
+ $second = (new Rule('color'))->setPosition(1, 64);
+ $third = (new Rule('color'))->setPosition(55, 7);
+ $this->subject->setRules([$third, $second, $first]);
+
+ $result = $this->subject->getRules('color');
+
+ self::assertSame([$first, $second, $third], $result);
+ }
+
+ /**
+ * @return array}>
+ */
+ public static function provideDistinctPropertyNames(): array
+ {
+ return [
+ 'no properties' => [[]],
+ 'one property' => [['color']],
+ 'two properties' => [['color', 'display']],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @param list $propertyNamesToSet
+ *
+ * @dataProvider provideDistinctPropertyNames
+ */
+ public function getRulesAssocReturnsAllRulesWithDistinctPropertyNames(array $propertyNamesToSet): void
+ {
+ $rulesToSet = self::createRulesFromPropertyNames($propertyNamesToSet);
+ $this->subject->setRules($rulesToSet);
+
+ $result = $this->subject->getRulesAssoc();
+
+ self::assertSame($rulesToSet, \array_values($result));
+ }
+
+ /**
+ * @test
+ */
+ public function getRulesAssocReturnsLastRuleWithSamePropertyName(): void
+ {
+ $firstRule = new Rule('color');
+ $lastRule = new Rule('color');
+ $this->subject->setRules([$firstRule, $lastRule]);
+
+ $result = $this->subject->getRulesAssoc();
+
+ self::assertSame([$lastRule], \array_values($result));
+ }
+
+ /**
+ * @test
+ */
+ public function getRulesAssocOrdersRulesByPosition(): void
+ {
+ $first = (new Rule('color'))->setPosition(1, 42);
+ $second = (new Rule('display'))->setPosition(1, 64);
+ $third = (new Rule('width'))->setPosition(55, 7);
+ $this->subject->setRules([$third, $second, $first]);
+
+ $result = $this->subject->getRulesAssoc();
+
+ self::assertSame([$first, $second, $third], \array_values($result));
+ }
+
+ /**
+ * @test
+ */
+ public function getRulesAssocKeysRulesByPropertyName(): void
+ {
+ $this->subject->setRules([new Rule('color'), new Rule('display')]);
+
+ $result = $this->subject->getRulesAssoc();
+
+ foreach ($result as $key => $rule) {
+ self::assertSame($rule->getRule(), $key);
+ }
+ }
+
+ /**
+ * @param list $propertyNames
+ */
+ private function setRulesFromPropertyNames(array $propertyNames): void
+ {
+ $this->subject->setRules(self::createRulesFromPropertyNames($propertyNames));
+ }
+
+ /**
+ * @param list $propertyNames
+ *
+ * @return list
+ */
+ private static function createRulesFromPropertyNames(array $propertyNames): array
+ {
+ return \array_map(
+ function (string $propertyName): Rule {
+ return new Rule($propertyName);
+ },
+ $propertyNames
+ );
+ }
+}
diff --git a/tests/Unit/SettingsTest.php b/tests/Unit/SettingsTest.php
new file mode 100644
index 000000000..63f945214
--- /dev/null
+++ b/tests/Unit/SettingsTest.php
@@ -0,0 +1,155 @@
+subject = Settings::create();
+ }
+
+ /**
+ * @test
+ */
+ public function createReturnsInstance(): void
+ {
+ $settings = Settings::create();
+
+ self::assertInstanceOf(Settings::class, $settings);
+ }
+
+ /**
+ * @test
+ */
+ public function createReturnsANewInstanceForEachCall(): void
+ {
+ $settings1 = Settings::create();
+ $settings2 = Settings::create();
+
+ self::assertNotSame($settings1, $settings2);
+ }
+
+ /**
+ * @test
+ */
+ public function multibyteSupportByDefaultStateOfMbStringExtension(): void
+ {
+ self::assertSame(\extension_loaded('mbstring'), $this->subject->hasMultibyteSupport());
+ }
+
+ /**
+ * @test
+ */
+ public function withMultibyteSupportProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->withMultibyteSupport());
+ }
+
+ /**
+ * @return array
+ */
+ public static function booleanDataProvider(): array
+ {
+ return [
+ 'true' => [true],
+ 'false' => [false],
+ ];
+ }
+
+ /**
+ * @test
+ * @dataProvider booleanDataProvider
+ */
+ public function withMultibyteSupportSetsMultibyteSupport(bool $value): void
+ {
+ $this->subject->withMultibyteSupport($value);
+
+ self::assertSame($value, $this->subject->hasMultibyteSupport());
+ }
+
+ /**
+ * @test
+ */
+ public function defaultCharsetByDefaultIsUtf8(): void
+ {
+ self::assertSame('utf-8', $this->subject->getDefaultCharset());
+ }
+
+ /**
+ * @test
+ */
+ public function withDefaultCharsetProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->withDefaultCharset('UTF-8'));
+ }
+
+ /**
+ * @test
+ */
+ public function withDefaultCharsetSetsDefaultCharset(): void
+ {
+ $charset = 'ISO-8859-1';
+ $this->subject->withDefaultCharset($charset);
+
+ self::assertSame($charset, $this->subject->getDefaultCharset());
+ }
+
+ /**
+ * @test
+ */
+ public function lenientParsingByDefaultIsTrue(): void
+ {
+ self::assertTrue($this->subject->usesLenientParsing());
+ }
+
+ /**
+ * @test
+ */
+ public function withLenientParsingProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->withLenientParsing());
+ }
+
+ /**
+ * @test
+ * @dataProvider booleanDataProvider
+ */
+ public function withLenientParsingSetsLenientParsing(bool $value): void
+ {
+ $this->subject->withLenientParsing($value);
+
+ self::assertSame($value, $this->subject->usesLenientParsing());
+ }
+
+ /**
+ * @test
+ */
+ public function beStrictProvidesFluentInterface(): void
+ {
+ self::assertSame($this->subject, $this->subject->beStrict());
+ }
+
+ /**
+ * @test
+ */
+ public function beStrictSetsLenientParsingToFalse(): void
+ {
+ $this->subject->beStrict();
+
+ self::assertFalse($this->subject->usesLenientParsing());
+ }
+}
diff --git a/tests/Unit/Value/CSSStringTest.php b/tests/Unit/Value/CSSStringTest.php
new file mode 100644
index 000000000..e88f35543
--- /dev/null
+++ b/tests/Unit/Value/CSSStringTest.php
@@ -0,0 +1,84 @@
+getString());
+ }
+
+ /**
+ * @test
+ */
+ public function setStringSetsString(): void
+ {
+ $subject = new CSSString('');
+ $string = 'coffee';
+
+ $subject->setString($string);
+
+ self::assertSame($string, $subject->getString());
+ }
+
+ /**
+ * @test
+ */
+ public function getLineNoByDefaultReturnsZero(): void
+ {
+ $subject = new CSSString('');
+
+ self::assertSame(0, $subject->getLineNo());
+ }
+
+ /**
+ * @test
+ */
+ public function getLineNoReturnsLineNumberProvidedToConstructor(): void
+ {
+ $lineNumber = 42;
+
+ $subject = new CSSString('', $lineNumber);
+
+ self::assertSame($lineNumber, $subject->getLineNo());
+ }
+}
diff --git a/tests/Unit/Value/CalcRuleValueListTest.php b/tests/Unit/Value/CalcRuleValueListTest.php
new file mode 100644
index 000000000..5d73d9e93
--- /dev/null
+++ b/tests/Unit/Value/CalcRuleValueListTest.php
@@ -0,0 +1,60 @@
+getLineNo());
+ }
+
+ /**
+ * @test
+ */
+ public function getLineNoReturnsLineNumberProvidedToConstructor(): void
+ {
+ $lineNumber = 42;
+
+ $subject = new CalcRuleValueList($lineNumber);
+
+ self::assertSame($lineNumber, $subject->getLineNo());
+ }
+
+ /**
+ * @test
+ */
+ public function separatorAlwaysIsComma(): void
+ {
+ $subject = new CalcRuleValueList();
+
+ self::assertSame(',', $subject->getListSeparator());
+ }
+}
diff --git a/tests/Unit/Value/ColorTest.php b/tests/Unit/Value/ColorTest.php
new file mode 100644
index 000000000..aaa257553
--- /dev/null
+++ b/tests/Unit/Value/ColorTest.php
@@ -0,0 +1,474 @@
+
+ */
+ public static function provideValidColorAndExpectedRendering(): array
+ {
+ return [
+ '3-digit hex color' => [
+ '#070',
+ '#070',
+ ],
+ '6-digit hex color that can be represented as 3-digit' => [
+ '#007700',
+ '#070',
+ ],
+ '6-digit hex color that cannot be represented as 3-digit' => [
+ '#007600',
+ '#007600',
+ ],
+ '4-digit hex color (with alpha)' => [
+ '#0707',
+ 'rgba(0,119,0,.47)',
+ ],
+ '8-digit hex color (with alpha)' => [
+ '#0077007F',
+ 'rgba(0,119,0,.5)',
+ ],
+ 'legacy rgb that can be represented as 3-digit hex' => [
+ 'rgb(0, 119, 0)',
+ '#070',
+ ],
+ 'legacy rgb that cannot be represented as 3-digit hex' => [
+ 'rgb(0, 118, 0)',
+ '#007600',
+ ],
+ 'legacy rgb with percentage components' => [
+ 'rgb(0%, 60%, 0%)',
+ 'rgb(0%,60%,0%)',
+ ],
+ 'legacy rgba with fractional alpha' => [
+ 'rgba(0, 119, 0, 0.5)',
+ 'rgba(0,119,0,.5)',
+ ],
+ 'legacy rgba with percentage alpha' => [
+ 'rgba(0, 119, 0, 50%)',
+ 'rgba(0,119,0,50%)',
+ ],
+ 'legacy rgba with percentage components and fractional alpha' => [
+ 'rgba(0%, 60%, 0%, 0.5)',
+ 'rgba(0%,60%,0%,.5)',
+ ],
+ 'legacy rgba with percentage components and percentage alpha' => [
+ 'rgba(0%, 60%, 0%, 50%)',
+ 'rgba(0%,60%,0%,50%)',
+ ],
+ 'legacy rgb as rgba' => [
+ 'rgba(0, 119, 0)',
+ '#070',
+ ],
+ 'legacy rgba as rgb' => [
+ 'rgb(0, 119, 0, 0.5)',
+ 'rgba(0,119,0,.5)',
+ ],
+ 'modern rgb' => [
+ 'rgb(0 119 0)',
+ '#070',
+ ],
+ 'modern rgb with percentage R' => [
+ 'rgb(0% 119 0)',
+ 'rgb(0% 119 0)',
+ ],
+ 'modern rgb with percentage G' => [
+ 'rgb(0 60% 0)',
+ 'rgb(0 60% 0)',
+ ],
+ 'modern rgb with percentage B' => [
+ 'rgb(0 119 0%)',
+ 'rgb(0 119 0%)',
+ ],
+ 'modern rgb with percentage R&G' => [
+ 'rgb(0% 60% 0)',
+ 'rgb(0% 60% 0)',
+ ],
+ 'modern rgb with percentage R&B' => [
+ 'rgb(0% 119 0%)',
+ 'rgb(0% 119 0%)',
+ ],
+ 'modern rgb with percentage G&B' => [
+ 'rgb(0 60% 0%)',
+ 'rgb(0 60% 0%)',
+ ],
+ 'modern rgb with percentage components' => [
+ 'rgb(0% 60% 0%)',
+ 'rgb(0%,60%,0%)',
+ ],
+ 'modern rgb with none as red' => [
+ 'rgb(none 119 0)',
+ 'rgb(none 119 0)',
+ ],
+ 'modern rgb with none as green' => [
+ 'rgb(0 none 0)',
+ 'rgb(0 none 0)',
+ ],
+ 'modern rgb with none as blue' => [
+ 'rgb(0 119 none)',
+ 'rgb(0 119 none)',
+ ],
+ 'modern rgba with fractional alpha' => [
+ 'rgb(0 119 0 / 0.5)',
+ 'rgba(0,119,0,.5)',
+ ],
+ 'modern rgba with percentage alpha' => [
+ 'rgb(0 119 0 / 50%)',
+ 'rgba(0,119,0,50%)',
+ ],
+ 'modern rgba with percentage R' => [
+ 'rgb(0% 119 0 / 0.5)',
+ 'rgba(0% 119 0/.5)',
+ ],
+ 'modern rgba with percentage G' => [
+ 'rgb(0 60% 0 / 0.5)',
+ 'rgba(0 60% 0/.5)',
+ ],
+ 'modern rgba with percentage B' => [
+ 'rgb(0 119 0% / 0.5)',
+ 'rgba(0 119 0%/.5)',
+ ],
+ 'modern rgba with percentage RGB' => [
+ 'rgb(0% 60% 0% / 0.5)',
+ 'rgba(0%,60%,0%,.5)',
+ ],
+ 'modern rgba with percentage components' => [
+ 'rgb(0% 60% 0% / 50%)',
+ 'rgba(0%,60%,0%,50%)',
+ ],
+ 'modern rgba with none as alpha' => [
+ 'rgb(0 119 0 / none)',
+ 'rgba(0 119 0/none)',
+ ],
+ 'legacy rgb with var for R' => [
+ 'rgb(var(--r), 119, 0)',
+ 'rgb(var(--r),119,0)',
+ ],
+ 'legacy rgb with var for G' => [
+ 'rgb(0, var(--g), 0)',
+ 'rgb(0,var(--g),0)',
+ ],
+ 'legacy rgb with var for B' => [
+ 'rgb(0, 119, var(--b))',
+ 'rgb(0,119,var(--b))',
+ ],
+ 'legacy rgb with var for RG' => [
+ 'rgb(var(--rg), 0)',
+ 'rgb(var(--rg),0)',
+ ],
+ 'legacy rgb with var for GB' => [
+ 'rgb(0, var(--gb))',
+ 'rgb(0,var(--gb))',
+ ],
+ 'legacy rgba with var for R' => [
+ 'rgba(var(--r), 119, 0, 0.5)',
+ 'rgba(var(--r),119,0,.5)',
+ ],
+ 'legacy rgba with var for G' => [
+ 'rgba(0, var(--g), 0, 0.5)',
+ 'rgba(0,var(--g),0,.5)',
+ ],
+ 'legacy rgba with var for B' => [
+ 'rgb(0, 119, var(--b), 0.5)',
+ 'rgb(0,119,var(--b),.5)',
+ ],
+ 'legacy rgba with var for A' => [
+ 'rgba(0, 119, 0, var(--a))',
+ 'rgba(0,119,0,var(--a))',
+ ],
+ 'legacy rgba with var for RG' => [
+ 'rgba(var(--rg), 0, 0.5)',
+ 'rgba(var(--rg),0,.5)',
+ ],
+ 'legacy rgba with var for GB' => [
+ 'rgba(0, var(--gb), 0.5)',
+ 'rgba(0,var(--gb),.5)',
+ ],
+ 'legacy rgba with var for BA' => [
+ 'rgba(0, 119, var(--ba))',
+ 'rgba(0,119,var(--ba))',
+ ],
+ 'legacy rgba with var for RGB' => [
+ 'rgba(var(--rgb), 0.5)',
+ 'rgba(var(--rgb),.5)',
+ ],
+ 'legacy rgba with var for GBA' => [
+ 'rgba(0, var(--gba))',
+ 'rgba(0,var(--gba))',
+ ],
+ 'modern rgb with var for R' => [
+ 'rgb(var(--r) 119 0)',
+ 'rgb(var(--r),119,0)',
+ ],
+ 'modern rgb with var for G' => [
+ 'rgb(0 var(--g) 0)',
+ 'rgb(0,var(--g),0)',
+ ],
+ 'modern rgb with var for B' => [
+ 'rgb(0 119 var(--b))',
+ 'rgb(0,119,var(--b))',
+ ],
+ 'modern rgb with var for RG' => [
+ 'rgb(var(--rg) 0)',
+ 'rgb(var(--rg),0)',
+ ],
+ 'modern rgb with var for GB' => [
+ 'rgb(0 var(--gb))',
+ 'rgb(0,var(--gb))',
+ ],
+ 'modern rgba with var for R' => [
+ 'rgba(var(--r) 119 0 / 0.5)',
+ 'rgba(var(--r),119,0,.5)',
+ ],
+ 'modern rgba with var for G' => [
+ 'rgba(0 var(--g) 0 / 0.5)',
+ 'rgba(0,var(--g),0,.5)',
+ ],
+ 'modern rgba with var for B' => [
+ 'rgba(0 119 var(--b) / 0.5)',
+ 'rgba(0,119,var(--b),.5)',
+ ],
+ 'modern rgba with var for A' => [
+ 'rgba(0 119 0 / var(--a))',
+ 'rgba(0,119,0,var(--a))',
+ ],
+ 'modern rgba with var for RG' => [
+ 'rgba(var(--rg) 0 / 0.5)',
+ 'rgba(var(--rg),0,.5)',
+ ],
+ 'modern rgba with var for GB' => [
+ 'rgba(0 var(--gb) / 0.5)',
+ 'rgba(0,var(--gb),.5)',
+ ],
+ 'modern rgba with var for BA' => [
+ 'rgba(0 119 var(--ba))',
+ 'rgba(0,119,var(--ba))',
+ ],
+ 'modern rgba with var for RGB' => [
+ 'rgba(var(--rgb) / 0.5)',
+ 'rgba(var(--rgb),.5)',
+ ],
+ 'modern rgba with var for GBA' => [
+ 'rgba(0 var(--gba))',
+ 'rgba(0,var(--gba))',
+ ],
+ 'rgba with var for RGBA' => [
+ 'rgba(var(--rgba))',
+ 'rgba(var(--rgba))',
+ ],
+ 'legacy hsl' => [
+ 'hsl(120, 100%, 25%)',
+ 'hsl(120,100%,25%)',
+ ],
+ 'legacy hsl with deg' => [
+ 'hsl(120deg, 100%, 25%)',
+ 'hsl(120deg,100%,25%)',
+ ],
+ 'legacy hsl with grad' => [
+ 'hsl(133grad, 100%, 25%)',
+ 'hsl(133grad,100%,25%)',
+ ],
+ 'legacy hsl with rad' => [
+ 'hsl(2.094rad, 100%, 25%)',
+ 'hsl(2.094rad,100%,25%)',
+ ],
+ 'legacy hsl with turn' => [
+ 'hsl(0.333turn, 100%, 25%)',
+ 'hsl(.333turn,100%,25%)',
+ ],
+ 'legacy hsla with fractional alpha' => [
+ 'hsla(120, 100%, 25%, 0.5)',
+ 'hsla(120,100%,25%,.5)',
+ ],
+ 'legacy hsla with percentage alpha' => [
+ 'hsla(120, 100%, 25%, 50%)',
+ 'hsla(120,100%,25%,50%)',
+ ],
+ 'legacy hsl as hsla' => [
+ 'hsla(120, 100%, 25%)',
+ 'hsl(120,100%,25%)',
+ ],
+ 'legacy hsla as hsl' => [
+ 'hsl(120, 100%, 25%, 0.5)',
+ 'hsla(120,100%,25%,.5)',
+ ],
+ 'modern hsl' => [
+ 'hsl(120 100% 25%)',
+ 'hsl(120,100%,25%)',
+ ],
+ 'modern hsl with none as hue' => [
+ 'hsl(none 100% 25%)',
+ 'hsl(none 100% 25%)',
+ ],
+ 'modern hsl with none as saturation' => [
+ 'hsl(120 none 25%)',
+ 'hsl(120 none 25%)',
+ ],
+ 'modern hsl with none as lightness' => [
+ 'hsl(120 100% none)',
+ 'hsl(120 100% none)',
+ ],
+ 'modern hsla' => [
+ 'hsl(120 100% 25% / 0.5)',
+ 'hsla(120,100%,25%,.5)',
+ ],
+ 'modern hsla with none as alpha' => [
+ 'hsl(120 100% 25% / none)',
+ 'hsla(120 100% 25%/none)',
+ ],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideValidColorAndExpectedRendering
+ */
+ public function parsesAndRendersValidColor(string $color, string $expectedRendering): void
+ {
+ $subject = Color::parse(new ParserState($color, Settings::create()));
+
+ $renderedResult = $subject->render(OutputFormat::create());
+
+ self::assertSame($expectedRendering, $renderedResult);
+ }
+
+ /**
+ * Browsers reject all these, thus so should the parser.
+ *
+ * @return array
+ */
+ public static function provideInvalidColor(): array
+ {
+ return [
+ 'hex color with 0 digits' => [
+ '#',
+ ],
+ 'hex color with 1 digit' => [
+ '#f',
+ ],
+ 'hex color with 2 digits' => [
+ '#f0',
+ ],
+ 'hex color with 5 digits' => [
+ '#ff000',
+ ],
+ 'hex color with 7 digits' => [
+ '#ff00000',
+ ],
+ 'hex color with 9 digits' => [
+ '#ff0000000',
+ ],
+ 'rgb color with 0 arguments' => [
+ 'rgb()',
+ ],
+ 'rgb color with 1 argument' => [
+ 'rgb(255)',
+ ],
+ 'legacy rgb color with 2 arguments' => [
+ 'rgb(255, 0)',
+ ],
+ 'legacy rgb color with 5 arguments' => [
+ 'rgb(255, 0, 0, 0.5, 0)',
+ ],
+ /*
+ 'legacy rgb color with invalid unit' => [
+ 'rgb(255, 0px, 0)',
+ ],
+ //*/
+ 'legacy rgb color with none as red' => [
+ 'rgb(none, 0, 0)',
+ ],
+ 'legacy rgb color with none as green' => [
+ 'rgb(255, none, 0)',
+ ],
+ 'legacy rgb color with none as blue' => [
+ 'rgb(255, 0, none)',
+ ],
+ 'legacy rgba color with none as alpha' => [
+ 'rgba(255, 0, 0, none)',
+ ],
+ 'modern rgb color without slash separator for alpha' => [
+ 'rgb(255 0 0 0.5)',
+ ],
+ 'rgb color with mixed separators, comma first' => [
+ 'rgb(255, 0 0)',
+ ],
+ 'rgb color with mixed separators, space first' => [
+ 'rgb(255 0, 0)',
+ ],
+ 'hsl color with 0 arguments' => [
+ 'hsl()',
+ ],
+ 'hsl color with 1 argument' => [
+ 'hsl(0)',
+ ],
+ 'legacy hsl color with 2 arguments' => [
+ 'hsl(0, 100%)',
+ ],
+ 'legacy hsl color with 5 arguments' => [
+ 'hsl(0, 100%, 50%, 0.5, 0)',
+ ],
+ 'legacy hsl color with none as hue' => [
+ 'hsl(none, 100%, 50%)',
+ ],
+ 'legacy hsl color with none as saturation' => [
+ 'hsl(0, none, 50%)',
+ ],
+ 'legacy hsl color with none as lightness' => [
+ 'hsl(0, 100%, none)',
+ ],
+ 'legacy hsla color with none as alpha' => [
+ 'hsl(0, 100%, 50%, none)',
+ ],
+ /*
+ 'legacy hsl color without % for S/L units' => [
+ 'hsl(0, 1, 0.5)'
+ ],
+ 'legacy hsl color with invalid unit for H' => [
+ 'hsl(0px, 100%, 50%)'
+ ],
+ //*/
+ 'modern hsl color without slash separator for alpha' => [
+ 'rgb(0 100% 50% 0.5)',
+ ],
+ 'hsl color with mixed separators, comma first' => [
+ 'hsl(0, 100% 50%)',
+ ],
+ 'hsl color with mixed separators, space first' => [
+ 'hsl(0 100%, 50%)',
+ ],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideInvalidColor
+ */
+ public function throwsExceptionWithInvalidColor(string $color): void
+ {
+ $this->expectException(SourceException::class);
+
+ Color::parse(new ParserState($color, Settings::create()));
+ }
+}
diff --git a/tests/Unit/Value/Fixtures/ConcreteValue.php b/tests/Unit/Value/Fixtures/ConcreteValue.php
new file mode 100644
index 000000000..b6e924805
--- /dev/null
+++ b/tests/Unit/Value/Fixtures/ConcreteValue.php
@@ -0,0 +1,19 @@
+
+ */
+ public static function provideUnit(): array
+ {
+ $units = [
+ 'px',
+ 'pt',
+ 'pc',
+ 'cm',
+ 'mm',
+ 'mozmm',
+ 'in',
+ 'vh',
+ 'dvh',
+ 'svh',
+ 'lvh',
+ 'vw',
+ 'vmin',
+ 'vmax',
+ 'rem',
+ '%',
+ 'em',
+ 'ex',
+ 'ch',
+ 'fr',
+ 'deg',
+ 'grad',
+ 'rad',
+ 's',
+ 'ms',
+ 'turn',
+ 'Hz',
+ 'kHz',
+ ];
+
+ return \array_combine(
+ $units,
+ \array_map(
+ static function (string $unit): array {
+ return [$unit];
+ },
+ $units
+ )
+ );
+ }
+
+ /**
+ * @test
+ *
+ * @param non-empty-string $unit
+ *
+ * @dataProvider provideUnit
+ */
+ public function parsesUnit(string $unit): void
+ {
+ $parsedSize = Size::parse(new ParserState('1' . $unit, Settings::create()));
+
+ self::assertSame($unit, $parsedSize->getUnit());
+ }
+}
diff --git a/tests/Unit/Value/URLTest.php b/tests/Unit/Value/URLTest.php
new file mode 100644
index 000000000..42d96e29e
--- /dev/null
+++ b/tests/Unit/Value/URLTest.php
@@ -0,0 +1,84 @@
+getURL());
+ }
+
+ /**
+ * @test
+ */
+ public function setUrlReplacesUrl(): void
+ {
+ $subject = new URL(new CSSString('http://example.com'));
+
+ $newUrl = new CSSString('http://example.org');
+ $subject->setURL($newUrl);
+
+ self::assertSame($newUrl, $subject->getURL());
+ }
+
+ /**
+ * @test
+ */
+ public function getLineNoByDefaultReturnsZero(): void
+ {
+ $subject = new URL(new CSSString('http://example.com'));
+
+ self::assertSame(0, $subject->getLineNo());
+ }
+
+ /**
+ * @test
+ */
+ public function getLineNoReturnsLineNumberProvidedToConstructor(): void
+ {
+ $lineNumber = 17;
+
+ $subject = new URL(new CSSString('http://example.com'), $lineNumber);
+
+ self::assertSame($lineNumber, $subject->getLineNo());
+ }
+}
diff --git a/tests/Unit/Value/ValueTest.php b/tests/Unit/Value/ValueTest.php
new file mode 100644
index 000000000..9a664a771
--- /dev/null
+++ b/tests/Unit/Value/ValueTest.php
@@ -0,0 +1,146 @@
+
+ */
+ private const DEFAULT_DELIMITERS = [',', ' ', '/'];
+
+ /**
+ * @test
+ */
+ public function implementsCSSElement(): void
+ {
+ $subject = new ConcreteValue();
+
+ self::assertInstanceOf(CSSElement::class, $subject);
+ }
+
+ /**
+ * @return array
+ */
+ public static function provideArithmeticOperator(): array
+ {
+ return [
+ '+' => ['+'],
+ '-' => ['-'],
+ '*' => ['*'],
+ '/' => ['/'],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideArithmeticOperator
+ */
+ public function parsesArithmeticInFunctions(string $operator): void
+ {
+ $subject = Value::parseValue(
+ new ParserState('max(300px, 50vh ' . $operator . ' 10px);', Settings::create()),
+ self::DEFAULT_DELIMITERS
+ );
+
+ self::assertInstanceOf(CSSFunction::class, $subject);
+ self::assertSame('max(300px,50vh ' . $operator . ' 10px)', $subject->render(OutputFormat::createCompact()));
+ }
+
+ /**
+ * @return array
+ * The first datum is a template for the parser (using `sprintf` insertion marker `%s` for some expression).
+ * The second is for the expected result, which may have whitespace and trailing semicolon removed.
+ */
+ public static function provideCssFunctionTemplates(): array
+ {
+ return [
+ 'calc' => [
+ 'to be parsed' => 'calc(%s);',
+ 'expected' => 'calc(%s)',
+ ],
+ 'max' => [
+ 'to be parsed' => 'max(300px, %s);',
+ 'expected' => 'max(300px,%s)',
+ ],
+ 'clamp' => [
+ 'to be parsed' => 'clamp(2.19rem, %s, 2.5rem);',
+ 'expected' => 'clamp(2.19rem,%s,2.5rem)',
+ ],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideCssFunctionTemplates
+ */
+ public function parsesArithmeticWithMultipleOperatorsInFunctions(
+ string $parserTemplate,
+ string $expectedResultTemplate
+ ): void {
+ static $expression = '300px + 10% + 10vw';
+
+ $subject = Value::parseValue(
+ new ParserState(\sprintf($parserTemplate, $expression), Settings::create()),
+ self::DEFAULT_DELIMITERS
+ );
+
+ self::assertInstanceOf(CSSFunction::class, $subject);
+ self::assertSame(
+ \sprintf($expectedResultTemplate, $expression),
+ $subject->render(OutputFormat::createCompact())
+ );
+ }
+
+ /**
+ * @return array
+ */
+ public static function provideMalformedLengthOperands(): array
+ {
+ return [
+ 'LHS missing number' => ['vh', '10px'],
+ 'RHS missing number' => ['50vh', 'px'],
+ 'LHS missing unit' => ['50', '10px'],
+ 'RHS missing unit' => ['50vh', '10'],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideMalformedLengthOperands
+ */
+ public function parsesArithmeticWithMalformedOperandsInFunctions(string $leftOperand, string $rightOperand): void
+ {
+ $subject = Value::parseValue(
+ new ParserState('max(300px, ' . $leftOperand . ' + ' . $rightOperand . ');', Settings::create()),
+ self::DEFAULT_DELIMITERS
+ );
+
+ self::assertInstanceOf(CSSFunction::class, $subject);
+ self::assertSame(
+ 'max(300px,' . $leftOperand . ' + ' . $rightOperand . ')',
+ $subject->render(OutputFormat::createCompact())
+ );
+ }
+}
diff --git a/tests/UnitDeprecated/Position/PositionTest.php b/tests/UnitDeprecated/Position/PositionTest.php
new file mode 100644
index 000000000..2e06cc6d8
--- /dev/null
+++ b/tests/UnitDeprecated/Position/PositionTest.php
@@ -0,0 +1,135 @@
+subject = new ConcretePosition();
+ }
+
+ /**
+ * @return array}>
+ */
+ public function provideLineNumber(): array
+ {
+ return [
+ 'line 1' => [1],
+ 'line 42' => [42],
+ ];
+ }
+
+ /**
+ * @return array}>
+ */
+ public function provideColumnNumber(): array
+ {
+ return [
+ 'column 0' => [0],
+ 'column 14' => [14],
+ 'column 39' => [39],
+ ];
+ }
+
+ /**
+ * @test
+ */
+ public function getLineNoInitiallyReturnsZero(): void
+ {
+ self::assertSame(0, $this->subject->getLineNo());
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideLineNumber
+ */
+ public function getLineNoReturnsLineNumberSet(int $lineNumber): void
+ {
+ $this->subject->setPosition($lineNumber);
+
+ self::assertSame($lineNumber, $this->subject->getLineNo());
+ }
+
+ /**
+ * @test
+ */
+ public function getLineNoReturnsZeroAfterLineNumberCleared(): void
+ {
+ $this->subject->setPosition(99);
+
+ $this->subject->setPosition(null);
+
+ self::assertSame(0, $this->subject->getLineNo());
+ }
+
+ /**
+ * @test
+ */
+ public function getColNoInitiallyReturnsZero(): void
+ {
+ self::assertSame(0, $this->subject->getColNo());
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider provideColumnNumber
+ */
+ public function getColNoReturnsColumnNumberSet(int $columnNumber): void
+ {
+ $this->subject->setPosition(1, $columnNumber);
+
+ self::assertSame($columnNumber, $this->subject->getColNo());
+ }
+
+ /**
+ * @test
+ */
+ public function getColNoReturnsZeroAfterColumnNumberCleared(): void
+ {
+ $this->subject->setPosition(1, 99);
+
+ $this->subject->setPosition(2);
+
+ self::assertSame(0, $this->subject->getColNo());
+ }
+
+ /**
+ * @test
+ */
+ public function setPositionWithZeroClearsLineNumber(): void
+ {
+ $this->subject->setPosition(99);
+
+ $this->subject->setPosition(0);
+
+ self::assertNull($this->subject->getLineNumber());
+ }
+
+ /**
+ * @test
+ */
+ public function getLineNoAfterSetPositionWithZeroReturnsZero(): void
+ {
+ $this->subject->setPosition(99);
+
+ $this->subject->setPosition(0);
+
+ self::assertSame(0, $this->subject->getLineNo());
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
deleted file mode 100644
index 41b1f5ea1..000000000
--- a/tests/bootstrap.php
+++ /dev/null
@@ -1,9 +0,0 @@
-