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 -[![Build Status](https://github.com/sabberworm/PHP-CSS-Parser/workflows/CI/badge.svg?branch=master)](https://github.com/sabberworm/PHP-CSS-Parser/actions/) +[![Build Status](https://github.com/MyIntervals/PHP-CSS-Parser/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/MyIntervals/PHP-CSS-Parser/actions/) +[![Coverage Status](https://coveralls.io/repos/github/MyIntervals/PHP-CSS-Parser/badge.svg?branch=main)](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 @@ -