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 new file mode 100644 index 000000000..82a4f0b95 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +/.editorconfig export-ignore +/.gitattributes export-ignore +/.github/ export-ignore +/.gitignore 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 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 new file mode 100644 index 000000000..14624a482 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,146 @@ +# https://help.github.com/en/categories/automating-your-workflow-with-github-actions + +on: + push: + branches: + - main + pull_request: + schedule: + - cron: '3 3 * * 1' + +name: CI + +jobs: + 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 new file mode 100644 index 000000000..8bdbea99c --- /dev/null +++ b/.gitignore @@ -0,0 +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 new file mode 100644 index 000000000..6af30ed59 --- /dev/null +++ b/.phive/phars.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index d2aeb0239..000000000 --- a/.travis.yml +++ /dev/null @@ -1,12 +0,0 @@ -language: php -php: - - "5.4" - - "5.3" - - "5.5" - - "5.6" - - "7.0" - - "nightly" - - hhvm -script: phpunit . -sudo: false - diff --git a/CHANGELOG.md b/CHANGELOG.md index ab489fdbc..7f69624e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,191 +1,503 @@ -# Revision History +# Changelog + +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 + +### 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 -## 7.0 +- Add support for PHP 8.4 (#643, #657) -### 7.0.0 (2015-08-24) +### Changed -* Compatibility with PHP 7. Well timed, eh? -* *No deprecations* +- Mark parsing-internal classes and methods as `@internal` (#674) +- Block installations on unsupported higher PHP versions (#691) -#### Backwards-incompatible changes +### Deprecated -* The `Sabberworm\CSS\Value\String` class has been renamed to `Sabberworm\CSS\Value\CSSString`. +- 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) -### 7.0.1 (2015-12-25) +### Fixed -* No more suppressed `E_NOTICE` +- 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) + +* 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 +* Performance improvements parsing large files, again thanks to @FMCorz * *No backwards-incompatible changes* * *No deprecations* -### 7.0.2 (2016-02-11) +## 8.1.0 (2016-07-19) -* 150 time performance boost thanks to @[ossinkine](https://github.com/ossinkine) +* 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.3 (2016-04-27) +## 8.0.0 (2016-06-30) + +* Store source CSS line numbers in tokens and parsing exceptions. +* *No deprecations* + +### Backwards-incompatible changes + +* Unrecoverable parser errors throw an exception of type + `Sabberworm\CSS\Parsing\SourceException` instead of `\Exception`. + +## 7.0.3 (2016-04-27) * Fixed parsing empty CSS when multibyte is off * *No backwards-incompatible changes* * *No deprecations* -## 6.0 +## 7.0.2 (2016-02-11) -### 6.0.0 (2014-07-03) +* 150 time performance boost thanks + to @[ossinkine](https://github.com/ossinkine) +* *No backwards-incompatible changes* +* *No deprecations* -* Format output using Sabberworm\CSS\OutputFormat +## 7.0.1 (2015-12-25) + +* No more suppressed `E_NOTICE` * *No backwards-incompatible changes* +* *No deprecations* + +## 7.0.0 (2015-08-24) + +* 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/LICENSE b/LICENSE new file mode 100644 index 000000000..686a4e311 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2011 Raphael Schweikert, https://www.sabberworm.com/ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index d65840bfc..9ecdc3e7f 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,16 @@ -PHP CSS Parser --------------- +# PHP CSS Parser -[![build status](https://api.travis-ci.org/sabberworm/PHP-CSS-Parser.svg)](https://travis-ci.org/sabberworm/PHP-CSS-Parser) [![HHVM Status](http://hhvm.h4cc.de/badge/sabberworm/php-css-parser.svg)](http://hhvm.h4cc.de/package/sabberworm/php-css-parser) +[![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: - -* `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. +`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. -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 are two types of `ValueList`s: +There is another abstract subclass of `Value`, `ValueList`: A `ValueList` represents a lists of `Value`s, separated by some separation character (mostly `,`, whitespace, or `/`). -* `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). +There are two types of `ValueList`s: + +* `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,31 +616,226 @@ 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). +* [Lullabot](https://github.com/Lullabot) for a patch that allows to know the line number for each parsed token. * [ju1ius](https://github.com/ju1ius) for the specificity parsing code and the ability to expand/compact shorthand properties. -* [GaryJones](https://github.com/GaryJones) for lots of input and [http://css-specificity.info/](http://css-specificity.info/). * [ossinkine](https://github.com/ossinkine) for a 150 time performance boost. -* [Lullabot](https://github.com/Lullabot) for a patch that allows to know the line number for each parsed token. +* [GaryJones](https://github.com/GaryJones) for lots of input and [https://css-specificity.info/](https://css-specificity.info/). * [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, have `phpunit` installed and run `phpunit .`. - -## License - -PHP-CSS-Parser is freely distributable under the terms of an MIT-style license. - -Copyright (c) 2011 Raphael Schweikert, http://sabberworm.com/ - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +### Legacy Support -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +The latest pre-PSR-0 version of this project can be checked with the `0.9.0` tag. diff --git a/bin/quickdump.php b/bin/quickdump.php new file mode 100755 index 000000000..c759d028a --- /dev/null +++ b/bin/quickdump.php @@ -0,0 +1,25 @@ +#!/usr/bin/env php +parse(); +echo "\n" . '#### Input' . "\n\n```css\n"; +print $sSource; + +echo "\n```\n\n" . '#### Structure (`var_dump()`)' . "\n\n```php\n"; +\var_dump($oDoc); + +echo "\n```\n\n" . '#### Output (`render()`)' . "\n\n```css\n"; +print $oDoc->render(); + +echo "\n```\n"; diff --git a/composer.json b/composer.json index bd3830ced..e4ea9c43d 100644 --- a/composer.json +++ b/composer.json @@ -1,17 +1,126 @@ { "name": "sabberworm/php-css-parser", - "type": "library", "description": "Parser for CSS Files written in PHP", - "keywords": ["parser", "css", "stylesheet"], - "homepage": "http://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.3.2" + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", + "ext-iconv": "*" + }, + "require-dev": { + "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-0": { "Sabberworm\\CSS": "lib/" } + "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:dynamic" + ], + "ci:composer:normalize": "\"./.phive/composer-normalize\" --dry-run", + "ci:dynamic": [ + "@ci:tests" + ], + "ci:php:fixer": "\"./.phive/php-cs-fixer\" --config=config/php-cs-fixer.php fix --dry-run -v --show-progress=dots --diff bin src tests config", + "ci:php:lint": "parallel-lint src tests config bin", + "ci:php:rector": "rector --no-progress-bar --dry-run --config=config/rector.php", + "ci:php:stan": "phpstan --no-progress --configuration=config/phpstan.neon", + "ci:static": [ + "@ci:composer:normalize", + "@ci:php:fixer", + "@ci:php:lint", + "@ci:php:rector", + "@ci:php:stan" + ], + "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:composer:normalize", + "@fix:php:rector", + "@fix:php:fixer" + ], + "fix:php:fixer": "\"./.phive/php-cs-fixer\" --config=config/php-cs-fixer.php fix bin src tests", + "fix:php:rector": "rector --config=config/rector.php", + "phpstan:baseline": "phpstan --configuration=config/phpstan.neon --generate-baseline=config/phpstan-baseline.neon --allow-empty-baseline" + }, + "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 a1ff8f8d1..000000000 --- a/lib/Sabberworm/CSS/CSSList/AtRuleBlockList.php +++ /dev/null @@ -1,48 +0,0 @@ -sType = $sType; - $this->sArgs = $sArgs; - } - - public function atRuleName() { - return $this->sType; - } - - public function atRuleArgs() { - return $this->sArgs; - } - - public function __toString() { - return $this->render(new \Sabberworm\CSS\OutputFormat()); - } - - 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; - } - - public function isRootList() { - return false; - } - -} \ No newline at end of file diff --git a/lib/Sabberworm/CSS/CSSList/CSSBlockList.php b/lib/Sabberworm/CSS/CSSList/CSSBlockList.php deleted file mode 100644 index 17c68142e..000000000 --- a/lib/Sabberworm/CSS/CSSList/CSSBlockList.php +++ /dev/null @@ -1,82 +0,0 @@ -aContents as $mContent) { - if ($mContent instanceof DeclarationBlock) { - $aResult[] = $mContent; - } else if ($mContent instanceof CSSBlockList) { - $mContent->allDeclarationBlocks($aResult); - } - } - } - - protected function allRuleSets(&$aResult) { - foreach ($this->aContents as $mContent) { - if ($mContent instanceof RuleSet) { - $aResult[] = $mContent; - } else if ($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); - } - } else if ($oElement instanceof RuleSet) { - foreach ($oElement->getRules($sSearchString) as $oRule) { - $this->allValues($oRule, $aResult, $sSearchString, $bSearchInFunctionArguments); - } - } else if ($oElement instanceof Rule) { - $this->allValues($oElement->getValue(), $aResult, $sSearchString, $bSearchInFunctionArguments); - } else if ($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 = array(); - $this->allDeclarationBlocks($aDeclarationBlocks); - foreach ($aDeclarationBlocks as $oBlock) { - foreach ($oBlock->getSelectors() as $oSelector) { - if ($sSpecificitySearch === null) { - $aResult[] = $oSelector; - } else { - $sComparison = "\$bRes = {$oSelector->getSpecificity()} $sSpecificitySearch;"; - eval($sComparison); - if ($bRes) { - $aResult[] = $oSelector; - } - } - } - } - } - -} diff --git a/lib/Sabberworm/CSS/CSSList/CSSList.php b/lib/Sabberworm/CSS/CSSList/CSSList.php deleted file mode 100644 index 853450aa4..000000000 --- a/lib/Sabberworm/CSS/CSSList/CSSList.php +++ /dev/null @@ -1,121 +0,0 @@ -aContents = array(); - $this->iLineNo = $iLineNo; - } - - /** - * @return int - */ - public function getLineNo() { - return $this->iLineNo; - } - - public function append($oItem) { - $this->aContents[] = $oItem; - } - - /** - * 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) - */ - public function remove($oItemToRemove) { - $iKey = array_search($oItemToRemove, $this->aContents, true); - if ($iKey !== false) { - unset($this->aContents[$iKey]); - return true; - } - return false; - } - - /** - * 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)) { - $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()); - } - - 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. - */ - public abstract function isRootList(); - - public function getContents() { - return $this->aContents; - } -} diff --git a/lib/Sabberworm/CSS/CSSList/Document.php b/lib/Sabberworm/CSS/CSSList/Document.php deleted file mode 100644 index bd4a23ee3..000000000 --- a/lib/Sabberworm/CSS/CSSList/Document.php +++ /dev/null @@ -1,105 +0,0 @@ -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 = array(); - $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; - } else if (is_string($mElement)) { - $sSearchString = $mElement; - $mElement = $this; - } - $aResult = array(); - $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) { - if (is_numeric($sSpecificitySearch) || is_numeric($sSpecificitySearch[0])) { - $sSpecificitySearch = "== $sSpecificitySearch"; - } - $aResult = array(); - $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 - 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; - } - -} \ No newline at end of file diff --git a/lib/Sabberworm/CSS/CSSList/KeyFrame.php b/lib/Sabberworm/CSS/CSSList/KeyFrame.php deleted file mode 100644 index 0334b1b3f..000000000 --- a/lib/Sabberworm/CSS/CSSList/KeyFrame.php +++ /dev/null @@ -1,56 +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()); - } - - 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; - } - - public function atRuleName() { - return $this->vendorKeyFrame; - } - - public function atRuleArgs() { - return $this->animationName; - } -} diff --git a/lib/Sabberworm/CSS/OutputFormat.php b/lib/Sabberworm/CSS/OutputFormat.php deleted file mode 100644 index 1b1798402..000000000 --- a/lib/Sabberworm/CSS/OutputFormat.php +++ /dev/null @@ -1,289 +0,0 @@ -set('Space*Rules', "\n");`) - */ - public $sSpaceAfterRuleName = ' '; - - public $sSpaceBeforeRules = ''; - public $sSpaceAfterRules = ''; - public $sSpaceBetweenRules = ''; - - public $sSpaceBeforeBlocks = ''; - public $sSpaceAfterBlocks = ''; - public $sSpaceBetweenBlocks = "\n"; - - // 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 = ' '; - - /** - * 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 = array('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 = array('a', 's', 'm', 'b', 'f', 'o', 'c', 'i'); - if(is_string($aNames) && strpos($aNames, '*') !== false) { - $aNames = array(str_replace('*', 'Before', $aNames), str_replace('*', 'Between', $aNames), str_replace('*', 'After', $aNames)); - } else if(!is_array($aNames)) { - $aNames = array($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]); - } else if(strpos($sMethodName, 'get') === 0) { - return $this->get(substr($sMethodName, 3)); - } else if(method_exists('\\Sabberworm\\CSS\\OutputFormatter', $sMethodName)) { - return call_user_func_array(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; - } - - public static function create() { - return new OutputFormat(); - } - - public static function createCompact() { - return self::create()->set('Space*Rules', "")->set('Space*Blocks', "")->setSpaceAfterRuleName('')->setSpaceBeforeOpeningBrace('')->setSpaceAfterSelectorSeparator(''); - } - - public static function createPretty() { - return self::create()->set('Space*Rules', "\n")->set('Space*Blocks', "\n")->setSpaceBetweenBlocks("\n\n")->set('SpaceAfterListArgumentSeparator', array('default' => '', ',' => ' ')); - } -} - -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()); - } -} \ No newline at end of file diff --git a/lib/Sabberworm/CSS/Parser.php b/lib/Sabberworm/CSS/Parser.php deleted file mode 100644 index ea68fee8e..000000000 --- a/lib/Sabberworm/CSS/Parser.php +++ /dev/null @@ -1,690 +0,0 @@ -sText = $sText; - $this->iCurrentPosition = 0; - $this->iLineNo = $iLineNo; - if ($oParserSettings === null) { - $oParserSettings = Settings::create(); - } - $this->oParserSettings = $oParserSettings; - $this->blockRules = explode('/', AtRule::BLOCK_RULES); - - foreach (explode('/', Size::ABSOLUTE_SIZE_UNITS.'/'.Size::RELATIVE_SIZE_UNITS.'/'.Size::NON_SIZE_UNITS) as $val) { - $iSize = strlen($val); - if(!isset($this->aSizeUnits[$iSize])) { - $this->aSizeUnits[$iSize] = array(); - } - $this->aSizeUnits[$iSize][strtolower($val)] = $val; - } - ksort($this->aSizeUnits, SORT_NUMERIC); - } - - public function setCharset($sCharset) { - $this->sCharset = $sCharset; - $this->aText = $this->strsplit($this->sText); - $this->iLength = count($this->aText); - } - - public function getCharset() { - return $this->sCharset; - } - - public function parse() { - $this->setCharset($this->oParserSettings->sDefaultCharset); - $oResult = new Document($this->iLineNo); - $this->parseDocument($oResult); - return $oResult; - } - - private function parseDocument(Document $oDocument) { - $this->consumeWhiteSpace(); - $this->parseList($oDocument, true); - } - - private function parseList(CSSList $oList, $bIsRoot = false) { - while (!$this->isEnd()) { - $oListItem = null; - if($this->oParserSettings->bLenientParsing) { - try { - $oListItem = $this->parseListItem($oList, $bIsRoot); - } catch (UnexpectedTokenException $e) { - $oListItem = false; - } - } else { - $oListItem = $this->parseListItem($oList, $bIsRoot); - } - if($oListItem === null) { - // List parsing finished - return; - } - if($oListItem) { - $oList->append($oListItem); - } - $this->consumeWhiteSpace(); - } - if (!$bIsRoot) { - throw new SourceException("Unexpected end of document", $this->iLineNo); - } - } - - private function parseListItem(CSSList $oList, $bIsRoot = false) { - if ($this->comes('@')) { - $oAtRule = $this->parseAtRule(); - if($oAtRule instanceof Charset) { - if(!$bIsRoot) { - throw new UnexpectedTokenException('@charset may only occur in root document', '', 'custom', $this->iLineNo); - } - if(count($oList->getContents()) > 0) { - throw new UnexpectedTokenException('@charset must be the first parseable token in a document', '', 'custom', $this->iLineNo); - } - $this->setCharset($oAtRule->getCharset()->getString()); - } - return $oAtRule; - } else if ($this->comes('}')) { - $this->consume('}'); - if ($bIsRoot) { - throw new SourceException("Unopened {", $this->iLineNo); - } else { - return null; - } - } else { - return $this->parseSelector(); - } - } - - private function parseAtRule() { - $this->consume('@'); - $sIdentifier = $this->parseIdentifier(); - $iIdentifierLineNum = $this->iLineNo; - $this->consumeWhiteSpace(); - if ($sIdentifier === 'import') { - $oLocation = $this->parseURLValue(); - $this->consumeWhiteSpace(); - $sMediaQuery = null; - if (!$this->comes(';')) { - $sMediaQuery = $this->consumeUntil(';'); - } - $this->consume(';'); - return new Import($oLocation, $sMediaQuery, $iIdentifierLineNum); - } else if ($sIdentifier === 'charset') { - $sCharset = $this->parseStringValue(); - $this->consumeWhiteSpace(); - $this->consume(';'); - return new Charset($sCharset, $iIdentifierLineNum); - } else if ($this->identifierIs($sIdentifier, 'keyframes')) { - $oResult = new KeyFrame($iIdentifierLineNum); - $oResult->setVendorKeyFrame($sIdentifier); - $oResult->setAnimationName(trim($this->consumeUntil('{', false, true))); - $this->consumeWhiteSpace(); - $this->parseList($oResult); - return $oResult; - } else if ($sIdentifier === 'namespace') { - $sPrefix = null; - $mUrl = $this->parsePrimitiveValue(); - if (!$this->comes(';')) { - $sPrefix = $mUrl; - $mUrl = $this->parsePrimitiveValue(); - } - $this->consume(';'); - 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($this->consumeUntil('{', false, true)); - $this->consumeWhiteSpace(); - $bUseRuleSet = true; - foreach($this->blockRules as $sBlockRuleName) { - if($this->identifierIs($sIdentifier, $sBlockRuleName)) { - $bUseRuleSet = false; - break; - } - } - if($bUseRuleSet) { - $oAtRule = new AtRuleSet($sIdentifier, $sArgs, $iIdentifierLineNum); - $this->parseRuleSet($oAtRule); - } else { - $oAtRule = new AtRuleBlockList($sIdentifier, $sArgs, $iIdentifierLineNum); - $this->parseList($oAtRule); - } - return $oAtRule; - } - } - - private function parseIdentifier($bAllowFunctions = true, $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) { - $sResult .= $sCharacter; - } - if ($bIgnoreCase) { - $sResult = $this->strtolower($sResult); - } - if ($bAllowFunctions && $this->comes('(')) { - $this->consume('('); - $aArguments = $this->parseValue(array('=', ' ', ',')); - $sResult = new CSSFunction($sResult, $aArguments, ',', $this->iLineNo); - $this->consume(')'); - } - return $sResult; - } - - private function parseStringValue() { - $sBegin = $this->peek(); - $sQuote = null; - if ($sBegin === "'") { - $sQuote = "'"; - } else if ($sBegin === '"') { - $sQuote = '"'; - } - if ($sQuote !== null) { - $this->consume($sQuote); - } - $sResult = ""; - $sContent = null; - if ($sQuote === null) { - //Unquoted strings end in whitespace or with braces, brackets, parentheses - while (!preg_match('/[\\s{}()<>\\[\\]]/isu', $this->peek())) { - $sResult .= $this->parseCharacter(false); - } - } else { - while (!$this->comes($sQuote)) { - $sContent = $this->parseCharacter(false); - if ($sContent === null) { - throw new SourceException("Non-well-formed quoted string {$this->peek(3)}", $this->iLineNo); - } - $sResult .= $sContent; - } - $this->consume($sQuote); - } - return new CSSString($sResult, $this->iLineNo); - } - - private function parseCharacter($bIsForIdentifier) { - 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); - } - $sUnicode = $this->consumeExpression('/^[0-9a-fA-F]{1,6}/u'); - 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; - } - - private function parseSelector() { - $oResult = new DeclarationBlock($this->iLineNo); - $oResult->setSelector($this->consumeUntil('{', false, true)); - $this->consumeWhiteSpace(); - $this->parseRuleSet($oResult); - return $oResult; - } - - private function parseRuleSet($oRuleSet) { - while ($this->comes(';')) { - $this->consume(';'); - $this->consumeWhiteSpace(); - } - while (!$this->comes('}')) { - $oRule = null; - if($this->oParserSettings->bLenientParsing) { - try { - $oRule = $this->parseRule(); - } catch (UnexpectedTokenException $e) { - try { - $sConsume = $this->consumeUntil(array("\n", ";", '}'), true); - // We need to “unfind” the matches to the end of the ruleSet as this will be matched later - if($this->streql(substr($sConsume, -1), '}')) { - --$this->iCurrentPosition; - } else { - $this->consumeWhiteSpace(); - while ($this->comes(';')) { - $this->consume(';'); - } - } - } catch (UnexpectedTokenException $e) { - // We’ve reached the end of the document. Just close the RuleSet. - return; - } - } - } else { - $oRule = $this->parseRule(); - } - if($oRule) { - $oRuleSet->addRule($oRule); - } - $this->consumeWhiteSpace(); - } - $this->consume('}'); - } - - private function parseRule() { - $oRule = new Rule($this->parseIdentifier(), $this->iLineNo); - $this->consumeWhiteSpace(); - $this->consume(':'); - $oValue = $this->parseValue(self::listDelimiterForRule($oRule->getRule())); - $oRule->setValue($oValue); - if ($this->comes('!')) { - $this->consume('!'); - $this->consumeWhiteSpace(); - $this->consume('important'); - $oRule->setIsImportant(true); - } - while ($this->comes(';')) { - $this->consume(';'); - $this->consumeWhiteSpace(); - } - return $oRule; - } - - private function parseValue($aListDelimiters) { - $aStack = array(); - $this->consumeWhiteSpace(); - //Build a list of delimiters and parsed values - while (!($this->comes('}') || $this->comes(';') || $this->comes('!') || $this->comes(')'))) { - if (count($aStack) > 0) { - $bFoundDelimiter = false; - foreach ($aListDelimiters as $sDelimiter) { - if ($this->comes($sDelimiter)) { - array_push($aStack, $this->consume($sDelimiter)); - $this->consumeWhiteSpace(); - $bFoundDelimiter = true; - break; - } - } - if (!$bFoundDelimiter) { - //Whitespace was the list delimiter - array_push($aStack, ' '); - } - } - array_push($aStack, $this->parsePrimitiveValue()); - $this->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, $this->iLineNo); - for ($i = $iStartPosition - 1; $i - $iStartPosition + 1 < $iLength * 2; $i+=2) { - $oList->addListComponent($aStack[$i]); - } - array_splice($aStack, $iStartPosition - 1, $iLength * 2 - 1, array($oList)); - } - } - return $aStack[0]; - } - - private static function listDelimiterForRule($sRule) { - if (preg_match('/^font($|-)/', $sRule)) { - return array(',', '/', ' '); - } - return array(',', ' ', '/'); - } - - private function parsePrimitiveValue() { - $oValue = null; - $this->consumeWhiteSpace(); - if (is_numeric($this->peek()) || ($this->comes('-.') && is_numeric($this->peek(1, 2))) || (($this->comes('-') || $this->comes('.')) && is_numeric($this->peek(1, 1)))) { - $oValue = $this->parseNumericValue(); - } else if ($this->comes('#') || $this->comes('rgb', true) || $this->comes('hsl', true)) { - $oValue = $this->parseColorValue(); - } else if ($this->comes('url', true)) { - $oValue = $this->parseURLValue(); - } else if ($this->comes("'") || $this->comes('"')) { - $oValue = $this->parseStringValue(); - } else { - $oValue = $this->parseIdentifier(true, false); - } - $this->consumeWhiteSpace(); - return $oValue; - } - - private function parseNumericValue($bForColor = false) { - $sSize = ''; - if ($this->comes('-')) { - $sSize .= $this->consume('-'); - } - while (is_numeric($this->peek()) || $this->comes('.')) { - if ($this->comes('.')) { - $sSize .= $this->consume('.'); - } else { - $sSize .= $this->consume(1); - } - } - - $sUnit = null; - foreach ($this->aSizeUnits as $iLength => &$aValues) { - $sKey = strtolower($this->peek($iLength)); - if(array_key_exists($sKey, $aValues)) { - if (($sUnit = $aValues[$sKey]) !== null) { - $this->consume($iLength); - break; - } - } - } - return new Size(floatval($sSize), $sUnit, $bForColor, $this->iLineNo); - } - - private function parseColorValue() { - $aColor = array(); - if ($this->comes('#')) { - $this->consume('#'); - $sValue = $this->parseIdentifier(false); - if ($this->strlen($sValue) === 3) { - $sValue = $sValue[0] . $sValue[0] . $sValue[1] . $sValue[1] . $sValue[2] . $sValue[2]; - } - $aColor = array('r' => new Size(intval($sValue[0] . $sValue[1], 16), null, true, $this->iLineNo), 'g' => new Size(intval($sValue[2] . $sValue[3], 16), null, true, $this->iLineNo), 'b' => new Size(intval($sValue[4] . $sValue[5], 16), null, true, $this->iLineNo)); - } else { - $sColorMode = $this->parseIdentifier(false); - $this->consumeWhiteSpace(); - $this->consume('('); - $iLength = $this->strlen($sColorMode); - for ($i = 0; $i < $iLength; ++$i) { - $this->consumeWhiteSpace(); - $aColor[$sColorMode[$i]] = $this->parseNumericValue(true); - $this->consumeWhiteSpace(); - if ($i < ($iLength - 1)) { - $this->consume(','); - } - } - $this->consume(')'); - } - return new Color($aColor, $this->iLineNo); - } - - private function parseURLValue() { - $bUseUrl = $this->comes('url', true); - if ($bUseUrl) { - $this->consume('url'); - $this->consumeWhiteSpace(); - $this->consume('('); - } - $this->consumeWhiteSpace(); - $oResult = new URL($this->parseStringValue(), $this->iLineNo); - if ($bUseUrl) { - $this->consumeWhiteSpace(); - $this->consume(')'); - } - return $oResult; - } - - /** - * 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 function identifierIs($sIdentifier, $sMatch) { - return (strcasecmp($sIdentifier, $sMatch) === 0) - ?: preg_match("/^(-\\w+-)?$sMatch$/i", $sIdentifier) === 1; - } - - private function comes($sString, $bCaseInsensitive = false) { - $sPeek = $this->peek(strlen($sString)); - return ($sPeek == '') - ? false - : $this->streql($sPeek, $sString, $bCaseInsensitive); - } - - private function peek($iLength = 1, $iOffset = 0) { - $iOffset += $this->iCurrentPosition; - if ($iOffset >= $this->iLength) { - return ''; - } - return $this->substr($iOffset, $iLength); - } - - private 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 UnexpectedTokenException($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; - } - } - - private function consumeExpression($mExpression) { - $aMatches = null; - if (preg_match($mExpression, $this->inputLeft(), $aMatches, PREG_OFFSET_CAPTURE) === 1) { - return $this->consume($aMatches[0][0]); - } - throw new UnexpectedTokenException($mExpression, $this->peek(5), 'expression', $this->iLineNo); - } - - private function consumeWhiteSpace() { - do { - while (preg_match('/\\s/isSu', $this->peek()) === 1) { - $this->consume(1); - } - if($this->oParserSettings->bLenientParsing) { - try { - $bHasComment = $this->consumeComment(); - } catch(UnexpectedTokenException $e) { - // When we can’t find the end of a comment, we assume the document is finished. - $this->iCurrentPosition = $this->iLength; - return; - } - } else { - $bHasComment = $this->consumeComment(); - } - } while($bHasComment); - } - - private function consumeComment() { - if ($this->comes('/*')) { - $this->consume(1); - while ($this->consume(1) !== '') { - if ($this->comes('*/')) { - $this->consume(2); - return true; - } - } - } - return false; - } - - private function isEnd() { - return $this->iCurrentPosition >= $this->iLength; - } - - private function consumeUntil($aEnd, $bIncludeEnd = false, $consumeEnd = false) { - $aEnd = is_array($aEnd) ? $aEnd : array($aEnd); - $out = ''; - $start = $this->iCurrentPosition; - - while (($char = $this->consume(1)) !== '') { - $this->consumeComment(); - if (in_array($char, $aEnd)) { - if ($bIncludeEnd) { - $out .= $char; - } elseif (!$consumeEnd) { - $this->iCurrentPosition -= $this->strlen($char); - } - return $out; - } - $out .= $char; - } - - $this->iCurrentPosition = $start; - throw new UnexpectedTokenException('One of ("'.implode('","', $aEnd).'")', $this->peek(5), 'search', $this->iLineNo); - } - - private function inputLeft() { - return $this->substr($this->iCurrentPosition, -1); - } - - 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 strlen($sString) { - if ($this->oParserSettings->bMultibyteSupport) { - return mb_strlen($sString, $this->sCharset); - } else { - return strlen($sString); - } - } - - private function streql($sString1, $sString2, $bCaseInsensitive = true) { - if($bCaseInsensitive) { - return $this->strtolower($sString1) === $this->strtolower($sString2); - } else { - return $sString1 === $sString2; - } - } - - 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 = array(); - for ($i = 0; $i < $iLength; ++$i) { - $aResult[] = mb_substr($sString, $i, 1, $this->sCharset); - } - return $aResult; - } - } else { - if($sString === '') { - return array(); - } 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/OutputException.php b/lib/Sabberworm/CSS/Parsing/OutputException.php deleted file mode 100644 index 1c8117704..000000000 --- a/lib/Sabberworm/CSS/Parsing/OutputException.php +++ /dev/null @@ -1,12 +0,0 @@ -iLineNo = $iLineNo; - if (!empty($iLineNo)) { - $sMessage .= " [line no: $iLineNo]"; - } - parent::__construct($sMessage); - } - - public function getLineNo() { - return $this->iLineNo; - } -} \ No newline at end of file diff --git a/lib/Sabberworm/CSS/Parsing/UnexpectedTokenException.php b/lib/Sabberworm/CSS/Parsing/UnexpectedTokenException.php deleted file mode 100644 index 0ef881846..000000000 --- a/lib/Sabberworm/CSS/Parsing/UnexpectedTokenException.php +++ /dev/null @@ -1,31 +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}”."; - } else if($this->sMatchType === 'count') { - $sMessage = "Next token was expected to have {$sExpected} chars. Context: “{$sFound}”."; - } else if($this->sMatchType === 'identifier') { - $sMessage = "Identifier expected. Got “{$sFound}”"; - } else if($this->sMatchType === 'custom') { - $sMessage = trim("$sExpected $sFound"); - } - - parent::__construct($sMessage, $iLineNo); - } -} \ No newline at end of file diff --git a/lib/Sabberworm/CSS/Property/AtRule.php b/lib/Sabberworm/CSS/Property/AtRule.php deleted file mode 100644 index e9009cc36..000000000 --- a/lib/Sabberworm/CSS/Property/AtRule.php +++ /dev/null @@ -1,14 +0,0 @@ -mUrl = $mUrl; - $this->sPrefix = $sPrefix; - $this->iLineNo = $iLineNo; - } - - /** - * @return int - */ - public function getLineNo() { - return $this->iLineNo; - } - - public function __toString() { - return $this->render(new \Sabberworm\CSS\OutputFormat()); - } - - 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; - } - - public function atRuleName() { - return 'namespace'; - } - - public function atRuleArgs() { - $aResult = array($this->mUrl); - if($this->sPrefix) { - array_unshift($aResult, $this->sPrefix); - } - return $aResult; - } -} \ No newline at end of file diff --git a/lib/Sabberworm/CSS/Property/Charset.php b/lib/Sabberworm/CSS/Property/Charset.php deleted file mode 100644 index 2d631515b..000000000 --- a/lib/Sabberworm/CSS/Property/Charset.php +++ /dev/null @@ -1,52 +0,0 @@ -sCharset = $sCharset; - $this->iLineNo = $iLineNo; - } - - /** - * @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()); - } - - public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat) { - return "@charset {$this->sCharset->render($oOutputFormat)};"; - } - - public function atRuleName() { - return 'charset'; - } - - public function atRuleArgs() { - return $this->sCharset; - } -} \ No newline at end of file diff --git a/lib/Sabberworm/CSS/Property/Import.php b/lib/Sabberworm/CSS/Property/Import.php deleted file mode 100644 index 2be6f144c..000000000 --- a/lib/Sabberworm/CSS/Property/Import.php +++ /dev/null @@ -1,55 +0,0 @@ -oLocation = $oLocation; - $this->sMediaQuery = $sMediaQuery; - $this->iLineNo = $iLineNo; - } - - /** - * @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()); - } - - public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat) { - return "@import ".$this->oLocation->render($oOutputFormat).($this->sMediaQuery === null ? '' : ' '.$this->sMediaQuery).';'; - } - - public function atRuleName() { - return 'import'; - } - - public function atRuleArgs() { - $aResult = array($this->oLocation); - if($this->sMediaQuery) { - array_push($aResult, $this->sMediaQuery); - } - return $aResult; - } -} \ No newline at end of file diff --git a/lib/Sabberworm/CSS/Property/Selector.php b/lib/Sabberworm/CSS/Property/Selector.php deleted file mode 100644 index d84171f5e..000000000 --- a/lib/Sabberworm/CSS/Property/Selector.php +++ /dev/null @@ -1,74 +0,0 @@ -\~]+)[\w]+ # elements - | - \:{1,2}( # pseudo-elements - after|before|first-letter|first-line|selection - )) - /ix'; - - private $sSelector; - private $iSpecificity; - - public function __construct($sSelector, $bCalculateSpecificity = false) { - $this->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 3ac06652e..000000000 --- a/lib/Sabberworm/CSS/Renderable.php +++ /dev/null @@ -1,9 +0,0 @@ -sRule = $sRule; - $this->mValue = null; - $this->bIsImportant = false; - $this->iLineNo = $iLineNo; - } - - /** - * @return int - */ - public function getLineNo() { - return $this->iLineNo; - } - - 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 array(array($this->mValue)); - } - if ($this->mValue->getListSeparator() === ',') { - return array($this->mValue->getListComponents()); - } - $aResult = array(); - foreach ($this->mValue->getListComponents() as $mValue) { - if (!$mValue instanceof RuleValueList || $mValue->getListSeparator() !== ',') { - $aResult[] = array($mValue); - continue; - } - if ($this->mValue->getListSeparator() === ' ' || count($aResult) === 0) { - $aResult[] = array(); - } - 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 = array($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 setIsImportant($bIsImportant) { - $this->bIsImportant = $bIsImportant; - } - - public function getIsImportant() { - return $this->bIsImportant; - } - - public function __toString() { - return $this->render(new \Sabberworm\CSS\OutputFormat()); - } - - 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 ($this->bIsImportant) { - $sResult .= ' !important'; - } - $sResult .= ';'; - return $sResult; - } - -} diff --git a/lib/Sabberworm/CSS/RuleSet/AtRuleSet.php b/lib/Sabberworm/CSS/RuleSet/AtRuleSet.php deleted file mode 100644 index a1042a95a..000000000 --- a/lib/Sabberworm/CSS/RuleSet/AtRuleSet.php +++ /dev/null @@ -1,44 +0,0 @@ -sType = $sType; - $this->sArgs = $sArgs; - } - - public function atRuleName() { - return $this->sType; - } - - public function atRuleArgs() { - return $this->sArgs; - } - - public function __toString() { - return $this->render(new \Sabberworm\CSS\OutputFormat()); - } - - 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; - } - -} \ No newline at end of file diff --git a/lib/Sabberworm/CSS/RuleSet/DeclarationBlock.php b/lib/Sabberworm/CSS/RuleSet/DeclarationBlock.php deleted file mode 100644 index e18f5d829..000000000 --- a/lib/Sabberworm/CSS/RuleSet/DeclarationBlock.php +++ /dev/null @@ -1,608 +0,0 @@ -aSelectors = array(); - } - - public function setSelectors($mSelector) { - if (is_array($mSelector)) { - $this->aSelectors = $mSelector; - } else { - $this->aSelectors = explode(',', $mSelector); - } - foreach ($this->aSelectors as $iKey => $mSelector) { - if (!($mSelector instanceof Selector)) { - $this->aSelectors[$iKey] = new Selector($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) { - $this->setSelectors($mSelector); - } - - 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 = array( - 'border', 'border-left', 'border-right', 'border-top', 'border-bottom' - ); - $aBorderSizes = array( - 'thin', 'medium', 'thick' - ); - $aRules = $this->getRulesAssoc(); - foreach ($aBorderRules as $sBorderRule) { - if (!isset($aRules[$sBorderRule])) - continue; - $oRule = $aRules[$sBorderRule]; - $mRuleValue = $oRule->getValue(); - $aValues = array(); - 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"; - } else if ($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, $this->iLineNo); - $oNewRule->setIsImportant($oRule->getIsImportant()); - $oNewRule->addValue(array($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 = array( - '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 = array(); - 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 (array('top', 'right', 'bottom', 'left') as $sPosition) { - $oNewRule = new Rule(sprintf($sExpanded, $sPosition), $this->iLineNo); - $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 = array( - 'font-style' => 'normal', - 'font-variant' => 'normal', - 'font-weight' => 'normal', - 'font-size' => 'normal', - 'line-height' => 'normal' - ); - $mRuleValue = $oRule->getValue(); - $aValues = array(); - 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, array('normal', 'inherit'))) { - foreach (array('font-style', 'font-weight', 'font-variant') as $sProperty) { - if (!isset($aFontProperties[$sProperty])) { - $aFontProperties[$sProperty] = $mValue; - } - } - } else if (in_array($mValue, array('italic', 'oblique'))) { - $aFontProperties['font-style'] = $mValue; - } else if ($mValue == 'small-caps') { - $aFontProperties['font-variant'] = $mValue; - } else if ( - in_array($mValue, array('bold', 'bolder', 'lighter')) - || ($mValue instanceof Size - && in_array($mValue->getSize(), range(100, 900, 100))) - ) { - $aFontProperties['font-weight'] = $mValue; - } else if ($mValue instanceof RuleValueList && $mValue->getListSeparator() == '/') { - list($oSize, $oHeight) = $mValue->getListComponents(); - $aFontProperties['font-size'] = $oSize; - $aFontProperties['line-height'] = $oHeight; - } else if ($mValue instanceof Size && $mValue->getUnit() !== null) { - $aFontProperties['font-size'] = $mValue; - } else { - $aFontProperties['font-family'] = $mValue; - } - } - foreach ($aFontProperties as $sProperty => $mValue) { - $oNewRule = new Rule($sProperty, $this->iLineNo); - $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 = array( - 'background-color' => array('transparent'), 'background-image' => array('none'), - 'background-repeat' => array('repeat'), 'background-attachment' => array('scroll'), - 'background-position' => array(new Size(0, '%', null, false, $this->iLineNo), new Size(0, '%', null, false, $this->iLineNo)) - ); - $mRuleValue = $oRule->getValue(); - $aValues = array(); - 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, $this->iLineNo); - $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; - } else if ($mValue instanceof Color) { - $aBgProperties['background-color'] = $mValue; - } else if (in_array($mValue, array('scroll', 'fixed'))) { - $aBgProperties['background-attachment'] = $mValue; - } else if (in_array($mValue, array('repeat', 'no-repeat', 'repeat-x', 'repeat-y'))) { - $aBgProperties['background-repeat'] = $mValue; - } else if (in_array($mValue, array('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, $this->iLineNo); - $oNewRule->setIsImportant($oRule->getIsImportant()); - $oNewRule->addValue($mValue); - $this->addRule($oNewRule); - } - $this->removeRule('background'); - } - - public function expandListStyleShorthand() { - $aListProperties = array( - 'list-style-type' => 'disc', - 'list-style-position' => 'outside', - 'list-style-image' => 'none' - ); - $aListStyleTypes = array( - '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 = array( - 'inside', 'outside' - ); - $aRules = $this->getRulesAssoc(); - if (!isset($aRules['list-style'])) - return; - $oRule = $aRules['list-style']; - $mRuleValue = $oRule->getValue(); - $aValues = array(); - 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, $this->iLineNo); - $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; - } else if (in_array($mValue, $aListStyleTypes)) { - $aListProperties['list-style-types'] = $mValue; - } else if (in_array($mValue, $aListStylePositions)) { - $aListProperties['list-style-position'] = $mValue; - } - } - foreach ($aListProperties as $sProperty => $mValue) { - $oNewRule = new Rule($sProperty, $this->iLineNo); - $oNewRule->setIsImportant($oRule->getIsImportant()); - $oNewRule->addValue($mValue); - $this->addRule($oNewRule); - } - $this->removeRule('list-style'); - } - - public function createShorthandProperties(array $aProperties, $sShorthand) { - $aRules = $this->getRulesAssoc(); - $aNewValues = array(); - foreach ($aProperties as $sProperty) { - if (!isset($aRules[$sProperty])) - continue; - $oRule = $aRules[$sProperty]; - if (!$oRule->getIsImportant()) { - $mRuleValue = $oRule->getValue(); - $aValues = array(); - 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, $this->iLineNo); - foreach ($aNewValues as $mValue) { - $oNewRule->addValue($mValue); - } - $this->addRule($oNewRule); - } - } - - public function createBackgroundShorthand() { - $aProperties = array( - 'background-color', 'background-image', 'background-repeat', - 'background-position', 'background-attachment' - ); - $this->createShorthandProperties($aProperties, 'background'); - } - - public function createListStyleShorthand() { - $aProperties = array( - '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 = array( - '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 = array('top', 'right', 'bottom', 'left'); - $aExpansions = array( - '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 = array(); - 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 = array(); - foreach ($aPositions as $sPosition) { - $oRule = $aRules[sprintf($sExpanded, $sPosition)]; - $mRuleValue = $oRule->getValue(); - $aRuleValues = array(); - if (!$mRuleValue instanceof RuleValueList) { - $aRuleValues[] = $mRuleValue; - } else { - $aRuleValues = $mRuleValue->getListComponents(); - } - $aValues[$sPosition] = $aRuleValues; - } - $oNewRule = new Rule($sProperty, $this->iLineNo); - 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 = array( - '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; - } - $oNewRule = new Rule('font', $this->iLineNo); - foreach (array('font-style', 'font-variant', 'font-weight') as $sProperty) { - if (isset($aRules[$sProperty])) { - $oRule = $aRules[$sProperty]; - $mRuleValue = $oRule->getValue(); - $aValues = array(); - 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 = array(); - 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 = array(); - 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 = array(); - 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()); - } - - 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->implode($oOutputFormat->spaceBeforeSelectorSeparator() . ',' . $oOutputFormat->spaceAfterSelectorSeparator(), $this->aSelectors) . $oOutputFormat->spaceBeforeOpeningBrace() . '{'; - $sResult .= parent::render($oOutputFormat); - $sResult .= '}'; - return $sResult; - } - -} diff --git a/lib/Sabberworm/CSS/RuleSet/RuleSet.php b/lib/Sabberworm/CSS/RuleSet/RuleSet.php deleted file mode 100644 index ffaec778a..000000000 --- a/lib/Sabberworm/CSS/RuleSet/RuleSet.php +++ /dev/null @@ -1,128 +0,0 @@ -aRules = array(); - $this->iLineNo = $iLineNo; - } - - /** - * @return int - */ - public function getLineNo() { - return $this->iLineNo; - } - - public function addRule(Rule $oRule) { - $sRule = $oRule->getRule(); - if(!isset($this->aRules[$sRule])) { - $this->aRules[$sRule] = array(); - } - $this->aRules[$sRule][] = $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(). - */ - public function getRules($mRule = null) { - if ($mRule instanceof Rule) { - $mRule = $mRule->getRule(); - } - $aResult = array(); - 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); - } - } - return $aResult; - } - - /** - * 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. - */ - public function getRulesAssoc($mRule = null) { - $aResult = array(); - 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()); - } - - 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); - } - -} diff --git a/lib/Sabberworm/CSS/Settings.php b/lib/Sabberworm/CSS/Settings.php deleted file mode 100644 index cb89a8636..000000000 --- a/lib/Sabberworm/CSS/Settings.php +++ /dev/null @@ -1,54 +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); - } -} \ No newline at end of file diff --git a/lib/Sabberworm/CSS/Value/CSSFunction.php b/lib/Sabberworm/CSS/Value/CSSFunction.php deleted file mode 100644 index 3633abc75..000000000 --- a/lib/Sabberworm/CSS/Value/CSSFunction.php +++ /dev/null @@ -1,40 +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()); - } - - public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat) { - $aArguments = parent::render($oOutputFormat); - return "{$this->sName}({$aArguments})"; - } - -} \ No newline at end of file diff --git a/lib/Sabberworm/CSS/Value/CSSString.php b/lib/Sabberworm/CSS/Value/CSSString.php deleted file mode 100644 index b07000818..000000000 --- a/lib/Sabberworm/CSS/Value/CSSString.php +++ /dev/null @@ -1,32 +0,0 @@ -sString = $sString; - parent::__construct($iLineNo); - } - - public function setString($sString) { - $this->sString = $sString; - } - - public function getString() { - return $this->sString; - } - - public function __toString() { - return $this->render(new \Sabberworm\CSS\OutputFormat()); - } - - public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat) { - $sString = addslashes($this->sString); - $sString = str_replace("\n", '\A', $sString); - return $oOutputFormat->getStringQuotingType() . $sString . $oOutputFormat->getStringQuotingType(); - } - -} \ No newline at end of file diff --git a/lib/Sabberworm/CSS/Value/Color.php b/lib/Sabberworm/CSS/Value/Color.php deleted file mode 100644 index e05b924a6..000000000 --- a/lib/Sabberworm/CSS/Value/Color.php +++ /dev/null @@ -1,41 +0,0 @@ -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()); - } - - 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/PrimitiveValue.php b/lib/Sabberworm/CSS/Value/PrimitiveValue.php deleted file mode 100644 index 187ce7e6a..000000000 --- a/lib/Sabberworm/CSS/Value/PrimitiveValue.php +++ /dev/null @@ -1,10 +0,0 @@ -fSize = floatval($fSize); - $this->sUnit = $sUnit; - $this->bIsColorComponent = $bIsColorComponent; - } - - public function setUnit($sUnit) { - $this->sUnit = $sUnit; - } - - public function getUnit() { - return $this->sUnit; - } - - public function setSize($fSize) { - $this->fSize = floatval($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()); - } - - public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat) { - $l = localeconv(); - $sPoint = preg_quote($l['decimal_point'], '/'); - return preg_replace(array("/$sPoint/", "/^(-?)0\./"), array('.', '$1.'), $this->fSize) . ($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 02cf5812d..000000000 --- a/lib/Sabberworm/CSS/Value/URL.php +++ /dev/null @@ -1,31 +0,0 @@ -oURL = $oURL; - } - - public function setURL(CSSString $oURL) { - $this->oURL = $oURL; - } - - public function getURL() { - return $this->oURL; - } - - public function __toString() { - return $this->render(new \Sabberworm\CSS\OutputFormat()); - } - - public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat) { - return "url({$this->oURL->render($oOutputFormat)})"; - } - -} \ No newline at end of file diff --git a/lib/Sabberworm/CSS/Value/Value.php b/lib/Sabberworm/CSS/Value/Value.php deleted file mode 100644 index 5d30bd97f..000000000 --- a/lib/Sabberworm/CSS/Value/Value.php +++ /dev/null @@ -1,24 +0,0 @@ -iLineNo = $iLineNo; - } - - /** - * @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 5c3d0e4fb..000000000 --- a/lib/Sabberworm/CSS/Value/ValueList.php +++ /dev/null @@ -1,47 +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()); - } - - 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/phpunit.xml b/phpunit.xml index 229736986..aab1f10c4 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1 +1,24 @@ - + + + + tests + + + + + + 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/OutputFormatTest.php b/tests/Sabberworm/CSS/OutputFormatTest.php deleted file mode 100644 index 238b5ba58..000000000 --- a/tests/Sabberworm/CSS/OutputFormatTest.php +++ /dev/null @@ -1,170 +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(array('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))); - } - -} \ No newline at end of file diff --git a/tests/Sabberworm/CSS/ParserTest.php b/tests/Sabberworm/CSS/ParserTest.php deleted file mode 100644 index 8d333bbf9..000000000 --- a/tests/Sabberworm/CSS/ParserTest.php +++ /dev/null @@ -1,508 +0,0 @@ -assertNotEquals('', $oParser->parse()->render()); - } catch (\Exception $e) { - $this->fail($e); - } - } - closedir($rHandle); - } - } - - /** - * @depends testFiles - */ - 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(array('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(array('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(array('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(array('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()); - } else if($sSelector === '#yours') { - $aColorRule = $oRuleSet->getRules('background-color'); - $oColor = $aColorRule[0]->getValue(); - $this->assertEquals(array('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(array('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);}', $oDoc->render()); - } - - 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); - } - } - } - - 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(array(new Selector('#test .help', true)), $oDoc->getSelectorsBySpecificity('> 100')); - } - - 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(http://www.w3.org/), - url-prefix(http://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(http://www.w3.org/), - url-prefix(http://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()); - } - - function testRuleGetters() { - $oDoc = $this->parsedStructureForFile('values'); - $aBlocks = $oDoc->getAllDeclarationBlocks(); - $oHeaderBlock = $aBlocks[0]; - $oBodyBlock = $aBlocks[1]; - $aHeaderRules = $oHeaderBlock->getRules('background-'); - $this->assertSame(2, count($aHeaderRules)); - $this->assertSame('background-color', $aHeaderRules[0]->getRule()); - $this->assertSame('background-color', $aHeaderRules[1]->getRule()); - $aHeaderRules = $oHeaderBlock->getRulesAssoc('background-'); - $this->assertSame(1, count($aHeaderRules)); - $this->assertSame(true, $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->assertSame(1, count($aHeaderRules)); - $this->assertSame('green', $aHeaderRules[0]->getValue()); - } - - 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()); - } - - 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(array('1.2em', '.2em', '60%'), array('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(array('.2s', '.3s', '90deg'), array('.4s', '.6s', '180deg'), $sExpected); - $this->assertSame($sExpected, $oDoc->render()); - } - - 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()); - } - - 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()); - } - - 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()); - } - - 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()); - } - - function testPrefixedGradient() { - $oDoc = $this->parsedStructureForFile('webkit'); - $sExpected = '.test {background: -webkit-linear-gradient(top right,white,black);}'; - $this->assertSame($sExpected, $oDoc->render()); - } - - 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 - */ - function testSelectorRemoval() { - $oDoc = $this->parsedStructureForFile('1readme'); - $aBlocks = $oDoc->getAllDeclarationBlocks(); - $oBlock1 = $aBlocks[0]; - $this->assertSame(true, $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->assertSame(false, $oBlock1->removeSelector('html')); - $this->assertSame(true, $oBlock1->removeSelector('body')); - // This tries to output a declaration block without a selector and throws. - $oDoc->render(); - } - - 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()); - } - - function testUrlInFile() { - $oDoc = $this->parsedStructureForFile('url', Settings::create()->withMultibyteSupport(true)); - $sExpected = 'body {background: #fff url("http://somesite.com/images/someimage.gif") repeat top center;} -body {background-url: url("http://somesite.com/images/someimage.gif");}'; - $this->assertSame($sExpected, $oDoc->render()); - } - - function testUrlInFileMbOff() { - $oDoc = $this->parsedStructureForFile('url', Settings::create()->withMultibyteSupport(false)); - $sExpected = 'body {background: #fff url("http://somesite.com/images/someimage.gif") repeat top center;} -body {background-url: url("http://somesite.com/images/someimage.gif");}'; - $this->assertSame($sExpected, $oDoc->render()); - } - - function testEmptyFile() { - $oDoc = $this->parsedStructureForFile('-empty', Settings::create()->withMultibyteSupport(true)); - $sExpected = ''; - $this->assertSame($sExpected, $oDoc->render()); - } - - function testEmptyFileMbOff() { - $oDoc = $this->parsedStructureForFile('-empty', Settings::create()->withMultibyteSupport(false)); - $sExpected = ''; - $this->assertSame($sExpected, $oDoc->render()); - } - - function testCharsetLenient1() { - $oDoc = $this->parsedStructureForFile('-charset-after-rule', Settings::create()->withLenientParsing(true)); - $sExpected = '#id {prop: var(--val);}'; - $this->assertSame($sExpected, $oDoc->render()); - } - - function testCharsetLenient2() { - $oDoc = $this->parsedStructureForFile('-charset-in-block', Settings::create()->withLenientParsing(true)); - $sExpected = '@media print {}'; - $this->assertSame($sExpected, $oDoc->render()); - } - - /** - * @expectedException Sabberworm\CSS\Parsing\UnexpectedTokenException - */ - function testCharsetFailure1() { - $this->parsedStructureForFile('-charset-after-rule', Settings::create()->withLenientParsing(false)); - } - - /** - * @expectedException Sabberworm\CSS\Parsing\UnexpectedTokenException - */ - function testCharsetFailure2() { - $this->parsedStructureForFile('-charset-in-block', Settings::create()->withLenientParsing(false)); - } - - function parsedStructureForFile($sFileName, $oSettings = null) { - $sFile = dirname(__FILE__) . '/../../files' . DIRECTORY_SEPARATOR . "$sFileName.css"; - $oParser = new Parser(file_get_contents($sFile), $oSettings); - return $oParser->parse(); - } - - /** - * @depends testFiles - */ - function testLineNumbersParsing() { - $oDoc = $this->parsedStructureForFile('line-numbers'); - // array key is the expected line number - $aExpected = array( - 1 => array('Sabberworm\CSS\Property\Charset'), - 3 => array('Sabberworm\CSS\Property\CSSNamespace'), - 5 => array('Sabberworm\CSS\RuleSet\AtRuleSet'), - 11 => array('Sabberworm\CSS\RuleSet\DeclarationBlock'), - // Line Numbers of the inner declaration blocks - 17 => array('Sabberworm\CSS\CSSList\KeyFrame', 18, 20), - 23 => array('Sabberworm\CSS\Property\Import'), - 25 => array('Sabberworm\CSS\RuleSet\DeclarationBlock') - ); - - $aActual = array(); - foreach ($oDoc->getContents() as $oContent) { - $aActual[$oContent->getLineNo()] = array(get_class($oContent)); - if ($oContent instanceof KeyFrame) { - foreach ($oContent->getContents() as $block) { - $aActual[$oContent->getLineNo()][] = $block->getLineNo(); - } - } - } - - $aUrlExpected = array(7, 26); // expected line numbers - $aUrlActual = array(); - foreach ($oDoc->getAllValues() as $oValue) { - if ($oValue instanceof URL) { - $aUrlActual[] = $oValue->getLineNo(); - } - } - - // Checking for the multiline color rule lines 27-31 - $aExpectedColorLines = array(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 ) - */ - function testUnexpectedTokenExceptionLineNo() { - $oParser = new Parser("\ntest: 1;", Settings::create()->beStrict()); - try { - $oParser->parse(); - } catch (UnexpectedTokenException $e) { - $this->assertSame(2, $e->getLineNo()); - throw $e; - } - } -} diff --git a/tests/Sabberworm/CSS/RuleSet/DeclarationBlockTest.php b/tests/Sabberworm/CSS/RuleSet/DeclarationBlockTest.php deleted file mode 100644 index 7a545d03f..000000000 --- a/tests/Sabberworm/CSS/RuleSet/DeclarationBlockTest.php +++ /dev/null @@ -1,208 +0,0 @@ -parse(); - foreach ($oDoc->getAllDeclarationBlocks() as $oDeclaration) { - $oDeclaration->expandBorderShorthand(); - } - $this->assertSame(trim((string) $oDoc), $sExpected); - } - - public function expandBorderShorthandProvider() { - return array( - array('body{ border: 2px solid #000 }', 'body {border-width: 2px;border-style: solid;border-color: #000;}'), - array('body{ border: none }', 'body {border-style: none;}'), - array('body{ border: 2px }', 'body {border-width: 2px;}'), - array('body{ border: #f00 }', 'body {border-color: #f00;}'), - array('body{ border: 1em solid }', 'body {border-width: 1em;border-style: solid;}'), - array('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 array( - array( - 'body{ margin: 1em; }', - 'body {margin: 1em;}' - ), - array( - 'body {font: 12px serif;}', - 'body {font-style: normal;font-variant: normal;font-weight: normal;font-size: 12px;line-height: normal;font-family: serif;}' - ), - array( - 'body {font: italic 12px serif;}', - 'body {font-style: italic;font-variant: normal;font-weight: normal;font-size: 12px;line-height: normal;font-family: serif;}' - ), - array( - '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;}' - ), - array( - '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;}' - ), - array( - '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 array( - array('body {border: 1px;}', 'body {border: 1px;}'), - array('body {background: #f00;}', 'body {background-color: #f00;background-image: none;background-repeat: repeat;background-attachment: scroll;background-position: 0% 0%;}'), - array('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%;}'), - array('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%;}'), - array('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;}'), - array('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 array( - array('body {border: 1px;}', 'body {border: 1px;}'), - array('body {margin-top: 1px;}', 'body {margin-top: 1px;}'), - array('body {margin: 1em;}', 'body {margin-top: 1em;margin-right: 1em;margin-bottom: 1em;margin-left: 1em;}'), - array('body {margin: 1em 2em;}', 'body {margin-top: 1em;margin-right: 2em;margin-bottom: 1em;margin-left: 2em;}'), - array('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 array( - array('body {border-width: 2px;border-style: solid;border-color: #000;}', 'body {border: 2px solid #000;}'), - array('body {border-style: none;}', 'body {border: none;}'), - array('body {border-width: 1em;border-style: solid;}', 'body {border: 1em solid;}'), - array('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 array( - array('body {font-size: 12px; font-family: serif}', 'body {font: 12px serif;}'), - array('body {font-size: 12px; font-family: serif; font-style: italic;}', 'body {font: italic 12px serif;}'), - array('body {font-size: 12px; font-family: serif; font-style: italic; font-weight: bold;}', 'body {font: italic bold 12px serif;}'), - array('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;}'), - array('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;}'), - array('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 array( - array('body {border: 1px;}', 'body {border: 1px;}'), - array('body {margin-top: 1px;}', 'body {margin-top: 1px;}'), - array('body {margin-top: 1em; margin-right: 1em; margin-bottom: 1em; margin-left: 1em;}', 'body {margin: 1em;}'), - array('body {margin-top: 1em; margin-right: 2em; margin-bottom: 1em; margin-left: 2em;}', 'body {margin: 1em 2em;}'), - array('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 array( - array('body {border: 1px;}', 'body {border: 1px;}'), - array('body {background-color: #f00;}', 'body {background: #f00;}'), - array('body {background-color: #f00;background-image: url(foobar.png);}', 'body {background: #f00 url("foobar.png");}'), - array('body {background-color: #f00;background-image: url(foobar.png);background-repeat: no-repeat;}', 'body {background: #f00 url("foobar.png") no-repeat;}'), - array('body {background-color: #f00;background-image: url(foobar.png);background-repeat: no-repeat;}', 'body {background: #f00 url("foobar.png") no-repeat;}'), - array('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;}'), - array('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;}'), - ); - } - -} diff --git a/tests/Sabberworm/CSS/RuleSet/LenientParsingTest.php b/tests/Sabberworm/CSS/RuleSet/LenientParsingTest.php deleted file mode 100644 index d7005ba2f..000000000 --- a/tests/Sabberworm/CSS/RuleSet/LenientParsingTest.php +++ /dev/null @@ -1,76 +0,0 @@ -beStrict()); - $oParser->parse(); - } - - public function testFaultToleranceOn() { - $sFile = dirname(__FILE__) . '/../../../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 = dirname(__FILE__) . '/../../../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 = dirname(__FILE__) . '/../../../files' . DIRECTORY_SEPARATOR . "-end-token-2.css"; - $oParser = new Parser(file_get_contents($sFile), Settings::create()->beStrict()); - $oParser->parse(); - } - - public function testEndTokenPositive() { - $sFile = dirname(__FILE__) . '/../../../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 = dirname(__FILE__) . '/../../../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 = dirname(__FILE__) . '/../../../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 = dirname(__FILE__) . '/../../../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 7c4de8144..000000000 --- a/tests/bootstrap.php +++ /dev/null @@ -1,10 +0,0 @@ - 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; +} +html[dir="rtl"] .super-menu.menu-floated > li:first-of-type +border-right-width:0; +} +} + + +.super-menu.menu-floated{ +border-right-width:1px; +border-left-width:1px; +border-color:rgb(90, 66, 66); +border-style:dotted; +} + +body { + background-color: red; +} diff --git a/tests/fixtures/invalid-selectors.css b/tests/fixtures/invalid-selectors.css new file mode 100644 index 000000000..1c633c573 --- /dev/null +++ b/tests/fixtures/invalid-selectors.css @@ -0,0 +1,24 @@ +@keyframes mymove { + from { top: 0px; } +} + +#test { + color: white; + background: green; +} + +body + background: black; + } + +#test { + display: block; + background: red; + color: white; +} +#test { + display: block; + background: white; + color: black; +} + diff --git a/tests/fixtures/keyframe-selector-validation.css b/tests/fixtures/keyframe-selector-validation.css new file mode 100644 index 000000000..1a1addd76 --- /dev/null +++ b/tests/fixtures/keyframe-selector-validation.css @@ -0,0 +1,11 @@ +@-webkit-keyframes zoom { + 0% { + -webkit-transform: scale(1,1); + } + 50% { + -webkit-transform: scale(1.2,1.2); + } + 100% { + -webkit-transform: scale(1,1); + } +} diff --git a/tests/fixtures/large-z-index.css b/tests/fixtures/large-z-index.css new file mode 100644 index 000000000..779c8d098 --- /dev/null +++ b/tests/fixtures/large-z-index.css @@ -0,0 +1,3 @@ +.overlay { + z-index: 9999999999999999999999; +} diff --git a/tests/files/line-numbers.css b/tests/fixtures/line-numbers.css similarity index 77% rename from tests/files/line-numbers.css rename to tests/fixtures/line-numbers.css index 73d3189f8..6c27f489c 100644 --- a/tests/files/line-numbers.css +++ b/tests/fixtures/line-numbers.css @@ -4,7 +4,7 @@ @font-face { /* line 5 */ font-family: "CrassRoots"; - src: url("http://example.com/media/cr.ttf") /* line 7 */ + src: url("https://example.com/media/cr.ttf") /* line 7 */ } @@ -23,7 +23,7 @@ @IMPORT uRL(test.css); /* line 23 */ body { - background: #FFFFFF url("http://somesite.com/images/someimage.gif") repeat top center; /* line 25 */ + background: #FFFFFF url("https://somesite.com/images/someimage.gif") repeat top center; /* line 25 */ color: rgb( /* line 27 */ 233, /* line 28 */ 100, /* line 29 */ diff --git a/tests/fixtures/lonely-import.css b/tests/fixtures/lonely-import.css new file mode 100644 index 000000000..87767bbaf --- /dev/null +++ b/tests/fixtures/lonely-import.css @@ -0,0 +1 @@ +@import "example.css" only screen and (max-width: 600px) \ No newline at end of file diff --git a/tests/fixtures/missing-property-value.css b/tests/fixtures/missing-property-value.css new file mode 100644 index 000000000..22d87c8f4 --- /dev/null +++ b/tests/fixtures/missing-property-value.css @@ -0,0 +1,4 @@ +div { + display: inline-block; + display: +} diff --git a/tests/fixtures/ms-filter.css b/tests/fixtures/ms-filter.css new file mode 100644 index 000000000..c2e7e1ad0 --- /dev/null +++ b/tests/fixtures/ms-filter.css @@ -0,0 +1 @@ +.test {filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);} diff --git a/tests/files/namespaces.css b/tests/fixtures/namespaces.css similarity index 51% rename from tests/files/namespaces.css rename to tests/fixtures/namespaces.css index ffd7a5890..3577c026d 100644 --- a/tests/files/namespaces.css +++ b/tests/fixtures/namespaces.css @@ -1,18 +1,18 @@ -/* From the spec at http://www.w3.org/TR/css3-namespace/ */ +/* From the spec at https://www.w3.org/TR/css3-namespace/ */ @namespace toto "http://toto.example.org"; @namespace "http://example.com/foo"; -/* From an introduction at http://www.blooberry.com/indexdot/css/syntax/atrules/namespace.htm */ +/* From an introduction at https://www.blooberry.com/indexdot/css/syntax/atrules/namespace.htm */ @namespace foo url("http://www.example.com/"); @namespace foo url('http://www.example.com/'); foo|test { - gaga: 1; + gaga: 1; } |test { - gaga: 2; -} \ No newline at end of file + gaga: 2; +} diff --git a/tests/fixtures/nested.css b/tests/fixtures/nested.css new file mode 100644 index 000000000..e1f41fe69 --- /dev/null +++ b/tests/fixtures/nested.css @@ -0,0 +1,17 @@ +html { + some: -test(val1); +} + +html { + some-other: -test(val1); +} + +@media screen { + html { + some: -test(val2); + } +} + +#unrelated { + other: yes; +} diff --git a/tests/fixtures/scientific-notation-numbers.css b/tests/fixtures/scientific-notation-numbers.css new file mode 100644 index 000000000..cbed23371 --- /dev/null +++ b/tests/fixtures/scientific-notation-numbers.css @@ -0,0 +1,6 @@ +body { + background-color: rgba(62,174,151,3.0418206565232E+21); + z-index: 3.0418206565232E-2; + font-size: 1em; + top: 1.923478e2px; +} diff --git a/tests/fixtures/selector-escapes.css b/tests/fixtures/selector-escapes.css new file mode 100644 index 000000000..7797e06fb --- /dev/null +++ b/tests/fixtures/selector-escapes.css @@ -0,0 +1,7 @@ +#\# { + color: red; +} + +.col-sm-1\/5 { + width: 20%; +} diff --git a/tests/files/-tobedone.css b/tests/fixtures/selector-ignores.css similarity index 62% rename from tests/files/-tobedone.css rename to tests/fixtures/selector-ignores.css index d9fc1117b..5834e0098 100644 --- a/tests/files/-tobedone.css +++ b/tests/fixtures/selector-ignores.css @@ -1,9 +1,13 @@ .some[selectors-may='contain-a-{'] { - + +} + +.this-selector /* should remain-} */ .valid { + width:100px; } @media only screen and (min-width: 200px) { .test { prop: val; } -} \ No newline at end of file +} diff --git a/tests/fixtures/slashed.css b/tests/fixtures/slashed.css new file mode 100644 index 000000000..86f4ee826 --- /dev/null +++ b/tests/fixtures/slashed.css @@ -0,0 +1,4 @@ +.test { + font: 12px/1.5 Verdana, Arial, sans-serif; + border-radius: 5px 10px 5px 10px / 10px 5px 10px 5px; +} diff --git a/tests/files/specificity.css b/tests/fixtures/specificity.css similarity index 68% rename from tests/files/specificity.css rename to tests/fixtures/specificity.css index 82a2939a1..1a7a0121b 100644 --- a/tests/files/specificity.css +++ b/tests/fixtures/specificity.css @@ -3,5 +3,5 @@ .help:hover, li.green, ol li::before { - font-family: Helvetica; + font-family: Helvetica; } diff --git a/tests/fixtures/trailing-whitespace.css b/tests/fixtures/trailing-whitespace.css new file mode 100644 index 000000000..159e50c7a --- /dev/null +++ b/tests/fixtures/trailing-whitespace.css @@ -0,0 +1,2 @@ +div { width: 200px; } + diff --git a/tests/fixtures/unicode-range.css b/tests/fixtures/unicode-range.css new file mode 100644 index 000000000..d5e152a08 --- /dev/null +++ b/tests/fixtures/unicode-range.css @@ -0,0 +1,3 @@ +@font-face { + unicode-range: U+0100-024F, U+0259, U+1E??-2EFF, U+202F; +} diff --git a/tests/files/unicode.css b/tests/fixtures/unicode.css similarity index 100% rename from tests/files/unicode.css rename to tests/fixtures/unicode.css diff --git a/tests/fixtures/unmatched_braces.css b/tests/fixtures/unmatched_braces.css new file mode 100644 index 000000000..b7172762b --- /dev/null +++ b/tests/fixtures/unmatched_braces.css @@ -0,0 +1,18 @@ +button,input,checkbox,textarea { + outline: 0; + margin: 0; +} + +@media only screen and (-webkit-min-device-pixel-ratio: 1.5), only screen and (-o-min-device-pixel-ratio:3/@media all and (orientation:portrait) { + #wrapper { + max-width:640px; + margin: 0 auto; + } +} + +@media all and (orientation: landscape) { + #wrapper { + max-width:640px; + margin: 0 auto; + } +} diff --git a/tests/fixtures/url.css b/tests/fixtures/url.css new file mode 100644 index 000000000..feb91bc33 --- /dev/null +++ b/tests/fixtures/url.css @@ -0,0 +1,4 @@ +body { background: #FFFFFF url("https://somesite.com/images/someimage.gif") repeat top center; } +body { + background-url: url("https://somesite.com/images/someimage.gif"); +} diff --git a/tests/fixtures/values.css b/tests/fixtures/values.css new file mode 100644 index 000000000..f00c0768e --- /dev/null +++ b/tests/fixtures/values.css @@ -0,0 +1,15 @@ +#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,0.7); + frequency: 30Hz; + transform: rotate(1turn); +} + +body { + color: green; + font: 75% "Lucida Grande", "Trebuchet MS", Verdana, sans-serif; +} diff --git a/tests/files/webkit.css b/tests/fixtures/webkit.css similarity index 100% rename from tests/files/webkit.css rename to tests/fixtures/webkit.css diff --git a/tests/fixtures/whitespace.css b/tests/fixtures/whitespace.css new file mode 100644 index 000000000..de127ece0 --- /dev/null +++ b/tests/fixtures/whitespace.css @@ -0,0 +1,3 @@ +.test { + background-image : url ( 4px ) ; +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml deleted file mode 100644 index 5dcbab2b3..000000000 --- a/tests/phpunit.xml +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/quickdump.php b/tests/quickdump.php deleted file mode 100644 index 387cab72e..000000000 --- a/tests/quickdump.php +++ /dev/null @@ -1,19 +0,0 @@ -parse(); -echo "\n".'#### Input'."\n\n```css\n"; -print $sSource; - -echo "\n```\n\n".'#### Structure (`var_dump()`)'."\n\n```php\n"; -var_dump($oDoc); - -echo "\n```\n\n".'#### Output (`render()`)'."\n\n```css\n"; -print $oDoc->render(); - -echo "\n```\n"; -